From 9ba28150e924f57e255df88ef63ca680bbd8d30c Mon Sep 17 00:00:00 2001 From: konsulten Date: Tue, 24 Feb 2026 21:29:46 +0100 Subject: [PATCH] Add light platform to systemnexa2 (#163710) Co-authored-by: Joost Lekkerkerker --- homeassistant/components/systemnexa2/const.py | 2 +- .../components/systemnexa2/coordinator.py | 6 + homeassistant/components/systemnexa2/light.py | 74 +++++ .../components/systemnexa2/strings.json | 7 +- tests/components/systemnexa2/conftest.py | 76 +++-- .../systemnexa2/snapshots/test_light.ambr | 60 ++++ .../systemnexa2/snapshots/test_switch.ambr | 40 +-- .../systemnexa2/test_config_flow.py | 34 +- tests/components/systemnexa2/test_light.py | 302 ++++++++++++++++++ tests/components/systemnexa2/test_switch.py | 86 +++-- 10 files changed, 573 insertions(+), 114 deletions(-) create mode 100644 homeassistant/components/systemnexa2/light.py create mode 100644 tests/components/systemnexa2/snapshots/test_light.ambr create mode 100644 tests/components/systemnexa2/test_light.py diff --git a/homeassistant/components/systemnexa2/const.py b/homeassistant/components/systemnexa2/const.py index 952c2286d061..8931c297ec4d 100644 --- a/homeassistant/components/systemnexa2/const.py +++ b/homeassistant/components/systemnexa2/const.py @@ -6,4 +6,4 @@ from homeassistant.const import Platform DOMAIN = "systemnexa2" MANUFACTURER = "NEXA" -PLATFORMS: Final = [Platform.SWITCH] +PLATFORMS: Final = [Platform.LIGHT, Platform.SWITCH] diff --git a/homeassistant/components/systemnexa2/coordinator.py b/homeassistant/components/systemnexa2/coordinator.py index c22a6075d800..d52702148f6d 100644 --- a/homeassistant/components/systemnexa2/coordinator.py +++ b/homeassistant/components/systemnexa2/coordinator.py @@ -156,6 +156,12 @@ class SystemNexa2DataUpdateCoordinator(DataUpdateCoordinator[SystemNexa2Data]): """Toggle the device.""" await self._async_sn2_call_with_error_handling(self.device.toggle()) + async def async_set_brightness(self, value: float) -> None: + """Set the brightness of the device (0.0 to 1.0).""" + await self._async_sn2_call_with_error_handling( + self.device.set_brightness(value) + ) + async def async_setting_enable(self, setting: OnOffSetting) -> None: """Enable a device setting.""" await self._async_sn2_call_with_error_handling(setting.enable(self.device)) diff --git a/homeassistant/components/systemnexa2/light.py b/homeassistant/components/systemnexa2/light.py new file mode 100644 index 000000000000..7a28988db705 --- /dev/null +++ b/homeassistant/components/systemnexa2/light.py @@ -0,0 +1,74 @@ +"""Light entity for the SystemNexa2 integration.""" + +from typing import Any + +from homeassistant.components.light import ATTR_BRIGHTNESS, ColorMode, LightEntity +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from .coordinator import SystemNexa2ConfigEntry, SystemNexa2DataUpdateCoordinator +from .entity import SystemNexa2Entity + +PARALLEL_UPDATES = 0 + + +async def async_setup_entry( + hass: HomeAssistant, + entry: SystemNexa2ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up lights based on a config entry.""" + coordinator = entry.runtime_data + + # Only add light entity for dimmable devices + if coordinator.data.info_data.dimmable: + async_add_entities([SystemNexa2Light(coordinator)]) + + +class SystemNexa2Light(SystemNexa2Entity, LightEntity): + """Representation of a dimmable SystemNexa2 light.""" + + _attr_translation_key = "light" + _attr_color_mode = ColorMode.BRIGHTNESS + _attr_supported_color_modes = {ColorMode.BRIGHTNESS} + + def __init__( + self, + coordinator: SystemNexa2DataUpdateCoordinator, + ) -> None: + """Initialize the light.""" + super().__init__( + coordinator=coordinator, + key="light", + ) + + async def async_turn_on(self, **kwargs: Any) -> None: + """Turn on the light.""" + # Check if we're setting brightness + if ATTR_BRIGHTNESS in kwargs: + brightness = kwargs[ATTR_BRIGHTNESS] + # Convert HomeAssistant brightness (0-255) to device brightness (0-1.0) + value = brightness / 255 + await self.coordinator.async_set_brightness(value) + else: + await self.coordinator.async_turn_on() + + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn off the light.""" + await self.coordinator.async_turn_off() + + @property + def is_on(self) -> bool | None: + """Return true if the light is on.""" + if self.coordinator.data.state is None: + return None + # Consider the light on if brightness is greater than 0 + return self.coordinator.data.state > 0 + + @property + def brightness(self) -> int | None: + """Return the brightness of the light (0-255).""" + if self.coordinator.data.state is None: + return None + # Convert device brightness (0-1.0) to HomeAssistant brightness (0-255) + return max(0, min(255, round(self.coordinator.data.state * 255))) diff --git a/homeassistant/components/systemnexa2/strings.json b/homeassistant/components/systemnexa2/strings.json index 2e41782fc0d2..ed48e08e1bd9 100644 --- a/homeassistant/components/systemnexa2/strings.json +++ b/homeassistant/components/systemnexa2/strings.json @@ -31,6 +31,11 @@ } }, "entity": { + "light": { + "light": { + "name": "Light" + } + }, "switch": { "433mhz": { "name": "433 MHz" @@ -45,7 +50,7 @@ "name": "Physical button" }, "relay_1": { - "name": "Relay 1" + "name": "Relay" } } }, diff --git a/tests/components/systemnexa2/conftest.py b/tests/components/systemnexa2/conftest.py index aa84ff11af70..59476d2d7124 100644 --- a/tests/components/systemnexa2/conftest.py +++ b/tests/components/systemnexa2/conftest.py @@ -2,6 +2,7 @@ from collections.abc import Generator from ipaddress import ip_address +from typing import Any from unittest.mock import AsyncMock, MagicMock, patch import pytest @@ -14,6 +15,39 @@ from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from tests.common import MockConfigEntry +@pytest.fixture(params=[False]) +def dimmable(request: pytest.FixtureRequest) -> bool: + """Return whether device is dimmable.""" + return request.param + + +@pytest.fixture +def device_info(dimmable: bool) -> dict[str, Any]: + """Return device configuration based on type.""" + # Create mock settings (same for all devices) + mock_setting_433mhz = MagicMock(spec=OnOffSetting) + mock_setting_433mhz.name = "433Mhz" + mock_setting_433mhz.enable = AsyncMock() + mock_setting_433mhz.disable = AsyncMock() + mock_setting_433mhz.is_enabled = MagicMock(return_value=True) + + mock_setting_cloud = MagicMock(spec=OnOffSetting) + mock_setting_cloud.name = "Cloud Access" + mock_setting_cloud.enable = AsyncMock() + mock_setting_cloud.disable = AsyncMock() + mock_setting_cloud.is_enabled = MagicMock(return_value=False) + + return { + "name": "In-Wall Dimmer" if dimmable else "Outdoor Smart Plug", + "model": "WBD-01" if dimmable else "WPO-01", + "unique_id": "aabbccddee01" if dimmable else "aabbccddee02", + "host": "10.0.0.101" if dimmable else "10.0.0.100", + "initial_state": 0.5 if dimmable else 1.0, + "settings": [mock_setting_433mhz, mock_setting_cloud], + "dimmable": dimmable, + } + + @pytest.fixture def mock_setup_entry() -> Generator[AsyncMock]: """Override async_setup_entry.""" @@ -24,7 +58,7 @@ def mock_setup_entry() -> Generator[AsyncMock]: @pytest.fixture -def mock_system_nexa_2_device() -> Generator[MagicMock]: +def mock_system_nexa_2_device(device_info: dict[str, Any]) -> Generator[MagicMock]: """Mock the System Nexa 2 API.""" with ( patch( @@ -36,30 +70,17 @@ def mock_system_nexa_2_device() -> Generator[MagicMock]: ): device = mock_device.return_value device.info_data = InformationData( - name="Test Device", - model="Test Model", - unique_id="test_device_id", + name=device_info["name"], + model=device_info["model"], + unique_id=device_info["unique_id"], sw_version="Test Model Version", hw_version="Test HW Version", wifi_dbm=-50, wifi_ssid="Test WiFi SSID", - dimmable=False, + dimmable=device_info["dimmable"], ) - # Create mock OnOffSettings - mock_setting_433mhz = MagicMock(spec=OnOffSetting) - mock_setting_433mhz.name = "433Mhz" - mock_setting_433mhz.enable = AsyncMock() - mock_setting_433mhz.disable = AsyncMock() - mock_setting_433mhz.is_enabled = MagicMock(return_value=True) - - mock_setting_cloud = MagicMock(spec=OnOffSetting) - mock_setting_cloud.name = "Cloud Access" - mock_setting_cloud.enable = AsyncMock() - mock_setting_cloud.disable = AsyncMock() - mock_setting_cloud.is_enabled = MagicMock(return_value=False) - - device.settings = [mock_setting_433mhz, mock_setting_cloud] + device.settings = device_info["settings"] device.get_info = AsyncMock() device.get_info.return_value = InformationUpdate(information=device.info_data) @@ -72,13 +93,14 @@ def mock_system_nexa_2_device() -> Generator[MagicMock]: "on_update" ) if on_update: - await on_update(StateChange(state=1.0)) + await on_update(StateChange(state=device_info["initial_state"])) device.connect = AsyncMock(side_effect=mock_connect) device.disconnect = AsyncMock() device.turn_on = AsyncMock() device.turn_off = AsyncMock() device.toggle = AsyncMock() + device.set_brightness = AsyncMock() mock_device.is_device_supported = MagicMock(return_value=(True, "")) mock_device.initiate_device = AsyncMock(return_value=device) @@ -86,16 +108,16 @@ def mock_system_nexa_2_device() -> Generator[MagicMock]: @pytest.fixture -def mock_config_entry() -> MockConfigEntry: +def mock_config_entry(device_info: dict[str, Any]) -> MockConfigEntry: """Return a mock config entry.""" return MockConfigEntry( domain=DOMAIN, - unique_id="test_device_id", + unique_id=device_info["unique_id"], data={ - CONF_HOST: "10.0.0.100", - CONF_NAME: "Test Device", - CONF_DEVICE_ID: "test_device_id", - CONF_MODEL: "Test Model", + CONF_HOST: device_info["host"], + CONF_NAME: device_info["name"], + CONF_DEVICE_ID: device_info["unique_id"], + CONF_MODEL: device_info["model"], }, ) @@ -121,5 +143,5 @@ def mock_zeroconf_discovery_info(): name="systemnexa2_test._systemnexa2._tcp.local.", port=80, type="_systemnexa2._tcp.local.", - properties={"id": "test_device_id", "model": "Test Model", "version": "1.0.0"}, + properties={"id": "aabbccddee02", "model": "WPO-01", "version": "1.0.0"}, ) diff --git a/tests/components/systemnexa2/snapshots/test_light.ambr b/tests/components/systemnexa2/snapshots/test_light.ambr new file mode 100644 index 000000000000..6fa20882b7a1 --- /dev/null +++ b/tests/components/systemnexa2/snapshots/test_light.ambr @@ -0,0 +1,60 @@ +# serializer version: 1 +# name: test_light_entities[True][light.in_wall_dimmer_light-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'supported_color_modes': list([ + , + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'light', + 'entity_category': None, + 'entity_id': 'light.in_wall_dimmer_light', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Light', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Light', + 'platform': 'systemnexa2', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'light', + 'unique_id': 'aabbccddee01-light', + 'unit_of_measurement': None, + }) +# --- +# name: test_light_entities[True][light.in_wall_dimmer_light-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'brightness': 128, + 'color_mode': , + 'friendly_name': 'In-Wall Dimmer Light', + 'supported_color_modes': list([ + , + ]), + 'supported_features': , + }), + 'context': , + 'entity_id': 'light.in_wall_dimmer_light', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- diff --git a/tests/components/systemnexa2/snapshots/test_switch.ambr b/tests/components/systemnexa2/snapshots/test_switch.ambr index 84a4b84af692..fe39f1478412 100644 --- a/tests/components/systemnexa2/snapshots/test_switch.ambr +++ b/tests/components/systemnexa2/snapshots/test_switch.ambr @@ -1,5 +1,5 @@ # serializer version: 1 -# name: test_switch_entities[switch.test_device_433_mhz-entry] +# name: test_switch_entities[False][switch.outdoor_smart_plug_433_mhz-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -12,7 +12,7 @@ 'disabled_by': None, 'domain': 'switch', 'entity_category': , - 'entity_id': 'switch.test_device_433_mhz', + 'entity_id': 'switch.outdoor_smart_plug_433_mhz', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -31,25 +31,25 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': '433mhz', - 'unique_id': 'test_device_id-433Mhz', + 'unique_id': 'aabbccddee02-433Mhz', 'unit_of_measurement': None, }) # --- -# name: test_switch_entities[switch.test_device_433_mhz-state] +# name: test_switch_entities[False][switch.outdoor_smart_plug_433_mhz-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'switch', - 'friendly_name': 'Test Device 433 MHz', + 'friendly_name': 'Outdoor Smart Plug 433 MHz', }), 'context': , - 'entity_id': 'switch.test_device_433_mhz', + 'entity_id': 'switch.outdoor_smart_plug_433_mhz', 'last_changed': , 'last_reported': , 'last_updated': , 'state': 'on', }) # --- -# name: test_switch_entities[switch.test_device_cloud_access-entry] +# name: test_switch_entities[False][switch.outdoor_smart_plug_cloud_access-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -62,7 +62,7 @@ 'disabled_by': None, 'domain': 'switch', 'entity_category': , - 'entity_id': 'switch.test_device_cloud_access', + 'entity_id': 'switch.outdoor_smart_plug_cloud_access', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -81,25 +81,25 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'cloud_access', - 'unique_id': 'test_device_id-Cloud Access', + 'unique_id': 'aabbccddee02-Cloud Access', 'unit_of_measurement': None, }) # --- -# name: test_switch_entities[switch.test_device_cloud_access-state] +# name: test_switch_entities[False][switch.outdoor_smart_plug_cloud_access-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'switch', - 'friendly_name': 'Test Device Cloud access', + 'friendly_name': 'Outdoor Smart Plug Cloud access', }), 'context': , - 'entity_id': 'switch.test_device_cloud_access', + 'entity_id': 'switch.outdoor_smart_plug_cloud_access', 'last_changed': , 'last_reported': , 'last_updated': , 'state': 'off', }) # --- -# name: test_switch_entities[switch.test_device_relay_1-entry] +# name: test_switch_entities[False][switch.outdoor_smart_plug_relay-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -112,7 +112,7 @@ 'disabled_by': None, 'domain': 'switch', 'entity_category': None, - 'entity_id': 'switch.test_device_relay_1', + 'entity_id': 'switch.outdoor_smart_plug_relay', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -120,28 +120,28 @@ 'labels': set({ }), 'name': None, - 'object_id_base': 'Relay 1', + 'object_id_base': 'Relay', 'options': dict({ }), 'original_device_class': None, 'original_icon': None, - 'original_name': 'Relay 1', + 'original_name': 'Relay', 'platform': 'systemnexa2', 'previous_unique_id': None, 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'relay_1', - 'unique_id': 'test_device_id-relay_1', + 'unique_id': 'aabbccddee02-relay_1', 'unit_of_measurement': None, }) # --- -# name: test_switch_entities[switch.test_device_relay_1-state] +# name: test_switch_entities[False][switch.outdoor_smart_plug_relay-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': 'Test Device Relay 1', + 'friendly_name': 'Outdoor Smart Plug Relay', }), 'context': , - 'entity_id': 'switch.test_device_relay_1', + 'entity_id': 'switch.outdoor_smart_plug_relay', 'last_changed': , 'last_reported': , 'last_updated': , diff --git a/tests/components/systemnexa2/test_config_flow.py b/tests/components/systemnexa2/test_config_flow.py index 4d0cdedb9d0e..91a118924546 100644 --- a/tests/components/systemnexa2/test_config_flow.py +++ b/tests/components/systemnexa2/test_config_flow.py @@ -30,14 +30,14 @@ async def test_full_flow(hass: HomeAssistant, mock_setup_entry: AsyncMock) -> No result["flow_id"], {CONF_HOST: "10.0.0.131"} ) assert result["type"] is FlowResultType.CREATE_ENTRY - assert result["title"] == "Test Device (Test Model)" + assert result["title"] == "Outdoor Smart Plug (WPO-01)" assert result["data"] == { CONF_HOST: "10.0.0.131", - CONF_NAME: "Test Device", - CONF_DEVICE_ID: "test_device_id", - CONF_MODEL: "Test Model", + CONF_NAME: "Outdoor Smart Plug", + CONF_DEVICE_ID: "aabbccddee02", + CONF_MODEL: "WPO-01", } - assert result["result"].unique_id == "test_device_id" + assert result["result"].unique_id == "aabbccddee02" assert len(mock_setup_entry.mock_calls) == 1 @@ -98,7 +98,7 @@ async def test_connection_error_and_recovery( result["flow_id"], {CONF_HOST: "10.0.0.131"} ) assert result["type"] is FlowResultType.CREATE_ENTRY - assert result["title"] == "Test Device (Test Model)" + assert result["title"] == "Outdoor Smart Plug (WPO-01)" assert len(mock_setup_entry.mock_calls) == 1 @@ -164,12 +164,12 @@ async def test_valid_hostname(hass: HomeAssistant, mock_setup_entry: AsyncMock) result["flow_id"], {CONF_HOST: "valid-hostname.local"} ) assert result["type"] is FlowResultType.CREATE_ENTRY - assert result["title"] == "Test Device (Test Model)" + assert result["title"] == "Outdoor Smart Plug (WPO-01)" assert result["data"] == { CONF_HOST: "valid-hostname.local", - CONF_NAME: "Test Device", - CONF_DEVICE_ID: "test_device_id", - CONF_MODEL: "Test Model", + CONF_NAME: "Outdoor Smart Plug", + CONF_DEVICE_ID: "aabbccddee02", + CONF_MODEL: "WPO-01", } assert len(mock_setup_entry.mock_calls) == 1 @@ -192,7 +192,7 @@ async def test_unsupported_device( assert result["type"] is FlowResultType.ABORT assert result["reason"] == "unsupported_model" assert result["description_placeholders"] == { - "model": "Test Model", + "model": "WPO-01", "sw_version": "Test Model Version", } @@ -215,12 +215,12 @@ async def test_zeroconf_discovery( result["flow_id"], user_input={} ) assert result["type"] is FlowResultType.CREATE_ENTRY - assert result["title"] == "systemnexa2_test (Test Model)" + assert result["title"] == "systemnexa2_test (WPO-01)" assert result["data"] == { CONF_HOST: "10.0.0.131", CONF_NAME: "systemnexa2_test", - CONF_DEVICE_ID: "test_device_id", - CONF_MODEL: "Test Model", + CONF_DEVICE_ID: "aabbccddee02", + CONF_MODEL: "WPO-01", } assert len(mock_setup_entry.mock_calls) == 1 @@ -249,8 +249,8 @@ async def test_device_with_none_values( device = mock_system_nexa_2_device.return_value # Create new InformationData with None unique_id device.info_data = InformationData( - name="Test Device", - model="Test Model", + name="Outdoor Smart Plug", + model="WPO-01", unique_id=None, sw_version="Test Model Version", hw_version="Test HW Version", @@ -281,7 +281,7 @@ async def test_zeroconf_discovery_none_values(hass: HomeAssistant) -> None: type="_systemnexa2._tcp.local.", properties={ "id": None, - "model": "Test Model", + "model": "WPO-01", "version": "1.0.0", }, ) diff --git a/tests/components/systemnexa2/test_light.py b/tests/components/systemnexa2/test_light.py new file mode 100644 index 000000000000..6fd8b19a3105 --- /dev/null +++ b/tests/components/systemnexa2/test_light.py @@ -0,0 +1,302 @@ +"""Test the System Nexa 2 light platform.""" + +from unittest.mock import MagicMock, patch + +import pytest +from sn2 import ConnectionStatus, StateChange +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.light import ATTR_BRIGHTNESS, DOMAIN as LIGHT_DOMAIN +from homeassistant.const import ( + ATTR_ENTITY_ID, + SERVICE_TURN_OFF, + SERVICE_TURN_ON, + STATE_OFF, + STATE_ON, + STATE_UNAVAILABLE, + Platform, +) +from homeassistant.core import HomeAssistant +import homeassistant.helpers.entity_registry as er + +from . import find_update_callback + +from tests.common import MockConfigEntry, snapshot_platform + + +@pytest.mark.parametrize("dimmable", [True], indirect=True) +async def test_light_entities( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + entity_registry: er.EntityRegistry, + mock_config_entry: MockConfigEntry, + mock_system_nexa_2_device: MagicMock, +) -> None: + """Test the light entities.""" + mock_config_entry.add_to_hass(hass) + + # Only load the light platform for snapshot testing + with patch( + "homeassistant.components.systemnexa2.PLATFORMS", + [Platform.LIGHT], + ): + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + await snapshot_platform( + hass, entity_registry, snapshot, mock_config_entry.entry_id + ) + + +async def test_light_only_for_dimmable_devices( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_system_nexa_2_device: MagicMock, +) -> None: + """Test that light entity is only created for dimmable devices.""" + # The mock_system_nexa_2_device has dimmable=False + mock_config_entry.add_to_hass(hass) + + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + # Light entity should NOT exist for non-dimmable device + state = hass.states.get("light.outdoor_smart_plug") + assert state is None + + +@pytest.mark.parametrize("dimmable", [True], indirect=True) +async def test_light_control_operations( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_system_nexa_2_device: MagicMock, +) -> None: + """Test all light control operations (on/off/toggle/dim).""" + device = mock_system_nexa_2_device.return_value + mock_config_entry.add_to_hass(hass) + + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + entity_id = "light.in_wall_dimmer_light" + + # Verify initial state (should be on with 50% brightness from fixture) + state = hass.states.get(entity_id) + assert state is not None + assert state.state == STATE_ON + + # Test turn on without brightness + await hass.services.async_call( + LIGHT_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + device.turn_on.assert_called_once() + device.set_brightness.assert_not_called() + device.turn_on.reset_mock() + + # Test turn on with brightness=128 (50% in HA scale 0-255) + await hass.services.async_call( + LIGHT_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: entity_id, ATTR_BRIGHTNESS: 128}, + blocking=True, + ) + device.set_brightness.assert_called_once_with(128 / 255) + device.turn_on.assert_not_called() + device.set_brightness.reset_mock() + + # Test turn on with brightness=255 (100% in HA scale) + await hass.services.async_call( + LIGHT_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: entity_id, ATTR_BRIGHTNESS: 255}, + blocking=True, + ) + device.set_brightness.assert_called_once_with(255 / 255) + device.set_brightness.reset_mock() + + # Test turn on with brightness=1 (minimum non-zero in HA scale) + await hass.services.async_call( + LIGHT_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: entity_id, ATTR_BRIGHTNESS: 1}, + blocking=True, + ) + device.set_brightness.assert_called_once_with(1 / 255) + device.set_brightness.reset_mock() + + # Test turn off + await hass.services.async_call( + LIGHT_DOMAIN, + SERVICE_TURN_OFF, + {ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + device.turn_off.assert_called_once() + device.turn_off.reset_mock() + + # No reason to test toggle service as its an internal function using turn_on/off + + +@pytest.mark.parametrize("dimmable", [True], indirect=True) +async def test_light_brightness_property( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_system_nexa_2_device: MagicMock, +) -> None: + """Test light brightness property conversion.""" + mock_config_entry.add_to_hass(hass) + + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + # Find the callback that was registered with the device + update_callback = find_update_callback(mock_system_nexa_2_device) + + # Test with state = 0.5 (50% in device scale, should be 128 in HA scale) + await update_callback(StateChange(state=0.5)) + await hass.async_block_till_done() + + state = hass.states.get("light.in_wall_dimmer_light") + assert state is not None + assert state.state == STATE_ON + assert state.attributes.get(ATTR_BRIGHTNESS) == 128 + + # Test with state = 1.0 (100% in device scale, should be 255 in HA scale) + await update_callback(StateChange(state=1.0)) + await hass.async_block_till_done() + + state = hass.states.get("light.in_wall_dimmer_light") + assert state is not None + assert state.state == STATE_ON + assert state.attributes.get(ATTR_BRIGHTNESS) == 255 + + # Test with state = 0.0 (0% - light should be off) + await update_callback(StateChange(state=0.0)) + await hass.async_block_till_done() + + state = hass.states.get("light.in_wall_dimmer_light") + assert state is not None + assert state.state == STATE_OFF + + # Test with state = 0.1 (10% in device scale, should be 26 in HA scale) + await update_callback(StateChange(state=0.1)) + await hass.async_block_till_done() + + state = hass.states.get("light.in_wall_dimmer_light") + assert state is not None + assert state.state == STATE_ON + assert state.attributes.get(ATTR_BRIGHTNESS) == 26 + + +@pytest.mark.parametrize("dimmable", [True], indirect=True) +async def test_light_is_on_property( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_system_nexa_2_device: MagicMock, +) -> None: + """Test light is_on property.""" + mock_config_entry.add_to_hass(hass) + + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + # Find the callback that was registered with the device + update_callback = find_update_callback(mock_system_nexa_2_device) + + # Test with state > 0 (light is on) + await update_callback(StateChange(state=0.5)) + await hass.async_block_till_done() + + state = hass.states.get("light.in_wall_dimmer_light") + assert state is not None + assert state.state == STATE_ON + + # Test with state = 0 (light is off) + await update_callback(StateChange(state=0.0)) + await hass.async_block_till_done() + + state = hass.states.get("light.in_wall_dimmer_light") + assert state is not None + assert state.state == STATE_OFF + + +@pytest.mark.parametrize("dimmable", [True], indirect=True) +async def test_coordinator_connection_status( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_system_nexa_2_device: MagicMock, +) -> None: + """Test coordinator handles connection status updates for light.""" + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + # Find the callback that was registered with the device + update_callback = find_update_callback(mock_system_nexa_2_device) + + # Initially, the light should be on (state=0.5 from fixture) + state = hass.states.get("light.in_wall_dimmer_light") + assert state is not None + assert state.state == STATE_ON + + # Simulate device disconnection + await update_callback(ConnectionStatus(connected=False)) + await hass.async_block_till_done() + + state = hass.states.get("light.in_wall_dimmer_light") + assert state is not None + assert state.state == STATE_UNAVAILABLE + + # Simulate reconnection and state update + await update_callback(ConnectionStatus(connected=True)) + await update_callback(StateChange(state=0.75)) + await hass.async_block_till_done() + + state = hass.states.get("light.in_wall_dimmer_light") + assert state is not None + assert state.state == STATE_ON + assert state.attributes.get(ATTR_BRIGHTNESS) == 191 # 0.75 * 255 ≈ 191 + + +@pytest.mark.parametrize("dimmable", [True], indirect=True) +async def test_coordinator_state_change( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_system_nexa_2_device: MagicMock, +) -> None: + """Test coordinator handles state change updates for light.""" + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + # Find the callback that was registered with the device + update_callback = find_update_callback(mock_system_nexa_2_device) + + # Change state to off (0.0) + await update_callback(StateChange(state=0.0)) + await hass.async_block_till_done() + + state = hass.states.get("light.in_wall_dimmer_light") + assert state is not None + assert state.state == STATE_OFF + + # Change state to 25% (0.25) + await update_callback(StateChange(state=0.25)) + await hass.async_block_till_done() + + state = hass.states.get("light.in_wall_dimmer_light") + assert state is not None + assert state.state == STATE_ON + assert state.attributes.get(ATTR_BRIGHTNESS) == 64 # 0.25 * 255 ≈ 64 + + # Change state to full brightness (1.0) + await update_callback(StateChange(state=1.0)) + await hass.async_block_till_done() + + state = hass.states.get("light.in_wall_dimmer_light") + assert state is not None + assert state.state == STATE_ON + assert state.attributes.get(ATTR_BRIGHTNESS) == 255 diff --git a/tests/components/systemnexa2/test_switch.py b/tests/components/systemnexa2/test_switch.py index 405419bee037..92a60203f319 100644 --- a/tests/components/systemnexa2/test_switch.py +++ b/tests/components/systemnexa2/test_switch.py @@ -1,9 +1,9 @@ """Test the System Nexa 2 switch platform.""" -from unittest.mock import AsyncMock, MagicMock +from unittest.mock import MagicMock import pytest -from sn2 import ConnectionStatus, OnOffSetting, SettingsUpdate, StateChange +from sn2 import ConnectionStatus, SettingsUpdate, StateChange from syrupy.assertion import SnapshotAssertion from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN @@ -52,18 +52,16 @@ async def test_switch_turn_on_off_toggle( await hass.config_entries.async_setup(mock_config_entry.entry_id) await hass.async_block_till_done() - # Get the coordinator and update it with state - entry = hass.config_entries.async_get_entry(mock_config_entry.entry_id) - assert entry - coordinator = entry.runtime_data - await coordinator._async_handle_update(StateChange(state=0.0)) + # Find the callback that was registered with the device + update_callback = find_update_callback(mock_system_nexa_2_device) + await update_callback(StateChange(state=0.0)) await hass.async_block_till_done() # Test turn on await hass.services.async_call( SWITCH_DOMAIN, SERVICE_TURN_ON, - {ATTR_ENTITY_ID: "switch.test_device_relay_1"}, + {ATTR_ENTITY_ID: "switch.outdoor_smart_plug_relay"}, blocking=True, ) device.turn_on.assert_called_once() @@ -72,7 +70,7 @@ async def test_switch_turn_on_off_toggle( await hass.services.async_call( SWITCH_DOMAIN, SERVICE_TURN_OFF, - {ATTR_ENTITY_ID: "switch.test_device_relay_1"}, + {ATTR_ENTITY_ID: "switch.outdoor_smart_plug_relay"}, blocking=True, ) device.turn_off.assert_called_once() @@ -81,7 +79,7 @@ async def test_switch_turn_on_off_toggle( await hass.services.async_call( SWITCH_DOMAIN, SERVICE_TOGGLE, - {ATTR_ENTITY_ID: "switch.test_device_relay_1"}, + {ATTR_ENTITY_ID: "switch.outdoor_smart_plug_relay"}, blocking=True, ) device.toggle.assert_called_once() @@ -98,22 +96,23 @@ async def test_switch_is_on_property( await hass.config_entries.async_setup(mock_config_entry.entry_id) await hass.async_block_till_done() - # Get the coordinator and update it with state - entry = hass.config_entries.async_get_entry(mock_config_entry.entry_id) - coordinator = entry.runtime_data + # Find the callback that was registered with the device + update_callback = find_update_callback(mock_system_nexa_2_device) # Test with state = 1.0 (on) - await coordinator._async_handle_update(StateChange(state=1.0)) + await update_callback(StateChange(state=1.0)) await hass.async_block_till_done() - state = hass.states.get("switch.test_device_relay_1") + state = hass.states.get("switch.outdoor_smart_plug_relay") + assert state is not None assert state.state == "on" # Test with state = 0.0 (off) - await coordinator._async_handle_update(StateChange(state=0.0)) + await update_callback(StateChange(state=0.0)) await hass.async_block_till_done() - state = hass.states.get("switch.test_device_relay_1") + state = hass.states.get("switch.outdoor_smart_plug_relay") + assert state is not None assert state.state == "off" @@ -125,39 +124,26 @@ async def test_configuration_switches( """Test configuration switch entities.""" device = mock_system_nexa_2_device.return_value - # Create mock OnOffSettings with keys matching SWITCH_TYPES - mock_setting_433mhz = MagicMock(spec=OnOffSetting) - mock_setting_433mhz.name = "433Mhz" # Must match key in SWITCH_TYPES - mock_setting_433mhz.enable = AsyncMock() - mock_setting_433mhz.disable = AsyncMock() - mock_setting_433mhz.is_enabled = MagicMock(return_value=True) - - mock_setting_cloud = MagicMock(spec=OnOffSetting) - mock_setting_cloud.name = "Cloud Access" # Must match key in SWITCH_TYPES - mock_setting_cloud.enable = AsyncMock() - mock_setting_cloud.disable = AsyncMock() - mock_setting_cloud.is_enabled = MagicMock(return_value=False) - - # Set settings before setup - device.settings = [mock_setting_433mhz, mock_setting_cloud] + # Settings are already configured in the fixture + mock_setting_433mhz = device.settings[0] # 433Mhz + mock_setting_cloud = device.settings[1] # Cloud Access mock_config_entry.add_to_hass(hass) await hass.config_entries.async_setup(mock_config_entry.entry_id) await hass.async_block_till_done() - # Make coordinator data available - entry = hass.config_entries.async_get_entry(mock_config_entry.entry_id) - coordinator = entry.runtime_data - await coordinator._async_handle_update(StateChange(state=1.0)) + # Find the callback that was registered with the device + update_callback = find_update_callback(mock_system_nexa_2_device) + await update_callback(StateChange(state=1.0)) await hass.async_block_till_done() # Check 433mhz switch state (should be on) - state = hass.states.get("switch.test_device_433_mhz") + state = hass.states.get("switch.outdoor_smart_plug_433_mhz") assert state is not None assert state.state == "on" # Check cloud_access switch state (should be off) - state = hass.states.get("switch.test_device_cloud_access") + state = hass.states.get("switch.outdoor_smart_plug_cloud_access") assert state is not None assert state.state == "off" @@ -165,7 +151,7 @@ async def test_configuration_switches( await hass.services.async_call( SWITCH_DOMAIN, SERVICE_TURN_OFF, - {ATTR_ENTITY_ID: "switch.test_device_433_mhz"}, + {ATTR_ENTITY_ID: "switch.outdoor_smart_plug_433_mhz"}, blocking=True, ) mock_setting_433mhz.disable.assert_called_once_with(device) @@ -174,7 +160,7 @@ async def test_configuration_switches( await hass.services.async_call( SWITCH_DOMAIN, SERVICE_TURN_ON, - {ATTR_ENTITY_ID: "switch.test_device_cloud_access"}, + {ATTR_ENTITY_ID: "switch.outdoor_smart_plug_cloud_access"}, blocking=True, ) mock_setting_cloud.enable.assert_called_once_with(device) @@ -193,8 +179,8 @@ async def test_coordinator_connection_status( # Find the callback that was registered with the device update_callback = find_update_callback(mock_system_nexa_2_device) - # Initially, the relay switch should be off (state=1.0 from fixture) - state = hass.states.get("switch.test_device_relay_1") + # Initially, the relay switch should be on (state=1.0 from fixture) + state = hass.states.get("switch.outdoor_smart_plug_relay") assert state is not None assert state.state == STATE_ON @@ -202,7 +188,8 @@ async def test_coordinator_connection_status( await update_callback(ConnectionStatus(connected=False)) await hass.async_block_till_done() - state = hass.states.get("switch.test_device_relay_1") + state = hass.states.get("switch.outdoor_smart_plug_relay") + assert state is not None assert state.state == STATE_UNAVAILABLE # Simulate reconnection and state update @@ -210,7 +197,8 @@ async def test_coordinator_connection_status( await update_callback(StateChange(state=1.0)) await hass.async_block_till_done() - state = hass.states.get("switch.test_device_relay_1") + state = hass.states.get("switch.outdoor_smart_plug_relay") + assert state is not None assert state.state == STATE_ON @@ -231,7 +219,7 @@ async def test_coordinator_state_change( await update_callback(StateChange(state=0.0)) await hass.async_block_till_done() - state = hass.states.get("switch.test_device_relay_1") + state = hass.states.get("switch.outdoor_smart_plug_relay") assert state is not None assert state.state == STATE_OFF @@ -239,7 +227,8 @@ async def test_coordinator_state_change( await update_callback(StateChange(state=1.0)) await hass.async_block_till_done() - state = hass.states.get("switch.test_device_relay_1") + state = hass.states.get("switch.outdoor_smart_plug_relay") + assert state is not None assert state.state == STATE_ON @@ -257,7 +246,7 @@ async def test_coordinator_settings_update( update_callback = find_update_callback(mock_system_nexa_2_device) # Get initial state of 433Mhz switch (should be on from fixture) - state = hass.states.get("switch.test_device_433_mhz") + state = hass.states.get("switch.outdoor_smart_plug_433_mhz") assert state is not None assert state.state == STATE_ON @@ -271,5 +260,6 @@ async def test_coordinator_settings_update( await update_callback(StateChange(state=1.0)) await hass.async_block_till_done() - state = hass.states.get("switch.test_device_433_mhz") + state = hass.states.get("switch.outdoor_smart_plug_433_mhz") + assert state is not None assert state.state == STATE_OFF