From 9485e2cfee0c43f8b758bce99411b62a3a456a5d Mon Sep 17 00:00:00 2001 From: Ermanno Baschiera Date: Thu, 10 Sep 2026 21:07:44 +0200 Subject: [PATCH] Add charging mode select to Silla Prism (#181424) --- homeassistant/components/silla_prism/const.py | 2 +- .../components/silla_prism/coordinator.py | 6 +- .../components/silla_prism/select.py | 54 +++++++++++ .../components/silla_prism/strings.json | 10 ++ tests/components/silla_prism/__init__.py | 19 +++- .../silla_prism/snapshots/test_select.ambr | 62 ++++++++++++ tests/components/silla_prism/test_select.py | 96 +++++++++++++++++++ tests/components/silla_prism/test_sensor.py | 4 +- 8 files changed, 245 insertions(+), 8 deletions(-) create mode 100644 homeassistant/components/silla_prism/select.py create mode 100644 tests/components/silla_prism/snapshots/test_select.ambr create mode 100644 tests/components/silla_prism/test_select.py diff --git a/homeassistant/components/silla_prism/const.py b/homeassistant/components/silla_prism/const.py index 8486babc1ac4..07f18a1ad251 100644 --- a/homeassistant/components/silla_prism/const.py +++ b/homeassistant/components/silla_prism/const.py @@ -6,7 +6,7 @@ from homeassistant.const import Platform DOMAIN: Final = "silla_prism" -PLATFORMS: Final = [Platform.SENSOR] +PLATFORMS: Final = [Platform.SELECT, Platform.SENSOR] CONF_BASE_TOPIC: Final = "base_topic" diff --git a/homeassistant/components/silla_prism/coordinator.py b/homeassistant/components/silla_prism/coordinator.py index 8dc8922d4688..5367a70ab2d2 100644 --- a/homeassistant/components/silla_prism/coordinator.py +++ b/homeassistant/components/silla_prism/coordinator.py @@ -39,7 +39,7 @@ class PrismCoordinator(DataUpdateCoordinator[PrismStatus]): """Initialize the coordinator.""" super().__init__(hass, _LOGGER, config_entry=entry, name=DOMAIN) self.base_topic: str = entry.data[CONF_BASE_TOPIC] - self.device = PrismDevice(self.base_topic) + self.device = PrismDevice(self.base_topic, publish=self._async_publish) self.device.on_status_update = self._on_status_update self.device.on_hello = self._on_hello @@ -60,6 +60,10 @@ class PrismCoordinator(DataUpdateCoordinator[PrismStatus]): """Return the accumulated state (populated by retained messages).""" return self.device.status + async def _async_publish(self, topic: str, payload: str) -> None: + """Publish a command built by the library.""" + await mqtt.async_publish(self.hass, topic, payload) + @callback def _message_received(self, msg: ReceiveMessage) -> None: if isinstance(msg.payload, str): diff --git a/homeassistant/components/silla_prism/select.py b/homeassistant/components/silla_prism/select.py new file mode 100644 index 000000000000..66d0275a74a7 --- /dev/null +++ b/homeassistant/components/silla_prism/select.py @@ -0,0 +1,54 @@ +"""Select platform for the Silla Prism integration.""" + +from typing import override + +from pysillaprism import SETTABLE_MODES + +from homeassistant.components.select import SelectEntity +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from .const import PORT +from .coordinator import PrismConfigEntry, PrismCoordinator +from .entity import PrismEntity + +PARALLEL_UPDATES = 0 + +OPTION_TO_MODE = {mode.name.lower(): mode for mode in SETTABLE_MODES} +MODE_TO_OPTION = {mode: option for option, mode in OPTION_TO_MODE.items()} + + +async def async_setup_entry( + hass: HomeAssistant, + entry: PrismConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up the Prism select entities.""" + async_add_entities([PrismModeSelect(entry.runtime_data)]) + + +class PrismModeSelect(PrismEntity, SelectEntity): + """Charging mode selector (Solar/Normal/Pause).""" + + _attr_translation_key = "charging_mode" + _attr_options = list(OPTION_TO_MODE) + + def __init__(self, coordinator: PrismCoordinator) -> None: + """Initialize the select entity.""" + super().__init__(coordinator, "charging_mode") + + @property + @override + def current_option(self) -> str | None: + """Return the current mode, or None when it is not user-settable. + + Prism also reports a load-balancing pause, which cannot be selected + back; it surfaces on the status sensor instead. + """ + mode = self.coordinator.device.status.port(PORT).mode + return MODE_TO_OPTION.get(mode) if mode is not None else None + + @override + async def async_select_option(self, option: str) -> None: + """Change the charging mode.""" + await self.coordinator.device.set_mode(OPTION_TO_MODE[option], PORT) diff --git a/homeassistant/components/silla_prism/strings.json b/homeassistant/components/silla_prism/strings.json index 2b7dce9a180d..503268a742aa 100644 --- a/homeassistant/components/silla_prism/strings.json +++ b/homeassistant/components/silla_prism/strings.json @@ -28,6 +28,16 @@ } }, "entity": { + "select": { + "charging_mode": { + "name": "Charging mode", + "state": { + "normal": "[%key:common::state::normal%]", + "pause": "[%key:common::state::paused%]", + "solar": "Solar" + } + } + }, "sensor": { "error": { "name": "Error", diff --git a/tests/components/silla_prism/__init__.py b/tests/components/silla_prism/__init__.py index 8d7d15c71e98..3fd9389618f3 100644 --- a/tests/components/silla_prism/__init__.py +++ b/tests/components/silla_prism/__init__.py @@ -1,5 +1,9 @@ """Tests for the Silla Prism integration.""" +from unittest.mock import patch + +from homeassistant.components.silla_prism.const import PLATFORMS +from homeassistant.const import Platform from homeassistant.core import HomeAssistant from .const import RETAINED_BURST @@ -7,10 +11,17 @@ from .const import RETAINED_BURST from tests.common import MockConfigEntry, async_fire_mqtt_message -async def setup_integration(hass: HomeAssistant, entry: MockConfigEntry) -> None: - """Set up the Silla Prism integration.""" - assert await hass.config_entries.async_setup(entry.entry_id) - await hass.async_block_till_done() +async def setup_integration( + hass: HomeAssistant, + entry: MockConfigEntry, + platforms: list[Platform] | None = None, +) -> None: + """Set up the Silla Prism integration, optionally limited to some platforms.""" + with patch( + "homeassistant.components.silla_prism.PLATFORMS", platforms or PLATFORMS + ): + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() async def fire_burst(hass: HomeAssistant) -> None: diff --git a/tests/components/silla_prism/snapshots/test_select.ambr b/tests/components/silla_prism/snapshots/test_select.ambr new file mode 100644 index 000000000000..93ddc643b57d --- /dev/null +++ b/tests/components/silla_prism/snapshots/test_select.ambr @@ -0,0 +1,62 @@ +# serializer version: 1 +# name: test_selects[select.silla_prism_charging_mode-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : list([ + 'solar', + 'normal', + 'pause', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': None, + 'entity_id': 'select.silla_prism_charging_mode', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Charging mode', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Charging mode', + 'platform': 'silla_prism', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'charging_mode', + 'unique_id': 'prism_charging_mode', + 'unit_of_measurement': None, + }) +# --- +# name: test_selects[select.silla_prism_charging_mode-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'Silla Prism Charging mode', + : list([ + 'solar', + 'normal', + 'pause', + ]), + }), + 'context': , + 'entity_id': 'select.silla_prism_charging_mode', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'normal', + }) +# --- diff --git a/tests/components/silla_prism/test_select.py b/tests/components/silla_prism/test_select.py new file mode 100644 index 000000000000..5c3c7aa604a1 --- /dev/null +++ b/tests/components/silla_prism/test_select.py @@ -0,0 +1,96 @@ +"""Test the Silla Prism selects.""" + +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.select import ( + ATTR_OPTION, + DOMAIN as SELECT_DOMAIN, + SERVICE_SELECT_OPTION, +) +from homeassistant.const import ATTR_ENTITY_ID, STATE_UNKNOWN, Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import fire_burst, setup_integration + +from tests.common import MockConfigEntry, async_fire_mqtt_message, snapshot_platform +from tests.typing import MqttMockHAClient + +CHARGING_MODE_ENTITY_ID = "select.silla_prism_charging_mode" +MODE_TOPIC = "prism/1/mode" +SET_MODE_TOPIC = "prism/1/command/set_mode" + + +async def test_selects( + hass: HomeAssistant, + mqtt_mock: MqttMockHAClient, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, +) -> None: + """Test the selects.""" + await setup_integration(hass, mock_config_entry, [Platform.SELECT]) + await fire_burst(hass) + + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) + + +@pytest.mark.parametrize( + ("option", "payload"), + [("solar", "1"), ("normal", "2"), ("pause", "3")], +) +async def test_select_option( + hass: HomeAssistant, + mqtt_mock: MqttMockHAClient, + mock_config_entry: MockConfigEntry, + option: str, + payload: str, +) -> None: + """Test that selecting a mode publishes the matching command.""" + await setup_integration(hass, mock_config_entry) + await fire_burst(hass) + + await hass.services.async_call( + SELECT_DOMAIN, + SERVICE_SELECT_OPTION, + {ATTR_ENTITY_ID: CHARGING_MODE_ENTITY_ID, ATTR_OPTION: option}, + blocking=True, + ) + + mqtt_mock.async_publish.assert_called_once_with( + SET_MODE_TOPIC, payload, 0, False, message_expiry_interval=None + ) + + +@pytest.mark.usefixtures("mqtt_mock") +async def test_mode_updates( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, +) -> None: + """Test that the selected mode follows the reported one.""" + await setup_integration(hass, mock_config_entry) + await fire_burst(hass) + + assert hass.states.get(CHARGING_MODE_ENTITY_ID).state == "normal" + + async_fire_mqtt_message(hass, MODE_TOPIC, "1") + await hass.async_block_till_done() + + assert hass.states.get(CHARGING_MODE_ENTITY_ID).state == "solar" + + +@pytest.mark.usefixtures("mqtt_mock") +async def test_mode_not_selectable( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, +) -> None: + """Test that a mode Prism cannot be put back into reads as unknown.""" + await setup_integration(hass, mock_config_entry) + await fire_burst(hass) + + # Load balancing suspended the session: reported, but not user-settable. + async_fire_mqtt_message(hass, MODE_TOPIC, "7") + await hass.async_block_till_done() + + assert hass.states.get(CHARGING_MODE_ENTITY_ID).state == STATE_UNKNOWN diff --git a/tests/components/silla_prism/test_sensor.py b/tests/components/silla_prism/test_sensor.py index 3aa198ae07f1..cd61637b6da5 100644 --- a/tests/components/silla_prism/test_sensor.py +++ b/tests/components/silla_prism/test_sensor.py @@ -6,7 +6,7 @@ from freezegun.api import FrozenDateTimeFactory import pytest from syrupy.assertion import SnapshotAssertion -from homeassistant.const import STATE_UNKNOWN +from homeassistant.const import STATE_UNKNOWN, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er @@ -33,7 +33,7 @@ async def test_sensors( ) -> None: """Test the sensors.""" freezer.move_to("2026-01-01 00:00:00+00:00") - await setup_integration(hass, mock_config_entry) + await setup_integration(hass, mock_config_entry, [Platform.SENSOR]) await fire_burst(hass) await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id)