diff --git a/homeassistant/components/bosch_shc/sensor.py b/homeassistant/components/bosch_shc/sensor.py index a5974a0b1527..c83863a1acd9 100644 --- a/homeassistant/components/bosch_shc/sensor.py +++ b/homeassistant/components/bosch_shc/sensor.py @@ -7,6 +7,7 @@ from typing import TYPE_CHECKING, Any, override from boschshcpy import ( SHCLightSwitchBSM, SHCMicromoduleShutterControl, + SHCSession, SHCSmartPlug, SHCSmartPlugCompact, SHCThermostat, @@ -28,10 +29,12 @@ from homeassistant.const import ( UnitOfTemperature, ) from homeassistant.core import HomeAssistant +from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import StateType from . import BoschConfigEntry +from .const import DOMAIN from .entity import SHCEntity PARALLEL_UPDATES = 0 @@ -297,6 +300,57 @@ async def async_setup_entry( async_add_entities(entities) + async_add_entities( + [SHCOpenWindowsSensor(session=session, parent_id=shc_info.unique_id)], + update_before_add=True, + ) + + +class SHCOpenWindowsSensor(SensorEntity): + """Whole-home summary of open doors/windows (official OpenAPI spec). + + Not tied to one SHC device, so this does not inherit SHCEntity — it's + scoped to the config entry and linked to the hub device directly. The + underlying doors-windows/openwindows endpoint is a plain GET, not + delivered by the long-poll stream, so this needs should_poll=True. + """ + + _attr_has_entity_name = True + _attr_translation_key = "open_windows_doors" + _attr_should_poll = True + + def __init__(self, session: SHCSession, parent_id: str) -> None: + """Initialize the open-windows/doors summary sensor.""" + self._session = session + self._attr_unique_id = f"{parent_id}_open_windows_doors" + self._attr_device_info = DeviceInfo(identifiers={(DOMAIN, parent_id)}) + self._open_doors: list[dict[str, Any]] = [] + self._open_windows: list[dict[str, Any]] = [] + self._open_others: list[dict[str, Any]] = [] + + @property + @override + def native_value(self) -> int: + """Return the total count of open doors, windows, and other openings.""" + return len(self._open_doors) + len(self._open_windows) + len(self._open_others) + + @property + @override + def extra_state_attributes(self) -> dict[str, list[str]]: + """Return the names of each currently-open door/window/other opening.""" + return { + "open_doors": [d.get("name", "") for d in self._open_doors], + "open_windows": [w.get("name", "") for w in self._open_windows], + "open_others": [o.get("name", "") for o in self._open_others], + } + + def update(self) -> None: + """Poll the whole-home open-doors/open-windows summary.""" + data = self._session.api.get_open_windows() + self._open_doors = data.get("openDoors", []) + self._open_windows = data.get("openWindows", []) + self._open_others = data.get("openOthers", []) + class SHCSensor[_DeviceT: SHCDevice](SHCEntity, SensorEntity): """Representation of a SHC sensor.""" diff --git a/homeassistant/components/bosch_shc/strings.json b/homeassistant/components/bosch_shc/strings.json index d2359625d613..1788a5194b74 100644 --- a/homeassistant/components/bosch_shc/strings.json +++ b/homeassistant/components/bosch_shc/strings.json @@ -53,6 +53,9 @@ "humidity_rating": { "name": "Humidity rating" }, + "open_windows_doors": { + "name": "Open doors and windows" + }, "purity": { "name": "Purity" }, diff --git a/tests/components/bosch_shc/test_sensor.py b/tests/components/bosch_shc/test_sensor.py new file mode 100644 index 000000000000..7827f27db15e --- /dev/null +++ b/tests/components/bosch_shc/test_sensor.py @@ -0,0 +1,67 @@ +"""Tests for the Bosch SHC sensor platform.""" + +from collections.abc import Generator +from unittest.mock import MagicMock, patch + +import pytest + +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr, entity_registry as er +from homeassistant.helpers.entity_component import async_update_entity + +from .conftest import setup_integration + +from tests.common import MockConfigEntry + + +@pytest.fixture(autouse=True) +def platforms() -> Generator[None]: + """Restrict bosch_shc setup to the sensor platform.""" + with patch("homeassistant.components.bosch_shc.PLATFORMS", [Platform.SENSOR]): + yield + + +@pytest.mark.usefixtures("mock_session") +async def test_open_windows_doors_sensor( + hass: HomeAssistant, + mock_session: MagicMock, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, + device_registry: dr.DeviceRegistry, +) -> None: + """The whole-home open-doors/open-windows summary is exposed and polled.""" + mock_session.api.get_open_windows.return_value = { + "openDoors": [{"name": "Front Door"}], + "openWindows": [{"name": "Kitchen Window"}, {"name": "Bedroom Window"}], + "openOthers": [{"name": "Cat Flap"}], + } + await setup_integration(hass, mock_config_entry) + + entity_id = "sensor.mock_title_open_doors_and_windows" + state = hass.states.get(entity_id) + assert state is not None + assert state.state == "4" + assert state.attributes["open_doors"] == ["Front Door"] + assert state.attributes["open_windows"] == ["Kitchen Window", "Bedroom Window"] + assert state.attributes["open_others"] == ["Cat Flap"] + + hub_device = device_registry.async_get_device_by_identifier( + ("bosch_shc", "test-mac"), mock_config_entry.entry_id + ) + assert hub_device is not None + entry = entity_registry.async_get(entity_id) + assert entry is not None + assert entry.device_id == hub_device.id + + mock_session.api.get_open_windows.return_value = { + "openDoors": [], + "openWindows": [], + "openOthers": [], + } + await async_update_entity(hass, entity_id) + await hass.async_block_till_done() + + state = hass.states.get(entity_id) + assert state is not None + assert state.state == "0"