"""Expose framework: ExposableEntity, provider protocol, and build_catalog. This module defines: - ``ExposableEntity`` — a dataclass describing one publishable MQTT/HA entity. - Provider protocol — a simple callable ``(session) -> list[ExposableEntity]``. - A provider registry and registration helper. - ``build_catalog(session)`` — merges all registered providers' entities with per-key toggle state from the ``exposed_entity_toggle`` table. Design notes ------------ - **No over-engineering**: providers are plain callables, not abstract base classes. This project is personal / single-user; keep it flat. - **Key stability**: entity keys MUST be derived from device uuid + metric key (e.g. ``"modbus..voltage"``), never from auto-increment IDs. Stable keys mean the toggle table survives device removal/re-addition without drift. - **Metadata from profile**: the modbus provider reads ``device_class``, ``unit``, and ``component`` directly from the YAML profile's ``metrics[]`` rather than maintaining a separate mapping. - **value_getter**: a callable ``() -> object | None`` or ``None`` if not yet implemented (T11 will wire real getters). Presence of the field lets T11 call it without changing the dataclass interface. """ from __future__ import annotations import logging from dataclasses import dataclass, field from typing import Any, Callable, Optional, Protocol from sqlalchemy.orm import Session logger = logging.getLogger(__name__) # --------------------------------------------------------------------------- # ExposableEntity # --------------------------------------------------------------------------- @dataclass class DeviceInfo: """Grouping metadata that maps an entity to a logical HA device. ``identifiers`` corresponds to ``device.identifiers`` in HA Discovery config — a stable tuple used to group entities under one device card. ``name`` is the human-readable device name (friendly_name). """ identifiers: tuple[str, ...] """Stable identifiers for HA device grouping (e.g. ``("modbus", "")``). These must not change over the device lifetime; they anchor the HA device card even when friendly_name changes. """ name: str """Human-readable device name (may change; triggers HA discovery re-publish).""" @dataclass class ExposableEntity: """One publishable entity in the MQTT / HA Discovery expose framework. Fields ------ key Stable unique identifier for this entity. Used as the toggle table key and as part of the HA Discovery ``unique_id`` derivation. Convention: ``".."``, e.g. ``"modbus.abc123.voltage"`` or ``"modbus.abc123.online"``. component HA MQTT component type: ``"sensor"``, ``"binary_sensor"``, ``"switch"``, etc. device Grouping info (DeviceInfo) that determines which HA device card this entity belongs to. device_class HA ``device_class`` string (e.g. ``"voltage"``, ``"power"``, ``"None"``). Empty string or ``None`` means no device_class (e.g. for the online sensor). unit Physical unit string (e.g. ``"V"``, ``"A"``, ``"kWh"``). Empty string for dimensionless. name Human-readable entity label (friendly display name, may include device context, e.g. ``"SDM120 Voltage"``). value_getter Optional callable ``() -> object | None`` that returns the current entity value. ``None`` means "not yet wired" (T11 will populate this). T11 calls this to obtain the current state before publishing. state_class HA ``state_class`` string (e.g. ``"measurement"``, ``"total_increasing"``). ``None`` means not set. """ key: str component: str device: DeviceInfo device_class: Optional[str] unit: str name: str value_getter: Optional[Callable[[], Any]] = field(default=None, repr=False) state_class: Optional[str] = None # --------------------------------------------------------------------------- # Provider protocol # --------------------------------------------------------------------------- class EntityProvider(Protocol): """Protocol for entity provider callables. A provider is any callable that accepts a ``Session`` and returns a list of ``ExposableEntity`` objects. Using a Protocol (not ABC) keeps the implementation lightweight — any matching callable qualifies. """ def __call__(self, session: Session) -> list[ExposableEntity]: ... # --------------------------------------------------------------------------- # Provider registry # --------------------------------------------------------------------------- _REGISTRY: list[EntityProvider] = [] def register_provider(provider: EntityProvider) -> EntityProvider: """Register an entity provider. Can be used as a decorator:: @register_provider def my_provider(session: Session) -> list[ExposableEntity]: ... Or called directly:: register_provider(my_provider) Returns the provider unchanged (for decorator compatibility). """ _REGISTRY.append(provider) return provider def get_providers() -> list[EntityProvider]: """Return a snapshot of the currently registered providers.""" return list(_REGISTRY) # --------------------------------------------------------------------------- # Catalog builder # --------------------------------------------------------------------------- @dataclass class CatalogEntry: """An entity from the catalog, enriched with its current toggle state.""" entity: ExposableEntity enabled: bool """True if this entity is currently enabled in the toggle table.""" def build_catalog(session: Session) -> list[CatalogEntry]: """Enumerate all entities from registered providers and attach toggle state. For each entity key, the toggle state is looked up in ``exposed_entity_toggle``. Entities with no toggle row default to ``enabled=False``. Parameters ---------- session: Active SQLAlchemy session for DB access. Returns ------- list[CatalogEntry] All entities from all registered providers, each paired with its current ``enabled`` state. """ from app.models.expose import ExposedEntityToggle # local import to avoid circular # Collect all entities from all providers. all_entities: list[ExposableEntity] = [] for provider in _REGISTRY: try: entities = provider(session) all_entities.extend(entities) except Exception: logger.exception("Provider %r raised an error; skipping its entities", provider) if not all_entities: return [] # Load all toggle rows in one query for efficiency. keys = [e.key for e in all_entities] toggle_rows = session.query(ExposedEntityToggle).filter( ExposedEntityToggle.key.in_(keys) ).all() toggle_map: dict[str, bool] = {row.key: row.enabled for row in toggle_rows} return [ CatalogEntry(entity=entity, enabled=toggle_map.get(entity.key, False)) for entity in all_entities ] # --------------------------------------------------------------------------- # Modbus provider # --------------------------------------------------------------------------- def _modbus_provider(session: Session) -> list[ExposableEntity]: """Enumerate ExposableEntity objects for all enabled Modbus devices. For each enabled ``ModbusDevice``: - Loads its YAML profile. - Produces one ``sensor`` entity per metric in ``profile.metrics`` (device_class / unit / component taken from the metric spec). - Produces one ``binary_sensor`` entity named "online" (derived from ``device.last_poll_ok``). Entity keys follow the pattern ``"modbus.."``, which is stable across device renames and DB rebuilds. """ from app.models.modbus import ModbusDevice # local import from app.integrations.modbus.profiles import ( load_profile, ProfileNotFoundError, ProfileValidationError, ) devices: list[ModbusDevice] = session.query(ModbusDevice).filter( ModbusDevice.enabled.is_(True) ).all() entities: list[ExposableEntity] = [] for device in devices: device_info = DeviceInfo( identifiers=("modbus", device.uuid), name=device.friendly_name, ) # Load the profile to get metric metadata. try: profile = load_profile(device.profile) except (ProfileNotFoundError, ProfileValidationError) as exc: logger.warning( "Skipping device %r (uuid=%s): cannot load profile %r: %s", device.friendly_name, device.uuid, device.profile, exc, ) continue # One sensor entity per profile metric. for metric in profile.metrics: entity_key = f"modbus.{device.uuid}.{metric.key}" # Capture metric and device for the value_getter closure. _device_uuid = device.uuid _metric_key = metric.key def _make_value_getter(dev_uuid: str, m_key: str) -> Callable[[], Any]: """Return a getter stub for this metric; T11 will wire real retrieval.""" def _getter() -> Any: # Stub — T11 will call build_catalog with a live session # and replace this with a real value-fetch callable. return None return _getter entities.append( ExposableEntity( key=entity_key, component=metric.ha_component, device=device_info, device_class=metric.device_class or None, unit=metric.unit, name=f"{device.friendly_name} {metric.key.replace('_', ' ').title()}", value_getter=_make_value_getter(_device_uuid, _metric_key), state_class=metric.state_class, ) ) # One binary_sensor entity for device "online" status. online_key = f"modbus.{device.uuid}.online" _dev_uuid_online = device.uuid def _make_online_getter(dev_uuid: str) -> Callable[[], Any]: def _getter() -> Any: # T11 will wire this to last_poll_ok. return None return _getter entities.append( ExposableEntity( key=online_key, component="binary_sensor", device=device_info, device_class="connectivity", unit="", name=f"{device.friendly_name} Online", value_getter=_make_online_getter(_dev_uuid_online), state_class=None, ) ) return entities # Register the modbus provider at module load time. register_provider(_modbus_provider)