diff --git a/homeassistant/components/bosch_shc/icons.json b/homeassistant/components/bosch_shc/icons.json index f5ef6e6165b4a3..2ef170b1288a8a 100644 --- a/homeassistant/components/bosch_shc/icons.json +++ b/homeassistant/components/bosch_shc/icons.json @@ -12,6 +12,9 @@ } }, "switch": { + "child_lock": { + "default": "mdi:lock" + }, "routing": { "default": "mdi:wifi" } diff --git a/homeassistant/components/bosch_shc/strings.json b/homeassistant/components/bosch_shc/strings.json index c747701a4b769b..5eaabdf4b05a70 100644 --- a/homeassistant/components/bosch_shc/strings.json +++ b/homeassistant/components/bosch_shc/strings.json @@ -68,6 +68,9 @@ } }, "switch": { + "child_lock": { + "name": "Child lock" + }, "routing": { "name": "Routing" } diff --git a/homeassistant/components/bosch_shc/switch.py b/homeassistant/components/bosch_shc/switch.py index 091ca8e5a74f0e..13c8b71a38da30 100644 --- a/homeassistant/components/bosch_shc/switch.py +++ b/homeassistant/components/bosch_shc/switch.py @@ -9,6 +9,7 @@ PowerSwitchService, PrivacyModeService, SHCSmartPlug, + ThermostatService, ) from boschshcpy.device import SHCDevice @@ -32,7 +33,7 @@ class SHCSwitchEntityDescription(SwitchEntityDescription): """Class describing SHC switch entities.""" on_key: str - on_value: Enum + on_value: bool | Enum should_poll: bool @@ -72,6 +73,24 @@ class SHCSwitchEntityDescription(SwitchEntityDescription): on_value=PrivacyModeService.State.DISABLED, should_poll=True, ), + "child_lock": SHCSwitchEntityDescription( + key="child_lock", + translation_key="child_lock", + device_class=SwitchDeviceClass.SWITCH, + entity_category=EntityCategory.CONFIG, + on_key="child_lock", + on_value=True, + should_poll=False, + ), + "child_lock_thermostat": SHCSwitchEntityDescription( + key="child_lock_thermostat", + translation_key="child_lock", + device_class=SwitchDeviceClass.SWITCH, + entity_category=EntityCategory.CONFIG, + on_key="child_lock", + on_value=ThermostatService.State.ON, + should_poll=False, + ), } @@ -152,6 +171,42 @@ async def async_setup_entry( for switch in session.device_helper.camera_360 ) + entities.extend( + SHCSwitch( + hass=hass, + device=switch, + parent_id=shc_info.unique_id, + entry_id=config_entry.entry_id, + description=SWITCH_TYPES["child_lock_thermostat"], + unique_id_suffix="child_lock", + ) + for switch in ( + *session.device_helper.thermostats, + *session.device_helper.roomthermostats, + *session.device_helper.wallthermostats, + ) + ) + + entities.extend( + SHCSwitch( + hass=hass, + device=switch, + parent_id=shc_info.unique_id, + entry_id=config_entry.entry_id, + description=SWITCH_TYPES["child_lock"], + unique_id_suffix="child_lock", + ) + for switch in ( + *session.device_helper.micromodule_shutter_controls, + *session.device_helper.micromodule_blinds, + *session.device_helper.micromodule_light_attached, + *session.device_helper.micromodule_relays, + *session.device_helper.micromodule_impulse_relays, + *session.device_helper.micromodule_dimmers, + *session.device_helper.light_switches_bsm, + ) + ) + async_add_entities(entities) @@ -167,10 +222,13 @@ def __init__( parent_id: str, entry_id: str, description: SHCSwitchEntityDescription, + unique_id_suffix: str | None = None, ) -> None: """Initialize a SHC switch.""" super().__init__(hass, device, parent_id, entry_id) self.entity_description = description + if unique_id_suffix is not None: + self._attr_unique_id = f"{device.serial}_{unique_id_suffix}" @property @override diff --git a/tests/components/bosch_shc/conftest.py b/tests/components/bosch_shc/conftest.py index 67f3f5ac407e98..1792f80d6685c3 100644 --- a/tests/components/bosch_shc/conftest.py +++ b/tests/components/bosch_shc/conftest.py @@ -5,7 +5,15 @@ from typing import Any from unittest.mock import MagicMock, create_autospec, patch -from boschshcpy import BatteryLevelService, SHCBatteryDevice +from boschshcpy import ( + BatteryLevelService, + PowerSwitchService, + SHCBatteryDevice, + SHCLightSwitchBSM, + SHCMicromoduleRelay, + SHCThermostat, + ThermostatService, +) import pytest from homeassistant.components.bosch_shc.const import ( @@ -43,9 +51,21 @@ def mock_config_entry() -> MockConfigEntry: _EMPTY_DEVICE_BUCKETS: dict[str, list[Any]] = { bucket: [] for bucket in ( + "camera_360", + "camera_eyes", + "light_switches_bsm", + "micromodule_blinds", + "micromodule_dimmers", + "micromodule_impulse_relays", + "micromodule_light_attached", + "micromodule_relays", + "micromodule_shutter_controls", "motion_detectors", + "roomthermostats", "shutter_contacts", "shutter_contacts2", + "smart_plugs", + "smart_plugs_compact", "smoke_detectors", "thermostats", "twinguards", @@ -112,3 +132,68 @@ def battery_only_device( device.status = "AVAILABLE" device.deleted = False return device + + +def thermostat_device( + device_id: str = "hdm:ZigBee:thermostat1", + name: str = "Thermostat", + child_lock: ThermostatService.State = ThermostatService.State.OFF, +) -> SHCThermostat: + """Build a minimal device double for the thermostats/roomthermostats/wallthermostats buckets.""" + device = create_autospec(SHCThermostat, instance=True, spec_set=True) + device.name = name + device.id = device_id + device.root_device_id = "test-mac" + device.serial = f"serial-{device_id}" + device.manufacturer = "Bosch" + device.device_model = "TRV" + device.device_services = [] + device.deleted = False + device.status = "AVAILABLE" + device.child_lock = child_lock + return device + + +def micromodule_relay_device( + device_id: str = "hdm:ZigBee:relay1", + name: str = "Relay", + child_lock: bool = False, +) -> SHCMicromoduleRelay: + """Build a minimal device double for the micromodule_relays bucket.""" + device = create_autospec(SHCMicromoduleRelay, instance=True, spec_set=True) + device.name = name + device.id = device_id + device.root_device_id = "test-mac" + device.serial = f"serial-{device_id}" + device.manufacturer = "Bosch" + device.device_model = "MICROMODULE_RELAY" + device.device_services = [] + device.deleted = False + device.status = "AVAILABLE" + device.child_lock = child_lock + return device + + +def light_switch_bsm_device( + device_id: str = "hdm:ZigBee:lightswitch1", + name: str = "Light switch", + child_lock: bool = False, +) -> SHCLightSwitchBSM: + """Build a minimal device double for the light_switches_bsm bucket. + + Backs both the primary "lightswitch" switch and the new child-lock + switch, so a unique_id collision between the two would surface here. + """ + device = create_autospec(SHCLightSwitchBSM, instance=True, spec_set=True) + device.name = name + device.id = device_id + device.root_device_id = "test-mac" + device.serial = f"serial-{device_id}" + device.manufacturer = "Bosch" + device.device_model = "LIGHT_SWITCH_BSM" + device.device_services = [] + device.deleted = False + device.status = "AVAILABLE" + device.switchstate = PowerSwitchService.State.OFF + device.child_lock = child_lock + return device diff --git a/tests/components/bosch_shc/test_switch.py b/tests/components/bosch_shc/test_switch.py new file mode 100644 index 00000000000000..903efc30da9767 --- /dev/null +++ b/tests/components/bosch_shc/test_switch.py @@ -0,0 +1,118 @@ +"""Tests for the Bosch SHC switch platform.""" + +from collections.abc import Generator +from unittest.mock import MagicMock, patch + +from boschshcpy import ThermostatService +import pytest + +from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN +from homeassistant.const import ( + ATTR_ENTITY_ID, + SERVICE_TURN_OFF, + SERVICE_TURN_ON, + Platform, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from .conftest import ( + light_switch_bsm_device, + micromodule_relay_device, + setup_integration, + thermostat_device, +) + +from tests.common import MockConfigEntry + + +@pytest.fixture(autouse=True) +def platforms() -> Generator[None]: + """Restrict bosch_shc setup to the switch platform.""" + with patch("homeassistant.components.bosch_shc.PLATFORMS", [Platform.SWITCH]): + yield + + +@pytest.mark.parametrize( + "device_buckets", + [{"thermostats": [thermostat_device(child_lock=ThermostatService.State.OFF)]}], + indirect=True, +) +@pytest.mark.usefixtures("mock_session") +async def test_thermostat_child_lock( + hass: HomeAssistant, + mock_session: MagicMock, + mock_config_entry: MockConfigEntry, +) -> None: + """A thermostat's enum-based child lock is exposed and controllable.""" + await setup_integration(hass, mock_config_entry) + device = mock_session.device_helper.thermostats[0] + + state = hass.states.get("switch.thermostat_child_lock") + assert state is not None + assert state.state == "off" + + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: "switch.thermostat_child_lock"}, + blocking=True, + ) + assert device.child_lock is True + + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_OFF, + {ATTR_ENTITY_ID: "switch.thermostat_child_lock"}, + blocking=True, + ) + assert device.child_lock is False + + +@pytest.mark.parametrize( + "device_buckets", + [{"micromodule_relays": [micromodule_relay_device(child_lock=False)]}], + indirect=True, +) +@pytest.mark.usefixtures("mock_session") +async def test_micromodule_relay_child_lock( + hass: HomeAssistant, + mock_session: MagicMock, + mock_config_entry: MockConfigEntry, +) -> None: + """A ChildProtection device's bool-based child lock is exposed and controllable.""" + await setup_integration(hass, mock_config_entry) + device = mock_session.device_helper.micromodule_relays[0] + + state = hass.states.get("switch.relay_child_lock") + assert state is not None + assert state.state == "off" + + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: "switch.relay_child_lock"}, + blocking=True, + ) + assert device.child_lock is True + + +@pytest.mark.parametrize( + "device_buckets", + [{"light_switches_bsm": [light_switch_bsm_device(child_lock=False)]}], + indirect=True, +) +@pytest.mark.usefixtures("mock_session") +async def test_light_switch_bsm_child_lock_unique_id( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + mock_config_entry: MockConfigEntry, +) -> None: + """A BSM light switch's primary switch and child-lock switch use distinct unique_ids.""" + await setup_integration(hass, mock_config_entry) + + lightswitch_entry = entity_registry.async_get("switch.light_switch") + child_lock_entry = entity_registry.async_get("switch.light_switch_child_lock") + assert lightswitch_entry is not None + assert child_lock_entry is not None + assert lightswitch_entry.unique_id != child_lock_entry.unique_id