Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions homeassistant/components/bosch_shc/icons.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,9 @@
}
},
"switch": {
"child_lock": {
"default": "mdi:lock"
},
"routing": {
"default": "mdi:wifi"
}
Expand Down
3 changes: 3 additions & 0 deletions homeassistant/components/bosch_shc/strings.json
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,9 @@
}
},
"switch": {
"child_lock": {
"name": "Child lock"
},
"routing": {
"name": "Routing"
}
Expand Down
60 changes: 59 additions & 1 deletion homeassistant/components/bosch_shc/switch.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
PowerSwitchService,
PrivacyModeService,
SHCSmartPlug,
ThermostatService,
)
from boschshcpy.device import SHCDevice

Expand All @@ -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


Expand Down Expand Up @@ -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,
),
}


Expand Down Expand Up @@ -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)


Expand All @@ -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
Expand Down
87 changes: 86 additions & 1 deletion tests/components/bosch_shc/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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
118 changes: 118 additions & 0 deletions tests/components/bosch_shc/test_switch.py
Original file line number Diff line number Diff line change
@@ -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
Loading