bosch_shc: add alarm bypass switch entities (#182246)

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Thomas
2026-09-21 20:08:19 +02:00
committed by GitHub
co-authored by Claude Sonnet 5
parent cf3580a8e4
commit 1f949d9234
5 changed files with 202 additions and 1 deletions
@@ -12,6 +12,12 @@
}
},
"switch": {
"bypass": {
"default": "mdi:shield-off-outline"
},
"bypass_infinite": {
"default": "mdi:timer-off-outline"
},
"child_lock": {
"default": "mdi:lock"
},
@@ -70,6 +70,12 @@
}
},
"switch": {
"bypass": {
"name": "Break function"
},
"bypass_infinite": {
"name": "Break function never expires"
},
"child_lock": {
"name": "Child lock"
},
@@ -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)
+24
View File
@@ -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
+103 -1
View File
@@ -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