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.
|
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.
|
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.config_entries import ConfigEntry
|
||||||
from homeassistant.core import HomeAssistant
|
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."""
|
"""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
|
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."""
|
"""Unload an MB-Secure config entry."""
|
||||||
hass.data[DOMAIN].pop(entry.entry_id)
|
await entry.runtime_data.async_shutdown()
|
||||||
return True
|
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."""
|
"""Constants for the MB-Secure integration."""
|
||||||
|
|
||||||
|
from datetime import timedelta
|
||||||
|
|
||||||
|
from homeassistant.const import Platform
|
||||||
|
|
||||||
DOMAIN = "mb_secure"
|
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",
|
"domain": "mb_secure",
|
||||||
"name": "MB-Secure",
|
"name": "MB-Secure",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"config_flow": false,
|
"config_flow": true,
|
||||||
"documentation": "https://git.bahmcloud.de/bahmcloud/home-assistant-mb-secure",
|
"documentation": "https://git.bahmcloud.de/bahmcloud/home-assistant-mb-secure",
|
||||||
"integration_type": "hub",
|
"integration_type": "hub",
|
||||||
"iot_class": "local_push",
|
"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