bosch_shc: add whole-home open doors/windows summary sensor (#182869)

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Thomas
2026-09-21 19:41:09 +02:00
committed by GitHub
co-authored by Claude Sonnet 5
parent c945eab58a
commit 1ca85dc3bc
3 changed files with 124 additions and 0 deletions
@@ -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."""
@@ -53,6 +53,9 @@
"humidity_rating": {
"name": "Humidity rating"
},
"open_windows_doors": {
"name": "Open doors and windows"
},
"purity": {
"name": "Purity"
},
+67
View File
@@ -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"