Implement bridge-backed Home Assistant integration

This commit is contained in:
2026-09-04 12:57:32 +02:00
parent 42050010b2
commit 9766f7f9b2
25 changed files with 2154 additions and 7 deletions
+22
View File
@@ -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
```
+23 -6
View File
@@ -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()
+200
View File
@@ -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"}
+199
View File
@@ -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
+20
View File
@@ -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,
)
+110
View File
@@ -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)
+77
View File
@@ -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,
}
+151
View File
@@ -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))
+1 -1
View File
@@ -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",
+227
View File
@@ -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},
)
+47
View File
@@ -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
+61
View File
@@ -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"
}
}
}
+14
View File
@@ -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
+3
View File
@@ -0,0 +1,3 @@
pytest-homeassistant-custom-component==0.13.363
ruff==0.16.6
mypy==2.3.1
+10
View File
@@ -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."""
+115
View File
@@ -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
+148
View File
@@ -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"
+147
View File
@@ -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
+42
View File
@@ -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"]
+192
View File
@@ -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
+143
View File
@@ -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