diff --git a/homeassistant/components/bosch_shc/icons.json b/homeassistant/components/bosch_shc/icons.json index 2ef170b1288a..da813238f977 100644 --- a/homeassistant/components/bosch_shc/icons.json +++ b/homeassistant/components/bosch_shc/icons.json @@ -12,6 +12,12 @@ } }, "switch": { + "bypass": { + "default": "mdi:shield-off-outline" + }, + "bypass_infinite": { + "default": "mdi:timer-off-outline" + }, "child_lock": { "default": "mdi:lock" }, diff --git a/homeassistant/components/bosch_shc/strings.json b/homeassistant/components/bosch_shc/strings.json index 1788a5194b74..63b169e0c343 100644 --- a/homeassistant/components/bosch_shc/strings.json +++ b/homeassistant/components/bosch_shc/strings.json @@ -70,6 +70,12 @@ } }, "switch": { + "bypass": { + "name": "Break function" + }, + "bypass_infinite": { + "name": "Break function never expires" + }, "child_lock": { "name": "Child lock" }, diff --git a/homeassistant/components/bosch_shc/switch.py b/homeassistant/components/bosch_shc/switch.py index f1949dfd1480..34cc500cd605 100644 --- a/homeassistant/components/bosch_shc/switch.py +++ b/homeassistant/components/bosch_shc/switch.py @@ -5,9 +5,11 @@ from enum import Enum from typing import TYPE_CHECKING, Any, override from boschshcpy import ( + BypassService, CameraLightService, PowerSwitchService, PrivacyModeService, + SHCShutterContact2, SHCSmartPlug, ThermostatService, ) @@ -98,6 +100,14 @@ SWITCH_TYPES: dict[str, SHCSwitchEntityDescription] = { on_value=True, should_poll=False, ), + "bypass": SHCSwitchEntityDescription( + key="bypass", + translation_key="bypass", + device_class=SwitchDeviceClass.SWITCH, + on_key="bypass", + on_value=BypassService.State.BYPASS_ACTIVE, + should_poll=False, + ), } @@ -226,6 +236,27 @@ async def async_setup_entry( ) ) + entities.extend( + SHCSwitch( + hass=hass, + device=switch, + parent_id=shc_info.unique_id, + entry_id=config_entry.entry_id, + description=SWITCH_TYPES["bypass"], + ) + for switch in session.device_helper.shutter_contacts2 + ) + + entities.extend( + SHCBypassInfiniteSwitch( + hass=hass, + device=switch, + parent_id=shc_info.unique_id, + entry_id=config_entry.entry_id, + ) + for switch in session.device_helper.shutter_contacts2 + ) + async_add_entities(entities) @@ -308,3 +339,35 @@ class SHCRoutingSwitch(SHCEntity, SwitchEntity): def turn_off(self, **kwargs: Any) -> None: """Turn the switch off.""" self._device.routing = False + + +class SHCBypassInfiniteSwitch(SHCEntity, SwitchEntity): + """Representation of a SHC alarm-bypass "never expires" switch.""" + + _attr_translation_key = "bypass_infinite" + _attr_device_class = SwitchDeviceClass.SWITCH + _attr_entity_category = EntityCategory.CONFIG + _device: SHCShutterContact2 + + def __init__( + self, hass: HomeAssistant, device: SHCDevice, parent_id: str, entry_id: str + ) -> None: + """Initialize an SHC bypass-never-expires switch.""" + super().__init__(hass, device, parent_id, entry_id) + self._attr_unique_id = f"{device.serial}_bypass_infinite" + + @property + @override + def is_on(self) -> bool: + """Return the state of the switch.""" + return self._device.bypass_infinite + + @override + def turn_on(self, **kwargs: Any) -> None: + """Turn the switch on.""" + self._device.set_bypass_configuration(infinite=True) + + @override + def turn_off(self, **kwargs: Any) -> None: + """Turn the switch off.""" + self._device.set_bypass_configuration(infinite=False) diff --git a/tests/components/bosch_shc/conftest.py b/tests/components/bosch_shc/conftest.py index 7b282a71a86b..4bfc80eb3e84 100644 --- a/tests/components/bosch_shc/conftest.py +++ b/tests/components/bosch_shc/conftest.py @@ -7,6 +7,7 @@ from unittest.mock import MagicMock, create_autospec, patch from boschshcpy import ( BatteryLevelService, + BypassService, PowerSwitchService, RoutingService, SHCBatteryDevice, @@ -14,6 +15,7 @@ from boschshcpy import ( SHCMicromoduleBlinds, SHCMicromoduleRelay, SHCPresenceSimulationSystem, + SHCShutterContact2, SHCShutterControl, SHCSmartPlug, SHCThermostat, @@ -301,3 +303,25 @@ def presence_simulation_system_device( device.status = "AVAILABLE" device.enabled = enabled return device + + +def shutter_contact2_device( + device_id: str = "hdm:ZigBee:shuttercontact1", + name: str = "Shutter contact", + bypass: BypassService.State = BypassService.State.BYPASS_INACTIVE, + bypass_infinite: bool = False, +) -> SHCShutterContact2: + """Build a minimal device double for the shutter_contacts2 bucket.""" + device = create_autospec(SHCShutterContact2, 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 = "SWD2" + device.device_services = [] + device.deleted = False + device.status = "AVAILABLE" + device.bypass = bypass + device.bypass_infinite = bypass_infinite + return device diff --git a/tests/components/bosch_shc/test_switch.py b/tests/components/bosch_shc/test_switch.py index 2a181a409acd..53ced8857858 100644 --- a/tests/components/bosch_shc/test_switch.py +++ b/tests/components/bosch_shc/test_switch.py @@ -3,7 +3,7 @@ from collections.abc import Generator from unittest.mock import MagicMock, patch -from boschshcpy import ThermostatService +from boschshcpy import BypassService, ThermostatService import pytest from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN @@ -21,6 +21,7 @@ from .conftest import ( micromodule_relay_device, presence_simulation_system_device, setup_integration, + shutter_contact2_device, smart_plug_device, thermostat_device, ) @@ -215,3 +216,104 @@ async def test_smart_plug_routing_switch_name( state = hass.states.get("switch.smart_plug_range_extension") assert state is not None assert state.attributes["friendly_name"] == "Smart Plug Range extension" + + +@pytest.mark.parametrize( + "device_buckets", + [ + { + "shutter_contacts2": [ + shutter_contact2_device(bypass=BypassService.State.BYPASS_INACTIVE) + ] + } + ], + indirect=True, +) +@pytest.mark.usefixtures("mock_session") +async def test_shutter_contact2_bypass( + hass: HomeAssistant, + mock_session: MagicMock, + mock_config_entry: MockConfigEntry, +) -> None: + """A Door/Window Contact II's alarm bypass is exposed and controllable.""" + await setup_integration(hass, mock_config_entry) + device = mock_session.device_helper.shutter_contacts2[0] + + state = hass.states.get("switch.shutter_contact_break_function") + assert state is not None + assert state.state == "off" + + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: "switch.shutter_contact_break_function"}, + blocking=True, + ) + assert device.bypass is True + + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_OFF, + {ATTR_ENTITY_ID: "switch.shutter_contact_break_function"}, + blocking=True, + ) + assert device.bypass is False + + +@pytest.mark.parametrize( + "device_buckets", + [{"shutter_contacts2": [shutter_contact2_device(bypass_infinite=False)]}], + indirect=True, +) +@pytest.mark.usefixtures("mock_session") +async def test_shutter_contact2_bypass_infinite( + hass: HomeAssistant, + mock_session: MagicMock, + mock_config_entry: MockConfigEntry, +) -> None: + """A Door/Window Contact II's bypass-never-expires option is exposed and controllable.""" + await setup_integration(hass, mock_config_entry) + device = mock_session.device_helper.shutter_contacts2[0] + + state = hass.states.get("switch.shutter_contact_break_function_never_expires") + assert state is not None + assert state.state == "off" + + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: "switch.shutter_contact_break_function_never_expires"}, + blocking=True, + ) + device.set_bypass_configuration.assert_called_once_with(infinite=True) + + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_OFF, + {ATTR_ENTITY_ID: "switch.shutter_contact_break_function_never_expires"}, + blocking=True, + ) + device.set_bypass_configuration.assert_called_with(infinite=False) + + +@pytest.mark.parametrize( + "device_buckets", + [{"shutter_contacts2": [shutter_contact2_device()]}], + indirect=True, +) +@pytest.mark.usefixtures("mock_session") +async def test_shutter_contact2_bypass_unique_id( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + mock_config_entry: MockConfigEntry, +) -> None: + """A Door/Window Contact II's two bypass switches use distinct unique_ids.""" + await setup_integration(hass, mock_config_entry) + + bypass_entry = entity_registry.async_get("switch.shutter_contact_break_function") + bypass_infinite_entry = entity_registry.async_get( + "switch.shutter_contact_break_function_never_expires" + ) + assert bypass_entry is not None + assert bypass_infinite_entry is not None + assert bypass_entry.unique_id != bypass_infinite_entry.unique_id