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
+23 -6
View File
@@ -4,17 +4,34 @@ from __future__ import annotations
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from .const import DOMAIN
from .api import BridgeClient
from .const import CONF_TOKEN, PLATFORMS
from .coordinator import MBSecureCoordinator
from .devices import async_setup_devices
type MBSecureConfigEntry = ConfigEntry[MBSecureCoordinator]
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
async def async_setup_entry(hass: HomeAssistant, entry: MBSecureConfigEntry) -> bool:
"""Set up MB-Secure from a config entry."""
hass.data.setdefault(DOMAIN, {})[entry.entry_id] = None
client = BridgeClient(
async_get_clientsession(hass),
host=entry.data["host"],
port=entry.data["port"],
token=entry.data[CONF_TOKEN],
)
coordinator = MBSecureCoordinator(hass, entry, client)
await coordinator.async_config_entry_first_refresh()
entry.runtime_data = coordinator
async_setup_devices(hass, entry)
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
coordinator.async_start_event_listener()
return True
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
async def async_unload_entry(hass: HomeAssistant, entry: MBSecureConfigEntry) -> bool:
"""Unload an MB-Secure config entry."""
hass.data[DOMAIN].pop(entry.entry_id)
return True
await entry.runtime_data.async_shutdown()
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
@@ -0,0 +1,83 @@
"""Alarm control panel entities for MB-Secure."""
from __future__ import annotations
from homeassistant.components.alarm_control_panel import (
AlarmControlPanelEntity,
AlarmControlPanelEntityFeature,
AlarmControlPanelState,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from .coordinator import MBSecureCoordinator
from .entity import MBSecureEntity, async_setup_dynamic_entities
async def async_setup_entry(
hass: HomeAssistant,
entry: ConfigEntry[MBSecureCoordinator],
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up capability-reported area alarm entities."""
coordinator = entry.runtime_data
assert coordinator.info is not None and coordinator.info.bridge_id is not None
bridge_id = coordinator.info.bridge_id
async_setup_dynamic_entities(
entry,
async_add_entities,
collection_name="areas",
capability=frozenset({"arm_away", "disarm"}),
factory=lambda object_id: MBSecureAlarmEntity(
coordinator, bridge_id, object_id
),
)
class MBSecureAlarmEntity(MBSecureEntity, AlarmControlPanelEntity):
"""Represent one normalized security area."""
def __init__(
self, coordinator: MBSecureCoordinator, bridge_id: str, object_id: str
) -> None:
"""Initialize an area alarm entity."""
super().__init__(coordinator, bridge_id, "areas", object_id)
@property
def alarm_state(self) -> AlarmControlPanelState | None:
"""Return the normalized area state."""
item = self.bridge_object
if item is None:
return None
try:
return AlarmControlPanelState(item.state)
except ValueError:
return None
@property
def supported_features(self) -> AlarmControlPanelEntityFeature:
"""Expose only explicitly reported arm capabilities."""
item = self.bridge_object
if item is None:
return AlarmControlPanelEntityFeature(0)
features = AlarmControlPanelEntityFeature(0)
if "arm_away" in item.capabilities:
features |= AlarmControlPanelEntityFeature.ARM_AWAY
return features
async def async_alarm_arm_away(self, code: str | None = None) -> None:
"""Arm this area in away mode."""
item = self.bridge_object
if item is None or "arm_away" not in item.capabilities:
raise NotImplementedError("arm away capability is not available")
await self.coordinator.client.async_arm_area(self._object_id, "away")
await self.coordinator.async_request_refresh()
async def async_alarm_disarm(self, code: str | None = None) -> None:
"""Disarm this area when the capability is available."""
item = self.bridge_object
if item is None or "disarm" not in item.capabilities:
raise NotImplementedError("disarm capability is not available")
await self.coordinator.client.async_disarm_area(self._object_id)
await self.coordinator.async_request_refresh()
+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."""
from datetime import timedelta
from homeassistant.const import Platform
DOMAIN = "mb_secure"
CONF_TOKEN = "token"
DEFAULT_HOST = "localhost"
DEFAULT_PORT = 8099
DEFAULT_SCAN_INTERVAL = timedelta(minutes=1)
API_VERSION = 1
MIN_TOKEN_LENGTH = 32
PLATFORMS = (
Platform.ALARM_CONTROL_PANEL,
Platform.BINARY_SENSOR,
Platform.SENSOR,
Platform.SWITCH,
)
+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",
"name": "MB-Secure",
"version": "0.1.0",
"config_flow": false,
"config_flow": true,
"documentation": "https://git.bahmcloud.de/bahmcloud/home-assistant-mb-secure",
"integration_type": "hub",
"iot_class": "local_push",
+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"
}
}
}