Implement bridge-backed Home Assistant integration
This commit is contained in:
@@ -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
|
||||
```
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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"}
|
||||
@@ -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
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
@@ -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))
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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))
|
||||
@@ -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",
|
||||
|
||||
@@ -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},
|
||||
)
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -0,0 +1,3 @@
|
||||
pytest-homeassistant-custom-component==0.13.363
|
||||
ruff==0.16.6
|
||||
mypy==2.3.1
|
||||
@@ -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."""
|
||||
@@ -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
|
||||
@@ -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"
|
||||
@@ -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
|
||||
@@ -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"]
|
||||
@@ -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
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user