From 9766f7f9b2dae15c0e8b28bce0528790c6ff1a14 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ren=C3=A9=20Bachmann?= Date: Fri, 4 Sep 2026 12:57:32 +0200 Subject: [PATCH] Implement bridge-backed Home Assistant integration --- README.md | 22 ++ custom_components/mb_secure/__init__.py | 29 ++- .../mb_secure/alarm_control_panel.py | 83 +++++++ custom_components/mb_secure/api.py | 200 +++++++++++++++ custom_components/mb_secure/binary_sensor.py | 49 ++++ custom_components/mb_secure/config_flow.py | 199 +++++++++++++++ custom_components/mb_secure/const.py | 20 ++ custom_components/mb_secure/coordinator.py | 110 +++++++++ custom_components/mb_secure/devices.py | 77 ++++++ custom_components/mb_secure/diagnostics.py | 28 +++ custom_components/mb_secure/entity.py | 151 ++++++++++++ custom_components/mb_secure/manifest.json | 2 +- custom_components/mb_secure/models.py | 227 ++++++++++++++++++ custom_components/mb_secure/sensor.py | 47 ++++ custom_components/mb_secure/switch.py | 61 +++++ .../mb_secure/translations/en.json | 42 ++++ pyproject.toml | 14 ++ requirements_test.txt | 3 + tests/conftest.py | 10 + tests/test_api.py | 115 +++++++++ tests/test_config_flow.py | 148 ++++++++++++ tests/test_coordinator.py | 147 ++++++++++++ tests/test_diagnostics.py | 42 ++++ tests/test_entities.py | 192 +++++++++++++++ tests/test_models.py | 143 +++++++++++ 25 files changed, 2154 insertions(+), 7 deletions(-) create mode 100644 custom_components/mb_secure/alarm_control_panel.py create mode 100644 custom_components/mb_secure/api.py create mode 100644 custom_components/mb_secure/binary_sensor.py create mode 100644 custom_components/mb_secure/config_flow.py create mode 100644 custom_components/mb_secure/coordinator.py create mode 100644 custom_components/mb_secure/devices.py create mode 100644 custom_components/mb_secure/diagnostics.py create mode 100644 custom_components/mb_secure/entity.py create mode 100644 custom_components/mb_secure/models.py create mode 100644 custom_components/mb_secure/sensor.py create mode 100644 custom_components/mb_secure/switch.py create mode 100644 custom_components/mb_secure/translations/en.json create mode 100644 pyproject.toml create mode 100644 requirements_test.txt create mode 100644 tests/conftest.py create mode 100644 tests/test_api.py create mode 100644 tests/test_config_flow.py create mode 100644 tests/test_coordinator.py create mode 100644 tests/test_diagnostics.py create mode 100644 tests/test_entities.py create mode 100644 tests/test_models.py diff --git a/README.md b/README.md index 13c90a3..795650f 100644 --- a/README.md +++ b/README.md @@ -3,3 +3,25 @@ Unofficial local Home Assistant integration for MB-Secure through the vendor-neutral MB-Secure Bridge API. This public integration never connects directly to the controller and never stores controller credentials. The implementation is currently being developed against a neutral mock bridge. + +The current development core includes UI and Supervisor app discovery flows, +local bearer-token authentication, periodic snapshot reconciliation, a +server-sent event listener with reconnect handling, and sanitized diagnostics. + +Entities are created only for capabilities reported by the bridge. Controllers, +areas, and modules are registered as devices with a stable hierarchy; points and +outputs are attached to their normalized parent device. Snapshot reconciliation +adds and removes runtime entities when the topology changes. + +## Development + +Use Python 3.14 and install the pinned test toolchain in an isolated environment: + +```shell +python -m venv .venv +.venv/bin/python -m pip install -r requirements_test.txt +.venv/bin/ruff check . +.venv/bin/ruff format --check . +.venv/bin/mypy custom_components/mb_secure +.venv/bin/pytest +``` diff --git a/custom_components/mb_secure/__init__.py b/custom_components/mb_secure/__init__.py index 2b05c07..6f0cf48 100644 --- a/custom_components/mb_secure/__init__.py +++ b/custom_components/mb_secure/__init__.py @@ -4,17 +4,34 @@ from __future__ import annotations from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant +from homeassistant.helpers.aiohttp_client import async_get_clientsession -from .const import DOMAIN +from .api import BridgeClient +from .const import CONF_TOKEN, PLATFORMS +from .coordinator import MBSecureCoordinator +from .devices import async_setup_devices + +type MBSecureConfigEntry = ConfigEntry[MBSecureCoordinator] -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_setup_entry(hass: HomeAssistant, entry: MBSecureConfigEntry) -> bool: """Set up MB-Secure from a config entry.""" - hass.data.setdefault(DOMAIN, {})[entry.entry_id] = None + client = BridgeClient( + async_get_clientsession(hass), + host=entry.data["host"], + port=entry.data["port"], + token=entry.data[CONF_TOKEN], + ) + coordinator = MBSecureCoordinator(hass, entry, client) + await coordinator.async_config_entry_first_refresh() + entry.runtime_data = coordinator + async_setup_devices(hass, entry) + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + coordinator.async_start_event_listener() return True -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry(hass: HomeAssistant, entry: MBSecureConfigEntry) -> bool: """Unload an MB-Secure config entry.""" - hass.data[DOMAIN].pop(entry.entry_id) - return True + await entry.runtime_data.async_shutdown() + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/custom_components/mb_secure/alarm_control_panel.py b/custom_components/mb_secure/alarm_control_panel.py new file mode 100644 index 0000000..4841c0f --- /dev/null +++ b/custom_components/mb_secure/alarm_control_panel.py @@ -0,0 +1,83 @@ +"""Alarm control panel entities for MB-Secure.""" + +from __future__ import annotations + +from homeassistant.components.alarm_control_panel import ( + AlarmControlPanelEntity, + AlarmControlPanelEntityFeature, + AlarmControlPanelState, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .coordinator import MBSecureCoordinator +from .entity import MBSecureEntity, async_setup_dynamic_entities + + +async def async_setup_entry( + hass: HomeAssistant, + entry: ConfigEntry[MBSecureCoordinator], + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up capability-reported area alarm entities.""" + coordinator = entry.runtime_data + assert coordinator.info is not None and coordinator.info.bridge_id is not None + bridge_id = coordinator.info.bridge_id + async_setup_dynamic_entities( + entry, + async_add_entities, + collection_name="areas", + capability=frozenset({"arm_away", "disarm"}), + factory=lambda object_id: MBSecureAlarmEntity( + coordinator, bridge_id, object_id + ), + ) + + +class MBSecureAlarmEntity(MBSecureEntity, AlarmControlPanelEntity): + """Represent one normalized security area.""" + + def __init__( + self, coordinator: MBSecureCoordinator, bridge_id: str, object_id: str + ) -> None: + """Initialize an area alarm entity.""" + super().__init__(coordinator, bridge_id, "areas", object_id) + + @property + def alarm_state(self) -> AlarmControlPanelState | None: + """Return the normalized area state.""" + item = self.bridge_object + if item is None: + return None + try: + return AlarmControlPanelState(item.state) + except ValueError: + return None + + @property + def supported_features(self) -> AlarmControlPanelEntityFeature: + """Expose only explicitly reported arm capabilities.""" + item = self.bridge_object + if item is None: + return AlarmControlPanelEntityFeature(0) + features = AlarmControlPanelEntityFeature(0) + if "arm_away" in item.capabilities: + features |= AlarmControlPanelEntityFeature.ARM_AWAY + return features + + async def async_alarm_arm_away(self, code: str | None = None) -> None: + """Arm this area in away mode.""" + item = self.bridge_object + if item is None or "arm_away" not in item.capabilities: + raise NotImplementedError("arm away capability is not available") + await self.coordinator.client.async_arm_area(self._object_id, "away") + await self.coordinator.async_request_refresh() + + async def async_alarm_disarm(self, code: str | None = None) -> None: + """Disarm this area when the capability is available.""" + item = self.bridge_object + if item is None or "disarm" not in item.capabilities: + raise NotImplementedError("disarm capability is not available") + await self.coordinator.client.async_disarm_area(self._object_id) + await self.coordinator.async_request_refresh() diff --git a/custom_components/mb_secure/api.py b/custom_components/mb_secure/api.py new file mode 100644 index 0000000..077e39e --- /dev/null +++ b/custom_components/mb_secure/api.py @@ -0,0 +1,200 @@ +"""Asynchronous client for the vendor-neutral Bridge API v1.""" + +from __future__ import annotations + +import asyncio +import json +from collections.abc import AsyncIterator +from typing import Any +from urllib.parse import quote, urlsplit + +import aiohttp + +from .const import API_VERSION, MIN_TOKEN_LENGTH +from .models import ( + BridgeDataError, + BridgeInfo, + BridgeSnapshot, + CommandResult, + DomainEvent, +) + + +class BridgeError(Exception): + """Base bridge client error.""" + + +class BridgeConnectionError(BridgeError): + """Raised when the bridge cannot be reached.""" + + +class BridgeAuthenticationError(BridgeError): + """Raised when the local bridge token is rejected.""" + + +class BridgeVersionError(BridgeError): + """Raised when the bridge API version is unsupported.""" + + +class BridgeResponseError(BridgeError): + """Raised when the bridge returns an invalid response.""" + + +def normalize_base_url(host: str, port: int) -> str: + """Build a normalized local bridge base URL.""" + candidate = host.strip() + if not candidate: + raise ValueError("host must not be empty") + if "://" not in candidate: + candidate = f"http://{candidate}" + parsed = urlsplit(candidate) + if parsed.scheme not in {"http", "https"} or not parsed.hostname: + raise ValueError("host must be a valid HTTP or HTTPS address") + if parsed.path not in {"", "/"} or parsed.query or parsed.fragment: + raise ValueError("host must not contain a path, query, or fragment") + effective_port = parsed.port or port + host_part = f"[{parsed.hostname}]" if ":" in parsed.hostname else parsed.hostname + return f"{parsed.scheme}://{host_part}:{effective_port}" + + +class BridgeClient: + """Client for Bridge API v1.""" + + def __init__( + self, + session: aiohttp.ClientSession, + *, + host: str, + port: int, + token: str, + ) -> None: + """Initialize the client without logging credentials.""" + if len(token) < MIN_TOKEN_LENGTH: + raise ValueError("token is too short") + self._session = session + self._base_url = normalize_base_url(host, port) + self._headers = {"Authorization": f"Bearer {token}"} + + @property + def base_url(self) -> str: + """Return the normalized non-secret endpoint.""" + return self._base_url + + async def async_get_info(self) -> BridgeInfo: + """Return bridge metadata and enforce API compatibility.""" + info = BridgeInfo.from_dict(await self._async_request_json("GET", "/v1/info")) + if info.api_version != API_VERSION: + raise BridgeVersionError( + f"unsupported Bridge API version {info.api_version}" + ) + return info + + async def async_get_snapshot(self) -> BridgeSnapshot: + """Return the current normalized snapshot.""" + return BridgeSnapshot.from_dict( + await self._async_request_json("GET", "/v1/snapshot") + ) + + async def async_get_diagnostics(self) -> dict[str, Any]: + """Return diagnostics already sanitized by the bridge.""" + return await self._async_request_json("GET", "/v1/diagnostics") + + async def async_arm_area(self, object_id: str, mode: str) -> CommandResult: + """Arm an area using a bridge-reported mode capability.""" + return await self._async_command( + f"/v1/areas/{quote(object_id, safe='')}/arm", {"mode": mode} + ) + + async def async_disarm_area(self, object_id: str) -> CommandResult: + """Disarm an area.""" + return await self._async_command( + f"/v1/areas/{quote(object_id, safe='')}/disarm" + ) + + async def async_set_output(self, object_id: str, state: str) -> CommandResult: + """Set an output state.""" + return await self._async_command( + f"/v1/outputs/{quote(object_id, safe='')}/set", {"state": state} + ) + + async def async_run_command(self, command: str) -> CommandResult: + """Run a named capability reported by the bridge.""" + return await self._async_command(f"/v1/commands/{quote(command, safe='')}") + + async def async_events(self) -> AsyncIterator[DomainEvent]: + """Yield normalized server-sent events until disconnected.""" + try: + async with self._session.get( + f"{self._base_url}/v1/events", + headers=self._headers, + timeout=aiohttp.ClientTimeout( + total=None, sock_connect=10, sock_read=None + ), + ) as response: + self._raise_for_status(response.status) + data_lines: list[str] = [] + async for raw_line in response.content: + line = raw_line.decode("utf-8").rstrip("\r\n") + if not line: + if data_lines: + yield self._parse_event("\n".join(data_lines)) + data_lines.clear() + continue + if line.startswith("data:"): + data_lines.append(line[5:].lstrip()) + if data_lines: + yield self._parse_event("\n".join(data_lines)) + raise BridgeConnectionError("bridge event stream ended") + except (TimeoutError, aiohttp.ClientError, UnicodeDecodeError) as err: + raise BridgeConnectionError("bridge event stream is unavailable") from err + + async def _async_command( + self, path: str, payload: dict[str, Any] | None = None + ) -> CommandResult: + result = CommandResult.from_dict( + await self._async_request_json("POST", path, payload) + ) + if not result.accepted: + raise BridgeResponseError("bridge rejected the command") + return result + + async def _async_request_json( + self, + method: str, + path: str, + request_payload: dict[str, Any] | None = None, + ) -> dict[str, Any]: + try: + async with asyncio.timeout(10): + async with self._session.request( + method, + f"{self._base_url}{path}", + headers=self._headers, + json=request_payload, + ) as response: + self._raise_for_status(response.status) + response_payload = await response.json(content_type=None) + except (TimeoutError, aiohttp.ClientError) as err: + raise BridgeConnectionError("bridge is unavailable") from err + except ValueError as err: + raise BridgeResponseError("bridge returned invalid JSON") from err + if not isinstance(response_payload, dict): + raise BridgeResponseError("bridge response must be an object") + return response_payload + + @staticmethod + def _raise_for_status(status: int) -> None: + if status in {401, 403}: + raise BridgeAuthenticationError("bridge authentication failed") + if status >= 400: + raise BridgeResponseError(f"bridge request failed with HTTP {status}") + + @staticmethod + def _parse_event(payload: str) -> DomainEvent: + try: + value = json.loads(payload) + if not isinstance(value, dict): + raise BridgeDataError("event must be an object") + return DomainEvent.from_dict(value) + except ValueError as err: + raise BridgeResponseError("bridge returned an invalid event") from err diff --git a/custom_components/mb_secure/binary_sensor.py b/custom_components/mb_secure/binary_sensor.py new file mode 100644 index 0000000..f1e2a60 --- /dev/null +++ b/custom_components/mb_secure/binary_sensor.py @@ -0,0 +1,49 @@ +"""Binary sensor entities for MB-Secure.""" + +from __future__ import annotations + +from homeassistant.components.binary_sensor import BinarySensorEntity +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .coordinator import MBSecureCoordinator +from .entity import MBSecureEntity, async_setup_dynamic_entities + + +async def async_setup_entry( + hass: HomeAssistant, + entry: ConfigEntry[MBSecureCoordinator], + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up capability-reported point entities.""" + coordinator = entry.runtime_data + assert coordinator.info is not None and coordinator.info.bridge_id is not None + bridge_id = coordinator.info.bridge_id + async_setup_dynamic_entities( + entry, + async_add_entities, + collection_name="points", + capability="binary_state", + factory=lambda object_id: MBSecurePointEntity( + coordinator, bridge_id, object_id + ), + ) + + +class MBSecurePointEntity(MBSecureEntity, BinarySensorEntity): + """Represent one normalized binary point.""" + + def __init__( + self, coordinator: MBSecureCoordinator, bridge_id: str, object_id: str + ) -> None: + """Initialize a point entity.""" + super().__init__(coordinator, bridge_id, "points", object_id) + + @property + def is_on(self) -> bool | None: + """Return a conservative normalized binary state.""" + item = self.bridge_object + if item is None: + return None + return item.state in {"active", "on", "open", "triggered"} diff --git a/custom_components/mb_secure/config_flow.py b/custom_components/mb_secure/config_flow.py new file mode 100644 index 0000000..bc3fdff --- /dev/null +++ b/custom_components/mb_secure/config_flow.py @@ -0,0 +1,199 @@ +"""Config flow for MB-Secure.""" + +from __future__ import annotations + +from collections.abc import Mapping +from typing import Any, override + +import voluptuous as vol +from homeassistant.config_entries import ConfigFlow, ConfigFlowResult +from homeassistant.const import CONF_HOST, CONF_PORT +from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.service_info.hassio import HassioServiceInfo + +from .api import ( + BridgeAuthenticationError, + BridgeClient, + BridgeConnectionError, + BridgeResponseError, + BridgeVersionError, +) +from .const import ( + CONF_TOKEN, + DEFAULT_HOST, + DEFAULT_PORT, + DOMAIN, + MIN_TOKEN_LENGTH, +) +from .models import BridgeInfo + + +class MBSecureConfigFlow(ConfigFlow, domain=DOMAIN): + """Handle MB-Secure configuration.""" + + def __init__(self) -> None: + """Initialize flow state.""" + self._discovery_data: dict[str, Any] | None = None + + @override + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle manual setup.""" + errors: dict[str, str] = {} + if user_input is not None: + error, info = await self._async_validate(user_input) + if error is None: + assert info is not None + if info.bridge_id is None: + errors["base"] = "missing_bridge_id" + return self._show_user_form(user_input, errors) + await self.async_set_unique_id(info.bridge_id) + self._abort_if_unique_id_configured() + return self.async_create_entry( + title="MB-Secure Bridge", data=user_input + ) + else: + errors["base"] = error + + return self._show_user_form(user_input, errors) + + def _show_user_form( + self, + user_input: dict[str, Any] | None, + errors: dict[str, str], + ) -> ConfigFlowResult: + """Show the manual configuration form.""" + schema = vol.Schema( + { + vol.Required( + CONF_HOST, + default=(user_input or {}).get(CONF_HOST, DEFAULT_HOST), + ): str, + vol.Required( + CONF_PORT, + default=(user_input or {}).get(CONF_PORT, DEFAULT_PORT), + ): vol.All(vol.Coerce(int), vol.Range(min=1, max=65535)), + vol.Required(CONF_TOKEN): vol.All( + str, vol.Length(min=MIN_TOKEN_LENGTH) + ), + } + ) + return self.async_show_form(step_id="user", data_schema=schema, errors=errors) + + @override + async def async_step_hassio( + self, discovery_info: HassioServiceInfo + ) -> ConfigFlowResult: + """Handle discovery from the MB-Secure Bridge app.""" + config = discovery_info.config + if not all(key in config for key in (CONF_HOST, CONF_PORT, CONF_TOKEN)): + return self.async_abort(reason="invalid_discovery") + if not isinstance(config[CONF_HOST], str) or not isinstance( + config[CONF_TOKEN], str + ): + return self.async_abort(reason="invalid_discovery") + if isinstance(config[CONF_PORT], bool): + return self.async_abort(reason="invalid_discovery") + try: + host = config[CONF_HOST] + port = int(config[CONF_PORT]) + token = config[CONF_TOKEN] + except (TypeError, ValueError): + return self.async_abort(reason="invalid_discovery") + if not host or len(token) < MIN_TOKEN_LENGTH or not 1 <= port <= 65535: + return self.async_abort(reason="invalid_discovery") + self._discovery_data = { + CONF_HOST: host, + CONF_PORT: port, + CONF_TOKEN: token, + } + await self.async_set_unique_id(discovery_info.uuid) + self._abort_if_unique_id_configured(updates={CONF_HOST: host, CONF_PORT: port}) + return await self.async_step_hassio_confirm() + + async def async_step_hassio_confirm( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Confirm Supervisor discovery before creating an entry.""" + if self._discovery_data is None: + return self.async_abort(reason="invalid_discovery") + errors: dict[str, str] = {} + if user_input is not None: + error, info = await self._async_validate(self._discovery_data) + if error is None: + assert info is not None + if info.bridge_id is None: + errors["base"] = "missing_bridge_id" + else: + await self.async_set_unique_id(info.bridge_id) + self._abort_if_unique_id_configured( + updates={ + CONF_HOST: self._discovery_data[CONF_HOST], + CONF_PORT: self._discovery_data[CONF_PORT], + } + ) + return self.async_create_entry( + title="MB-Secure Bridge", data=self._discovery_data + ) + else: + errors["base"] = error + return self.async_show_form( + step_id="hassio_confirm", + data_schema=vol.Schema({}), + errors=errors, + description_placeholders={"host": self._discovery_data[CONF_HOST]}, + ) + + async def async_step_reauth( + self, entry_data: Mapping[str, Any] + ) -> ConfigFlowResult: + """Start reauthentication.""" + self._discovery_data = dict(entry_data) + return await self.async_step_reauth_confirm() + + async def async_step_reauth_confirm( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Replace a rejected local bridge token.""" + errors: dict[str, str] = {} + if user_input is not None: + reauth_entry = self._get_reauth_entry() + candidate = {**reauth_entry.data, CONF_TOKEN: user_input[CONF_TOKEN]} + error, _ = await self._async_validate(candidate) + if error is None: + return self.async_update_reload_and_abort( + reauth_entry, + data_updates={CONF_TOKEN: user_input[CONF_TOKEN]}, + ) + errors["base"] = error + return self.async_show_form( + step_id="reauth_confirm", + data_schema=vol.Schema( + { + vol.Required(CONF_TOKEN): vol.All( + str, vol.Length(min=MIN_TOKEN_LENGTH) + ) + } + ), + errors=errors, + ) + + async def _async_validate( + self, data: dict[str, Any] + ) -> tuple[str | None, BridgeInfo | None]: + try: + client = BridgeClient( + async_get_clientsession(self.hass), + host=data[CONF_HOST], + port=data[CONF_PORT], + token=data[CONF_TOKEN], + ) + info = await client.async_get_info() + except BridgeAuthenticationError: + return "invalid_auth", None + except BridgeVersionError: + return "unsupported_version", None + except (BridgeConnectionError, BridgeResponseError, ValueError): + return "cannot_connect", None + return None, info diff --git a/custom_components/mb_secure/const.py b/custom_components/mb_secure/const.py index 39286d4..53d3655 100644 --- a/custom_components/mb_secure/const.py +++ b/custom_components/mb_secure/const.py @@ -1,3 +1,23 @@ """Constants for the MB-Secure integration.""" +from datetime import timedelta + +from homeassistant.const import Platform + DOMAIN = "mb_secure" + +CONF_TOKEN = "token" + +DEFAULT_HOST = "localhost" +DEFAULT_PORT = 8099 +DEFAULT_SCAN_INTERVAL = timedelta(minutes=1) + +API_VERSION = 1 +MIN_TOKEN_LENGTH = 32 + +PLATFORMS = ( + Platform.ALARM_CONTROL_PANEL, + Platform.BINARY_SENSOR, + Platform.SENSOR, + Platform.SWITCH, +) diff --git a/custom_components/mb_secure/coordinator.py b/custom_components/mb_secure/coordinator.py new file mode 100644 index 0000000..0ed6547 --- /dev/null +++ b/custom_components/mb_secure/coordinator.py @@ -0,0 +1,110 @@ +"""Data coordinator for MB-Secure Bridge API v1.""" + +from __future__ import annotations + +import asyncio +import logging +from typing import Any, override + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryAuthFailed +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .api import ( + BridgeAuthenticationError, + BridgeClient, + BridgeConnectionError, + BridgeResponseError, + BridgeVersionError, +) +from .const import DEFAULT_SCAN_INTERVAL, DOMAIN +from .models import BridgeDataError, BridgeInfo, BridgeSnapshot + +_LOGGER = logging.getLogger(__name__) + + +class MBSecureCoordinator(DataUpdateCoordinator[BridgeSnapshot]): + """Coordinate snapshots and push events from one local bridge.""" + + def __init__( + self, + hass: HomeAssistant, + config_entry: ConfigEntry[MBSecureCoordinator], + client: BridgeClient, + ) -> None: + """Initialize the coordinator.""" + super().__init__( + hass, + logger=_LOGGER, + config_entry=config_entry, + name=DOMAIN, + update_interval=DEFAULT_SCAN_INTERVAL, + always_update=False, + ) + self.client = client + self._entry: ConfigEntry[Any] = config_entry + self.info: BridgeInfo | None = None + self._event_task: asyncio.Task[None] | None = None + + @override + async def _async_update_data(self) -> BridgeSnapshot: + """Fetch a full snapshot for startup and periodic reconciliation.""" + try: + info = await self.client.async_get_info() + if info.bridge_id is None: + raise BridgeDataError("bridge installation ID is missing") + if info.bridge_id != self._entry.unique_id: + raise BridgeDataError("bridge installation ID has changed") + self.info = info + return await self.client.async_get_snapshot() + except BridgeAuthenticationError as err: + raise ConfigEntryAuthFailed("Bridge authentication failed") from err + except ( + BridgeConnectionError, + BridgeResponseError, + BridgeVersionError, + BridgeDataError, + ) as err: + raise UpdateFailed(f"Bridge update failed: {err}") from err + + def async_start_event_listener(self) -> None: + """Start the config-entry-managed event listener.""" + if self._event_task is not None: + return + self._event_task = self._entry.async_create_background_task( + self.hass, + self._async_event_loop(), + f"{DOMAIN}-events-{self._entry.entry_id}", + ) + + async def async_shutdown(self) -> None: + """Stop background event handling.""" + if self._event_task is None: + return + self._event_task.cancel() + await asyncio.gather(self._event_task, return_exceptions=True) + self._event_task = None + + async def _async_event_loop(self) -> None: + """Reconnect to events and reconcile gaps with a snapshot.""" + reconnect_delay = 1 + while True: + try: + async for event in self.client.async_events(): + reconnect_delay = 1 + updated = self.data.apply_event(event) + if updated is None: + await self.async_request_refresh() + else: + self.async_set_updated_data(updated) + except BridgeAuthenticationError: + self._entry.async_start_reauth(self.hass) + return + except (BridgeConnectionError, BridgeResponseError, BridgeDataError): + _LOGGER.debug( + "Bridge event stream disconnected; retrying in %s seconds", + reconnect_delay, + ) + await asyncio.sleep(reconnect_delay) + reconnect_delay = min(reconnect_delay * 2, 60) diff --git a/custom_components/mb_secure/devices.py b/custom_components/mb_secure/devices.py new file mode 100644 index 0000000..5247e4c --- /dev/null +++ b/custom_components/mb_secure/devices.py @@ -0,0 +1,77 @@ +"""Device registry synchronization for MB-Secure.""" + +from __future__ import annotations + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers import device_registry as dr + +from .const import DOMAIN +from .coordinator import MBSecureCoordinator + + +def _identifier(bridge_id: str, object_type: str, object_id: str) -> tuple[str, str]: + return (DOMAIN, f"{bridge_id}:{object_type}:{object_id}") + + +def async_setup_devices( + hass: HomeAssistant, entry: ConfigEntry[MBSecureCoordinator] +) -> None: + """Create and dynamically synchronize normalized bridge devices.""" + coordinator = entry.runtime_data + assert coordinator.info is not None and coordinator.info.bridge_id is not None + bridge_id = coordinator.info.bridge_id + registry = dr.async_get(hass) + managed = { + identifier: device.id + for device in registry.async_get_devices(config_entry_id=entry.entry_id) + for identifier in device.identifiers + if identifier[0] == DOMAIN and identifier[1].startswith(f"{bridge_id}:") + } + + @callback + def sync_devices() -> None: + desired: set[tuple[str, str]] = set() + for object_type, collection in ( + ("controller", coordinator.data.controllers), + ("area", coordinator.data.areas), + ("module", coordinator.data.modules), + ): + desired.update( + _identifier(bridge_id, object_type, object_id) + for object_id in collection + ) + + controller_ids: dict[str, str] = {} + for object_id, item in coordinator.data.controllers.items(): + identifier = _identifier(bridge_id, "controller", object_id) + device = registry.async_get_or_create( + config_entry_id=entry.entry_id, + identifiers={identifier}, + name=item.name or item.id, + model="Controller", + ) + managed[identifier] = device.id + controller_ids[object_id] = device.id + + for object_type, collection in ( + ("area", coordinator.data.areas), + ("module", coordinator.data.modules), + ): + for object_id, item in collection.items(): + assert item.controller_id is not None + identifier = _identifier(bridge_id, object_type, object_id) + device = registry.async_get_or_create( + config_entry_id=entry.entry_id, + identifiers={identifier}, + name=item.name or item.id, + model=object_type.title(), + via_device_id=controller_ids[item.controller_id], + ) + managed[identifier] = device.id + + for identifier in managed.keys() - desired: + registry.async_remove_device(managed.pop(identifier)) + + sync_devices() + entry.async_on_unload(coordinator.async_add_listener(sync_devices)) diff --git a/custom_components/mb_secure/diagnostics.py b/custom_components/mb_secure/diagnostics.py new file mode 100644 index 0000000..54fdf49 --- /dev/null +++ b/custom_components/mb_secure/diagnostics.py @@ -0,0 +1,28 @@ +"""Diagnostics support for MB-Secure.""" + +from __future__ import annotations + +from typing import Any + +from homeassistant.const import CONF_HOST +from homeassistant.core import HomeAssistant +from homeassistant.helpers.redact import async_redact_data + +from . import MBSecureConfigEntry +from .const import CONF_TOKEN + + +async def async_get_config_entry_diagnostics( + hass: HomeAssistant, entry: MBSecureConfigEntry +) -> dict[str, Any]: + """Return diagnostics without exposing the local bridge token.""" + bridge_diagnostics = await entry.runtime_data.client.async_get_diagnostics() + safe_bridge_data = { + key: bridge_diagnostics[key] + for key in ("api_version", "connected", "snapshot_revision", "object_counts") + if key in bridge_diagnostics + } + return { + "config_entry": async_redact_data(dict(entry.data), {CONF_HOST, CONF_TOKEN}), + "bridge": safe_bridge_data, + } diff --git a/custom_components/mb_secure/entity.py b/custom_components/mb_secure/entity.py new file mode 100644 index 0000000..e67923b --- /dev/null +++ b/custom_components/mb_secure/entity.py @@ -0,0 +1,151 @@ +"""Shared entity support for MB-Secure.""" + +from __future__ import annotations + +from collections.abc import Callable +from typing import Any + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import callback +from homeassistant.helpers import entity_registry as er +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import DOMAIN +from .coordinator import MBSecureCoordinator +from .models import BridgeObject + +type EntityFactory = Callable[[str], MBSecureEntity] + + +async def _async_remove_entity(entity: MBSecureEntity) -> None: + """Remove a topology entity from runtime and the entity registry.""" + entity_id = entity.entity_id + await entity.async_remove(force_remove=True) + if entity_id is not None: + er.async_get(entity.coordinator.hass).async_remove(entity_id) + + +class MBSecureEntity(CoordinatorEntity[MBSecureCoordinator]): + """Base class for an entity backed by one normalized bridge object.""" + + _attr_has_entity_name = True + + def __init__( + self, + coordinator: MBSecureCoordinator, + bridge_id: str, + collection_name: str, + object_id: str, + ) -> None: + """Initialize an entity with stable technical identifiers.""" + super().__init__(coordinator) + self._bridge_id = bridge_id + self._collection_name = collection_name + self._object_id = object_id + self._attr_unique_id = f"{bridge_id}:{collection_name}:{object_id}" + + @property + def bridge_object(self) -> BridgeObject | None: + """Return the latest normalized object, if it still exists.""" + return getattr(self.coordinator.data, self._collection_name).get( + self._object_id + ) + + @property + def available(self) -> bool: + """Report availability from both transport and topology state.""" + return super().available and self.bridge_object is not None + + @property + def name(self) -> str | None: + """Return a display name without using it for identity.""" + item = self.bridge_object + if self._collection_name in {"areas", "controllers", "modules"}: + return None + return item.name if item is not None and item.name else self._object_id + + @property + def device_info(self) -> DeviceInfo | None: + """Attach the entity to the normalized device hierarchy.""" + item = self.bridge_object + if item is None: + return None + return _device_info(self._bridge_id, self._collection_name, item) + + +def _identifier(bridge_id: str, object_type: str, object_id: str) -> tuple[str, str]: + return (DOMAIN, f"{bridge_id}:{object_type}:{object_id}") + + +def _device_info( + bridge_id: str, + collection_name: str, + item: BridgeObject, +) -> DeviceInfo: + object_type = collection_name.removesuffix("s") + if collection_name == "controllers": + return DeviceInfo( + identifiers={_identifier(bridge_id, object_type, item.id)}, + name=item.name or item.id, + model="Controller", + ) + if collection_name in {"areas", "modules"}: + return DeviceInfo( + identifiers={_identifier(bridge_id, object_type, item.id)}, + name=item.name or item.id, + model=object_type.title(), + ) + + parent_type = "controller" + parent_id = item.controller_id + if item.module_id is not None: + parent_type = "module" + parent_id = item.module_id + elif item.area_id is not None: + parent_type = "area" + parent_id = item.area_id + assert parent_id is not None + return DeviceInfo(identifiers={_identifier(bridge_id, parent_type, parent_id)}) + + +def async_setup_dynamic_entities( + entry: ConfigEntry[Any], + async_add_entities: AddEntitiesCallback, + *, + collection_name: str, + capability: str | frozenset[str], + factory: EntityFactory, +) -> None: + """Keep a capability-filtered platform synchronized with topology changes.""" + coordinator: MBSecureCoordinator = entry.runtime_data + entities: dict[str, MBSecureEntity] = {} + + @callback + def sync_entities() -> None: + collection: dict[str, BridgeObject] = getattr(coordinator.data, collection_name) + required_capabilities = ( + frozenset({capability}) if isinstance(capability, str) else capability + ) + desired = { + object_id + for object_id, item in collection.items() + if required_capabilities & item.capabilities + } + added = [factory(object_id) for object_id in desired - entities.keys()] + for entity in added: + entities[entity._object_id] = entity + if added: + async_add_entities(added) + + for object_id in entities.keys() - desired: + entity = entities.pop(object_id) + entry.async_create_background_task( + coordinator.hass, + _async_remove_entity(entity), + f"{DOMAIN}-remove-{entity.unique_id}", + ) + + sync_entities() + entry.async_on_unload(coordinator.async_add_listener(sync_entities)) diff --git a/custom_components/mb_secure/manifest.json b/custom_components/mb_secure/manifest.json index 63df0bd..ebb5cc5 100644 --- a/custom_components/mb_secure/manifest.json +++ b/custom_components/mb_secure/manifest.json @@ -2,7 +2,7 @@ "domain": "mb_secure", "name": "MB-Secure", "version": "0.1.0", - "config_flow": false, + "config_flow": true, "documentation": "https://git.bahmcloud.de/bahmcloud/home-assistant-mb-secure", "integration_type": "hub", "iot_class": "local_push", diff --git a/custom_components/mb_secure/models.py b/custom_components/mb_secure/models.py new file mode 100644 index 0000000..d75892b --- /dev/null +++ b/custom_components/mb_secure/models.py @@ -0,0 +1,227 @@ +"""Vendor-neutral Bridge API v1 models.""" + +from __future__ import annotations + +from dataclasses import dataclass, replace +from typing import Any + + +class BridgeDataError(ValueError): + """Raised when a bridge response violates API v1.""" + + +def _required_string(data: dict[str, Any], key: str) -> str: + value = data.get(key) + if not isinstance(value, str) or not value: + raise BridgeDataError(f"{key} must be a non-empty string") + return value + + +def _string(data: dict[str, Any], key: str) -> str: + value = data.get(key) + if not isinstance(value, str): + raise BridgeDataError(f"{key} must be a string") + return value + + +def _optional_string(data: dict[str, Any], key: str) -> str | None: + value = data.get(key) + if value is None: + return None + if not isinstance(value, str) or not value: + raise BridgeDataError(f"{key} must be null or a non-empty string") + return value + + +def _capabilities(data: dict[str, Any]) -> frozenset[str]: + value = data.get("capabilities") + if not isinstance(value, list) or not all(isinstance(item, str) for item in value): + raise BridgeDataError("capabilities must be a list of strings") + return frozenset(value) + + +@dataclass(frozen=True, slots=True) +class BridgeInfo: + """Bridge metadata.""" + + bridge_id: str | None + bridge_version: str + api_version: int + connected: bool + capabilities: frozenset[str] + + @classmethod + def from_dict(cls, data: dict[str, Any]) -> BridgeInfo: + """Parse bridge metadata while tolerating additive fields.""" + api_version = data.get("api_version") + connected = data.get("connected") + if not isinstance(api_version, int) or isinstance(api_version, bool): + raise BridgeDataError("api_version must be an integer") + if not isinstance(connected, bool): + raise BridgeDataError("connected must be a boolean") + return cls( + bridge_id=_optional_string(data, "bridge_id"), + bridge_version=_required_string(data, "bridge_version"), + api_version=api_version, + connected=connected, + capabilities=_capabilities(data), + ) + + +@dataclass(frozen=True, slots=True) +class BridgeObject: + """Normalized object reported by the bridge.""" + + id: str + name: str + state: str + capabilities: frozenset[str] + controller_id: str | None = None + area_id: str | None = None + module_id: str | None = None + + @classmethod + def from_dict(cls, data: dict[str, Any]) -> BridgeObject: + """Parse a normalized object while tolerating additive fields.""" + return cls( + id=_required_string(data, "id"), + name=_string(data, "name"), + state=_string(data, "state"), + capabilities=_capabilities(data), + controller_id=_optional_string(data, "controller_id"), + area_id=_optional_string(data, "area_id"), + module_id=_optional_string(data, "module_id"), + ) + + +@dataclass(frozen=True, slots=True) +class DomainEvent: + """Normalized state event.""" + + revision: int + event_type: str + object_type: str + object_id: str + state: str + + @classmethod + def from_dict(cls, data: dict[str, Any]) -> DomainEvent: + """Parse a normalized event.""" + revision = data.get("revision") + if not isinstance(revision, int) or isinstance(revision, bool) or revision < 0: + raise BridgeDataError("revision must be a non-negative integer") + return cls( + revision=revision, + event_type=_required_string(data, "event_type"), + object_type=_required_string(data, "object_type"), + object_id=_required_string(data, "object_id"), + state=_required_string(data, "state"), + ) + + +@dataclass(frozen=True, slots=True) +class CommandResult: + """Result of a normalized bridge command.""" + + accepted: bool + command_id: str + message: str | None + + @classmethod + def from_dict(cls, data: dict[str, Any]) -> CommandResult: + """Parse a command result.""" + accepted = data.get("accepted") + if not isinstance(accepted, bool): + raise BridgeDataError("accepted must be a boolean") + return cls( + accepted=accepted, + command_id=_required_string(data, "command_id"), + message=_optional_string(data, "message"), + ) + + +@dataclass(frozen=True, slots=True) +class BridgeSnapshot: + """Complete normalized bridge state.""" + + revision: int + controllers: dict[str, BridgeObject] + areas: dict[str, BridgeObject] + points: dict[str, BridgeObject] + modules: dict[str, BridgeObject] + outputs: dict[str, BridgeObject] + + @classmethod + def from_dict(cls, data: dict[str, Any]) -> BridgeSnapshot: + """Parse and validate a full snapshot.""" + revision = data.get("revision") + if not isinstance(revision, int) or isinstance(revision, bool) or revision < 0: + raise BridgeDataError("revision must be a non-negative integer") + + def parse_collection(key: str) -> dict[str, BridgeObject]: + value = data.get(key) + if not isinstance(value, list): + raise BridgeDataError(f"{key} must be a list") + parsed = [ + BridgeObject.from_dict(item) for item in value if isinstance(item, dict) + ] + if len(parsed) != len(value): + raise BridgeDataError(f"{key} must contain only objects") + result = {item.id: item for item in parsed} + if len(result) != len(parsed): + raise BridgeDataError(f"{key} contains duplicate IDs") + return result + + snapshot = cls( + revision=revision, + controllers=parse_collection("controllers"), + areas=parse_collection("areas"), + points=parse_collection("points"), + modules=parse_collection("modules"), + outputs=parse_collection("outputs"), + ) + snapshot._validate_references() + return snapshot + + def _validate_references(self) -> None: + for collection_name in ("areas", "points", "modules", "outputs"): + for item in getattr(self, collection_name).values(): + if item.controller_id not in self.controllers: + raise BridgeDataError( + f"{collection_name} contains an unknown controller reference" + ) + for point in self.points.values(): + if point.area_id is not None and point.area_id not in self.areas: + raise BridgeDataError("points contains an unknown area reference") + if point.module_id is not None and point.module_id not in self.modules: + raise BridgeDataError("points contains an unknown module reference") + for output in self.outputs.values(): + if output.module_id is not None and output.module_id not in self.modules: + raise BridgeDataError("outputs contains an unknown module reference") + + def apply_event(self, event: DomainEvent) -> BridgeSnapshot | None: + """Apply a contiguous state event or request snapshot reconciliation.""" + if event.revision != self.revision + 1 or event.event_type != "state_changed": + return None + collection_name = { + "controller": "controllers", + "area": "areas", + "point": "points", + "module": "modules", + "output": "outputs", + }.get(event.object_type) + if collection_name is None: + return None + collection = getattr(self, collection_name) + current = collection.get(event.object_id) + if current is None: + return None + updated_collection = { + **collection, + event.object_id: replace(current, state=event.state), + } + return replace( + self, + revision=event.revision, + **{collection_name: updated_collection}, + ) diff --git a/custom_components/mb_secure/sensor.py b/custom_components/mb_secure/sensor.py new file mode 100644 index 0000000..9909d4a --- /dev/null +++ b/custom_components/mb_secure/sensor.py @@ -0,0 +1,47 @@ +"""Sensor entities for MB-Secure.""" + +from __future__ import annotations + +from functools import partial + +from homeassistant.components.sensor import SensorEntity +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .coordinator import MBSecureCoordinator +from .entity import MBSecureEntity, async_setup_dynamic_entities + + +async def async_setup_entry( + hass: HomeAssistant, + entry: ConfigEntry[MBSecureCoordinator], + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up capability-reported controller and module status entities.""" + coordinator = entry.runtime_data + assert coordinator.info is not None and coordinator.info.bridge_id is not None + bridge_id = coordinator.info.bridge_id + for collection_name, capability in ( + ("controllers", "snapshot"), + ("modules", "status"), + ): + async_setup_dynamic_entities( + entry, + async_add_entities, + collection_name=collection_name, + capability=capability, + factory=partial( + MBSecureStateEntity, coordinator, bridge_id, collection_name + ), + ) + + +class MBSecureStateEntity(MBSecureEntity, SensorEntity): + """Represent a normalized textual object state.""" + + @property + def native_value(self) -> str | None: + """Return the normalized state value.""" + item = self.bridge_object + return item.state if item is not None else None diff --git a/custom_components/mb_secure/switch.py b/custom_components/mb_secure/switch.py new file mode 100644 index 0000000..6e9edb1 --- /dev/null +++ b/custom_components/mb_secure/switch.py @@ -0,0 +1,61 @@ +"""Switch entities for MB-Secure outputs.""" + +from __future__ import annotations + +from typing import Any + +from homeassistant.components.switch import SwitchEntity +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .coordinator import MBSecureCoordinator +from .entity import MBSecureEntity, async_setup_dynamic_entities + + +async def async_setup_entry( + hass: HomeAssistant, + entry: ConfigEntry[MBSecureCoordinator], + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up capability-reported output switches.""" + coordinator = entry.runtime_data + assert coordinator.info is not None and coordinator.info.bridge_id is not None + bridge_id = coordinator.info.bridge_id + async_setup_dynamic_entities( + entry, + async_add_entities, + collection_name="outputs", + capability="set", + factory=lambda object_id: MBSecureOutputEntity( + coordinator, bridge_id, object_id + ), + ) + + +class MBSecureOutputEntity(MBSecureEntity, SwitchEntity): + """Represent one controllable normalized output.""" + + def __init__( + self, coordinator: MBSecureCoordinator, bridge_id: str, object_id: str + ) -> None: + """Initialize an output switch.""" + super().__init__(coordinator, bridge_id, "outputs", object_id) + + @property + def is_on(self) -> bool | None: + """Return the normalized output state.""" + item = self.bridge_object + if item is None: + return None + return item.state == "on" + + async def async_turn_on(self, **kwargs: Any) -> None: + """Turn the output on.""" + await self.coordinator.client.async_set_output(self._object_id, "on") + await self.coordinator.async_request_refresh() + + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn the output off.""" + await self.coordinator.client.async_set_output(self._object_id, "off") + await self.coordinator.async_request_refresh() diff --git a/custom_components/mb_secure/translations/en.json b/custom_components/mb_secure/translations/en.json new file mode 100644 index 0000000..7a5acbf --- /dev/null +++ b/custom_components/mb_secure/translations/en.json @@ -0,0 +1,42 @@ +{ + "title": "MB-Secure", + "config": { + "step": { + "user": { + "title": "Connect to MB-Secure Bridge", + "description": "Enter the local Bridge API connection details.", + "data": { + "host": "Host", + "port": "Port", + "token": "Bridge token" + }, + "data_description": { + "host": "Local host name or address of the bridge.", + "port": "Local Bridge API port.", + "token": "Local bearer token generated by the bridge." + } + }, + "hassio_confirm": { + "title": "Set up MB-Secure Bridge", + "description": "A local MB-Secure Bridge app was discovered at {host}." + }, + "reauth_confirm": { + "title": "Update bridge authentication", + "description": "Enter the current local bridge token.", + "data": { + "token": "Bridge token" + } + } + }, + "error": { + "cannot_connect": "Unable to connect to the bridge", + "invalid_auth": "The bridge token was rejected", + "missing_bridge_id": "The bridge is missing a stable installation ID", + "unsupported_version": "The bridge API version is not supported" + }, + "abort": { + "already_configured": "This bridge is already configured", + "invalid_discovery": "The discovered bridge information is invalid" + } + } +} diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..05e2345 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,14 @@ +[tool.pytest.ini_options] +asyncio_mode = "auto" +pythonpath = ["."] +testpaths = ["tests"] + +[tool.ruff] +line-length = 88 +target-version = "py313" + +[tool.ruff.lint] +select = ["E", "F", "I", "UP", "B", "ASYNC", "RUF"] + +[tool.mypy] +explicit_package_bases = true diff --git a/requirements_test.txt b/requirements_test.txt new file mode 100644 index 0000000..4bb7a09 --- /dev/null +++ b/requirements_test.txt @@ -0,0 +1,3 @@ +pytest-homeassistant-custom-component==0.13.363 +ruff==0.16.6 +mypy==2.3.1 diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..f563c66 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,10 @@ +"""Shared fixtures for MB-Secure integration tests.""" + +import pytest + +pytest_plugins = "pytest_homeassistant_custom_component" + + +@pytest.fixture(autouse=True) +def auto_enable_custom_integrations(enable_custom_integrations: None) -> None: + """Enable loading the custom integration in every test.""" diff --git a/tests/test_api.py b/tests/test_api.py new file mode 100644 index 0000000..82d7644 --- /dev/null +++ b/tests/test_api.py @@ -0,0 +1,115 @@ +"""Tests for the asynchronous neutral Bridge API client.""" + +import json + +import pytest +from aiohttp import web +from aiohttp.test_utils import TestClient + +from custom_components.mb_secure.api import ( + BridgeAuthenticationError, + BridgeClient, +) + +TOKEN = "x" * 32 + + +async def test_client_authenticates_and_parses_additive_info( + aiohttp_client, + socket_enabled: None, +) -> None: + """The client authenticates locally and ignores additive response fields.""" + + async def info(request: web.Request) -> web.Response: + assert request.headers["Authorization"] == f"Bearer {TOKEN}" + return web.json_response( + { + "bridge_id": "bridge-installation-1", + "bridge_version": "0.1.0", + "api_version": 1, + "connected": True, + "capabilities": ["snapshot", "events"], + "future_field": True, + } + ) + + app = web.Application() + app.router.add_get("/v1/info", info) + http_client: TestClient = await aiohttp_client(app) + client = BridgeClient( + http_client.session, + host=str(http_client.make_url("/")), + port=80, + token=TOKEN, + ) + + result = await client.async_get_info() + + assert result.bridge_id == "bridge-installation-1" + assert result.connected + + +async def test_client_maps_authentication_failures( + aiohttp_client, + socket_enabled: None, +) -> None: + """HTTP authentication failures use the dedicated client exception.""" + + async def unauthorized(request: web.Request) -> web.Response: + return web.json_response({}, status=401) + + app = web.Application() + app.router.add_get("/v1/info", unauthorized) + http_client: TestClient = await aiohttp_client(app) + client = BridgeClient( + http_client.session, + host=str(http_client.make_url("/")), + port=80, + token=TOKEN, + ) + + with pytest.raises(BridgeAuthenticationError): + await client.async_get_info() + + +async def test_client_parses_server_sent_domain_events( + aiohttp_client, + socket_enabled: None, +) -> None: + """The event client accepts the mock's server-sent event framing.""" + event_payload = { + "revision": 2, + "event_type": "state_changed", + "object_type": "area", + "object_id": "area-1", + "state": "armed_away", + } + + async def events(request: web.Request) -> web.StreamResponse: + response = web.StreamResponse( + status=200, + headers={"Content-Type": "text/event-stream"}, + ) + await response.prepare(request) + await response.write( + f"event: domain_event\ndata: {json.dumps(event_payload)}\n\n".encode() + ) + await response.write_eof() + return response + + app = web.Application() + app.router.add_get("/v1/events", events) + http_client: TestClient = await aiohttp_client(app) + client = BridgeClient( + http_client.session, + host=str(http_client.make_url("/")), + port=80, + token=TOKEN, + ) + + stream = client.async_events() + event = await anext(stream) + await stream.aclose() + + assert event.object_id == "area-1" + assert event.revision == 2 diff --git a/tests/test_config_flow.py b/tests/test_config_flow.py new file mode 100644 index 0000000..0cd049b --- /dev/null +++ b/tests/test_config_flow.py @@ -0,0 +1,148 @@ +"""Tests for the MB-Secure config flow.""" + +from unittest.mock import patch + +from homeassistant.config_entries import SOURCE_HASSIO, SOURCE_USER +from homeassistant.const import CONF_HOST, CONF_PORT +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers.service_info.hassio import HassioServiceInfo + +from custom_components.mb_secure.api import BridgeAuthenticationError +from custom_components.mb_secure.const import CONF_TOKEN, DOMAIN +from custom_components.mb_secure.models import BridgeInfo + +USER_INPUT = { + CONF_HOST: "bridge.local", + CONF_PORT: 8099, + CONF_TOKEN: "x" * 32, +} + + +async def test_user_flow_creates_entry_with_stable_bridge_id( + hass: HomeAssistant, +) -> None: + """Successful validation uses the installation ID as config entry ID.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + + info = BridgeInfo( + bridge_id="bridge-installation-1", + bridge_version="0.1.0", + api_version=1, + connected=False, + capabilities=frozenset({"snapshot", "events"}), + ) + with patch( + "custom_components.mb_secure.api.BridgeClient.async_get_info", + return_value=info, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], USER_INPUT + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["data"] == USER_INPUT + assert result["result"].unique_id == "bridge-installation-1" + + +async def test_user_flow_reports_invalid_token(hass: HomeAssistant) -> None: + """A rejected local bridge token remains inside the form flow.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + + with patch( + "custom_components.mb_secure.api.BridgeClient.async_get_info", + side_effect=BridgeAuthenticationError, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], USER_INPUT + ) + + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": "invalid_auth"} + + +async def test_user_flow_rejects_bridge_without_stable_id( + hass: HomeAssistant, +) -> None: + """A bridge without an installation ID cannot create stable entities.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + info = BridgeInfo( + bridge_id=None, + bridge_version="0.1.0", + api_version=1, + connected=False, + capabilities=frozenset(), + ) + with patch( + "custom_components.mb_secure.api.BridgeClient.async_get_info", + return_value=info, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], USER_INPUT + ) + + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": "missing_bridge_id"} + + +async def test_hassio_discovery_requires_confirmation_and_uses_bridge_id( + hass: HomeAssistant, +) -> None: + """Supervisor discovery remains user-confirmed and adopts the bridge ID.""" + discovery = HassioServiceInfo( + config=USER_INPUT, + name="MB-Secure Bridge", + slug="mb_secure_bridge", + uuid="discovery-installation-1", + ) + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_HASSIO}, + data=discovery, + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "hassio_confirm" + + info = BridgeInfo( + bridge_id="bridge-installation-1", + bridge_version="0.1.0", + api_version=1, + connected=True, + capabilities=frozenset({"snapshot", "events"}), + ) + with patch( + "custom_components.mb_secure.api.BridgeClient.async_get_info", + return_value=info, + ): + result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["result"].unique_id == "bridge-installation-1" + assert result["data"] == USER_INPUT + + +async def test_hassio_discovery_rejects_incomplete_data( + hass: HomeAssistant, +) -> None: + """Supervisor discovery never guesses missing connection credentials.""" + discovery = HassioServiceInfo( + config={"host": "bridge.local"}, + name="MB-Secure Bridge", + slug="mb_secure_bridge", + uuid="discovery-installation-1", + ) + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_HASSIO}, + data=discovery, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "invalid_discovery" diff --git a/tests/test_coordinator.py b/tests/test_coordinator.py new file mode 100644 index 0000000..a0a2719 --- /dev/null +++ b/tests/test_coordinator.py @@ -0,0 +1,147 @@ +"""Tests for MB-Secure snapshot and event coordination.""" + +from unittest.mock import AsyncMock, MagicMock + +import pytest +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryAuthFailed +from homeassistant.helpers.update_coordinator import UpdateFailed +from pytest_homeassistant_custom_component.common import MockConfigEntry + +from custom_components.mb_secure.api import BridgeAuthenticationError +from custom_components.mb_secure.const import CONF_TOKEN, DOMAIN +from custom_components.mb_secure.coordinator import MBSecureCoordinator +from custom_components.mb_secure.models import BridgeInfo, BridgeSnapshot, DomainEvent + + +def snapshot(state: str = "disarmed", revision: int = 1) -> BridgeSnapshot: + """Return a minimal area snapshot.""" + return BridgeSnapshot.from_dict( + { + "revision": revision, + "controllers": [ + { + "id": "controller-1", + "name": "Controller", + "state": "online", + "capabilities": [], + } + ], + "areas": [ + { + "id": "area-1", + "controller_id": "controller-1", + "name": "Area", + "state": state, + "capabilities": ["arm_away"], + } + ], + "points": [], + "modules": [], + "outputs": [], + } + ) + + +def coordinator( + hass: HomeAssistant, client: MagicMock +) -> tuple[MBSecureCoordinator, MockConfigEntry]: + """Create a coordinator and attached mock config entry.""" + entry = MockConfigEntry( + domain=DOMAIN, + data={"host": "bridge.local", "port": 8099, CONF_TOKEN: "x" * 32}, + unique_id="bridge-installation-1", + ) + entry.add_to_hass(hass) + return MBSecureCoordinator(hass, entry, client), entry + + +async def test_snapshot_authentication_failure_starts_reauth( + hass: HomeAssistant, +) -> None: + """Rejected bridge credentials are converted to Home Assistant reauth.""" + client = MagicMock() + client.async_get_info = AsyncMock(side_effect=BridgeAuthenticationError) + instance, _ = coordinator(hass, client) + + with pytest.raises(ConfigEntryAuthFailed): + await instance._async_update_data() + + +async def test_snapshot_rejects_changed_bridge_identity( + hass: HomeAssistant, +) -> None: + """An endpoint cannot silently replace the configured bridge instance.""" + client = MagicMock() + client.async_get_info = AsyncMock( + return_value=BridgeInfo( + bridge_id="different-installation", + bridge_version="0.1.0", + api_version=1, + connected=True, + capabilities=frozenset(), + ) + ) + client.async_get_snapshot = AsyncMock(return_value=snapshot()) + instance, _ = coordinator(hass, client) + + with pytest.raises(UpdateFailed, match="bridge installation ID has changed"): + await instance._async_update_data() + + client.async_get_snapshot.assert_not_awaited() + + +async def test_contiguous_event_updates_coordinator_data( + hass: HomeAssistant, +) -> None: + """A contiguous event updates state without waiting for the next poll.""" + client = MagicMock() + + async def events(): + yield DomainEvent( + revision=2, + event_type="state_changed", + object_type="area", + object_id="area-1", + state="armed_away", + ) + raise BridgeAuthenticationError + + client.async_events = events + instance, entry = coordinator(hass, client) + instance.async_set_updated_data(snapshot()) + entry.async_start_reauth = MagicMock() + + await instance._async_event_loop() + + assert instance.data.revision == 2 + assert instance.data.areas["area-1"].state == "armed_away" + entry.async_start_reauth.assert_called_once_with(hass) + + +async def test_event_gap_requests_snapshot_reconciliation( + hass: HomeAssistant, +) -> None: + """A revision gap schedules a full snapshot refresh.""" + client = MagicMock() + + async def events(): + yield DomainEvent( + revision=3, + event_type="state_changed", + object_type="area", + object_id="area-1", + state="armed_away", + ) + raise BridgeAuthenticationError + + client.async_events = events + instance, entry = coordinator(hass, client) + instance.async_set_updated_data(snapshot()) + instance.async_request_refresh = AsyncMock() + entry.async_start_reauth = MagicMock() + + await instance._async_event_loop() + + instance.async_request_refresh.assert_awaited_once() + assert instance.data.revision == 1 diff --git a/tests/test_diagnostics.py b/tests/test_diagnostics.py new file mode 100644 index 0000000..6d707f0 --- /dev/null +++ b/tests/test_diagnostics.py @@ -0,0 +1,42 @@ +"""Tests for MB-Secure diagnostics redaction.""" + +from unittest.mock import AsyncMock, MagicMock + +from homeassistant.core import HomeAssistant +from pytest_homeassistant_custom_component.common import MockConfigEntry + +from custom_components.mb_secure.const import CONF_TOKEN, DOMAIN +from custom_components.mb_secure.diagnostics import async_get_config_entry_diagnostics + + +async def test_diagnostics_redact_config_and_whitelist_bridge_data( + hass: HomeAssistant, +) -> None: + """Tokens, hosts, and unrecognized bridge fields never leave diagnostics.""" + entry = MockConfigEntry( + domain=DOMAIN, + data={ + "host": "private-host.local", + "port": 8099, + CONF_TOKEN: "x" * 32, + }, + unique_id="bridge-installation-1", + ) + coordinator = MagicMock() + coordinator.client.async_get_diagnostics = AsyncMock( + return_value={ + "api_version": 1, + "connected": True, + "snapshot_revision": 4, + "object_counts": {"areas": 1}, + "unexpected_private_field": "must-not-leak", + } + ) + entry.runtime_data = coordinator + + result = await async_get_config_entry_diagnostics(hass, entry) + + assert result["config_entry"]["host"] != "private-host.local" + assert result["config_entry"][CONF_TOKEN] != entry.data[CONF_TOKEN] + assert result["config_entry"]["port"] == 8099 + assert "unexpected_private_field" not in result["bridge"] diff --git a/tests/test_entities.py b/tests/test_entities.py new file mode 100644 index 0000000..c880bde --- /dev/null +++ b/tests/test_entities.py @@ -0,0 +1,192 @@ +"""Tests for MB-Secure devices and entities.""" + +from copy import deepcopy +from unittest.mock import patch + +from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr +from homeassistant.helpers import entity_registry as er +from pytest_homeassistant_custom_component.common import MockConfigEntry + +from custom_components.mb_secure.const import CONF_TOKEN, DOMAIN +from custom_components.mb_secure.coordinator import MBSecureCoordinator +from custom_components.mb_secure.models import BridgeInfo, BridgeSnapshot + +ENTRY_DATA = { + "host": "bridge.local", + "port": 8099, + CONF_TOKEN: "x" * 32, +} + + +def snapshot_payload() -> dict[str, object]: + """Return a capability-complete normalized test snapshot.""" + return { + "revision": 1, + "controllers": [ + { + "id": "controller-1", + "name": "Main Controller", + "state": "online", + "capabilities": ["snapshot"], + } + ], + "areas": [ + { + "id": "area-1", + "controller_id": "controller-1", + "name": "Ground Floor", + "state": "disarmed", + "capabilities": ["arm_away", "disarm"], + } + ], + "points": [ + { + "id": "point-1", + "controller_id": "controller-1", + "area_id": "area-1", + "module_id": None, + "name": "Front Door", + "state": "closed", + "capabilities": ["binary_state"], + }, + { + "id": "point-without-capability", + "controller_id": "controller-1", + "area_id": "area-1", + "module_id": None, + "name": "Unsupported Point", + "state": "unknown", + "capabilities": [], + }, + ], + "modules": [ + { + "id": "module-1", + "controller_id": "controller-1", + "name": "Expansion Module", + "state": "online", + "capabilities": ["status"], + } + ], + "outputs": [ + { + "id": "output-1", + "controller_id": "controller-1", + "module_id": "module-1", + "name": "Indicator", + "state": "off", + "capabilities": ["set"], + } + ], + } + + +async def setup_entry(hass: HomeAssistant) -> MockConfigEntry: + """Set up an entry against mocked neutral Bridge API responses.""" + entry = MockConfigEntry( + domain=DOMAIN, + data=ENTRY_DATA, + unique_id="bridge-installation-1", + ) + entry.add_to_hass(hass) + info = BridgeInfo( + bridge_id="bridge-installation-1", + bridge_version="0.1.0", + api_version=1, + connected=True, + capabilities=frozenset({"snapshot", "events", "commands"}), + ) + snapshot = BridgeSnapshot.from_dict(snapshot_payload()) + with ( + patch( + "custom_components.mb_secure.api.BridgeClient.async_get_info", + return_value=info, + ), + patch( + "custom_components.mb_secure.api.BridgeClient.async_get_snapshot", + return_value=snapshot, + ), + patch.object(MBSecureCoordinator, "async_start_event_listener"), + ): + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + return entry + + +async def test_setup_creates_capability_gated_entities_and_hierarchy( + hass: HomeAssistant, +) -> None: + """Only reported capabilities create stable entities and linked devices.""" + entry = await setup_entry(hass) + registry = er.async_get(hass) + unique_ids = { + entity.unique_id + for entity in er.async_entries_for_config_entry(registry, entry.entry_id) + } + assert unique_ids == { + "bridge-installation-1:areas:area-1", + "bridge-installation-1:controllers:controller-1", + "bridge-installation-1:modules:module-1", + "bridge-installation-1:outputs:output-1", + "bridge-installation-1:points:point-1", + } + + device_registry = dr.async_get(hass) + controller = device_registry.async_get_device_by_identifier( + (DOMAIN, "bridge-installation-1:controller:controller-1"), + entry.entry_id, + ) + area = device_registry.async_get_device_by_identifier( + (DOMAIN, "bridge-installation-1:area:area-1"), + entry.entry_id, + ) + module = device_registry.async_get_device_by_identifier( + (DOMAIN, "bridge-installation-1:module:module-1"), + entry.entry_id, + ) + assert controller is not None + assert area is not None and area.via_device_id == controller.id + assert module is not None and module.via_device_id == controller.id + + +async def test_snapshot_topology_changes_add_and_remove_entities( + hass: HomeAssistant, +) -> None: + """Snapshot reconciliation updates the runtime entity topology.""" + entry = await setup_entry(hass) + coordinator = entry.runtime_data + registry = er.async_get(hass) + old_entry = registry.async_get_entity_id( + "binary_sensor", + DOMAIN, + "bridge-installation-1:points:point-1", + ) + assert old_entry is not None + payload = deepcopy(snapshot_payload()) + payload["revision"] = 2 + points = payload["points"] + assert isinstance(points, list) + points.pop(0) + points.append( + { + "id": "point-2", + "controller_id": "controller-1", + "area_id": "area-1", + "module_id": None, + "name": "Back Door", + "state": "open", + "capabilities": ["binary_state"], + } + ) + + coordinator.async_set_updated_data(BridgeSnapshot.from_dict(payload)) + await hass.async_block_till_done() + + unique_ids = { + entity.unique_id + for entity in er.async_entries_for_config_entry(registry, entry.entry_id) + } + assert "bridge-installation-1:points:point-1" not in unique_ids + assert "bridge-installation-1:points:point-2" in unique_ids + assert hass.states.get(old_entry) is None diff --git a/tests/test_models.py b/tests/test_models.py new file mode 100644 index 0000000..d6281ba --- /dev/null +++ b/tests/test_models.py @@ -0,0 +1,143 @@ +"""Tests for vendor-neutral Bridge API v1 models.""" + +from typing import Any + +import pytest + +from custom_components.mb_secure.models import ( + BridgeDataError, + BridgeInfo, + BridgeSnapshot, + DomainEvent, +) + + +def test_bridge_info_accepts_stable_bridge_id() -> None: + """Bridge metadata accepts the installation identifier used by config flow.""" + info = BridgeInfo.from_dict( + { + "bridge_id": "bridge-1", + "bridge_version": "0.1.0", + "api_version": 1, + "connected": False, + "capabilities": ["snapshot", "events"], + "future_field": "ignored", + } + ) + + assert info.bridge_id == "bridge-1" + + +def test_bridge_info_tolerates_legacy_v1_without_bridge_id() -> None: + """A newer client remains compatible with the original API v1 response.""" + info = BridgeInfo.from_dict( + { + "bridge_version": "0.1.0", + "api_version": 1, + "connected": False, + "capabilities": [], + } + ) + + assert info.bridge_id is None + + +def snapshot_payload() -> dict[str, Any]: + """Return a minimal neutral snapshot.""" + return { + "revision": 4, + "controllers": [ + { + "id": "controller-1", + "name": "Controller", + "state": "online", + "capabilities": [], + "future_field": True, + } + ], + "areas": [ + { + "id": "area-1", + "controller_id": "controller-1", + "name": "Area", + "state": "disarmed", + "capabilities": ["arm_away", "disarm"], + } + ], + "points": [], + "modules": [], + "outputs": [], + "future_collection": [], + } + + +def test_snapshot_tolerates_additive_fields() -> None: + """API v1 parsers ignore fields added in compatible updates.""" + snapshot = BridgeSnapshot.from_dict(snapshot_payload()) + + assert snapshot.controllers["controller-1"].name == "Controller" + assert snapshot.revision == 4 + + +def test_snapshot_accepts_empty_display_name_allowed_by_v1() -> None: + """Display names are not identifiers and may be empty in API v1.""" + payload = snapshot_payload() + payload["controllers"][0]["name"] = "" + + snapshot = BridgeSnapshot.from_dict(payload) + + assert snapshot.controllers["controller-1"].name == "" + + +def test_snapshot_rejects_duplicate_stable_ids() -> None: + """Duplicate technical IDs cannot create ambiguous entities.""" + payload = snapshot_payload() + payload["controllers"].append(dict(payload["controllers"][0])) + + with pytest.raises(BridgeDataError): + BridgeSnapshot.from_dict(payload) + + +def test_snapshot_rejects_unknown_parent_reference() -> None: + """Objects cannot reference a controller absent from the snapshot.""" + payload = snapshot_payload() + payload["areas"][0]["controller_id"] = "missing-controller" + + with pytest.raises(BridgeDataError): + BridgeSnapshot.from_dict(payload) + + +def test_contiguous_state_event_is_applied() -> None: + """A contiguous state event updates one object and the revision.""" + snapshot = BridgeSnapshot.from_dict(snapshot_payload()) + event = DomainEvent.from_dict( + { + "revision": 5, + "event_type": "state_changed", + "object_type": "area", + "object_id": "area-1", + "state": "armed_away", + } + ) + + updated = snapshot.apply_event(event) + + assert updated is not None + assert updated.revision == 5 + assert updated.areas["area-1"].state == "armed_away" + + +def test_event_gap_requires_snapshot_reconciliation() -> None: + """A missing event is not applied over an incomplete state.""" + snapshot = BridgeSnapshot.from_dict(snapshot_payload()) + event = DomainEvent.from_dict( + { + "revision": 6, + "event_type": "state_changed", + "object_type": "area", + "object_id": "area-1", + "state": "armed_away", + } + ) + + assert snapshot.apply_event(event) is None