M5-T09: add exposed_entities table and ExposableEntity provider framework

This commit is contained in:
2026-06-22 14:50:40 +02:00
parent 14b846549e
commit 360cddc05b
8 changed files with 1114 additions and 5 deletions
+325
View File
@@ -0,0 +1,325 @@
"""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.<uuid>.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", "<uuid>")``).
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: ``"<provider>.<device_uuid>.<metric_key>"``,
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.<uuid>.<metric_key>"``, 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)
+49
View File
@@ -0,0 +1,49 @@
"""SQLAlchemy model for the exposed-entity toggle table.
``ExposedEntityToggle`` stores the per-entity enable/disable state for the
MQTT / HA Discovery expose framework. The *catalog* of what *can* be
exposed is computed dynamically by provider functions (see
``app/integrations/expose.py``); this table only records which entries the
user has explicitly enabled.
Key design decisions
--------------------
- ``key`` is a stable string identifier derived from device uuid + metric key
(e.g. ``"modbus.<uuid>.voltage"``), so it does not drift if rows are
deleted and re-inserted.
- Default state for an entity not yet in this table is **disabled** (false).
``build_catalog`` in expose.py treats a missing row as ``enabled=False``.
- Only one row per entity key (``key`` is UNIQUE).
"""
from __future__ import annotations
from datetime import datetime
from sqlalchemy import Boolean, DateTime, Integer, String
from sqlalchemy.orm import Mapped, mapped_column
from app.db import Base
class ExposedEntityToggle(Base):
"""Per-entity on/off switch for MQTT / HA Discovery publishing.
Rows are created on demand (first toggle); entities with no row are
treated as disabled.
"""
__tablename__ = "exposed_entity_toggle"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
# Stable entity key, e.g. "modbus.<uuid>.voltage" or "modbus.<uuid>.online".
key: Mapped[str] = mapped_column(String(255), unique=True, nullable=False)
# Whether this entity should be published via MQTT / HA Discovery.
enabled: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
# Last time this row was created or modified.
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False
)