diff --git a/alembic_app/env.py b/alembic_app/env.py index 56c4383..efa98a9 100644 --- a/alembic_app/env.py +++ b/alembic_app/env.py @@ -12,6 +12,7 @@ from app.models.public_ip import PublicIPHistory, PublicIPState # noqa: F401 from app.models.location import Location # noqa: F401 from app.models.poo import PooRecord # noqa: F401 from app.models.modbus import ModbusDevice, ModbusReading # noqa: F401 +from app.models.expose import ExposedEntityToggle # noqa: F401 config = context.config diff --git a/alembic_app/versions/20260622_10_exposed_entities.py b/alembic_app/versions/20260622_10_exposed_entities.py new file mode 100644 index 0000000..83148ea --- /dev/null +++ b/alembic_app/versions/20260622_10_exposed_entities.py @@ -0,0 +1,44 @@ +"""add exposed_entity_toggle table + +Revision ID: 20260622_10_exposed_entities +Revises: 20260622_09_modbus_tables +Create Date: 2026-06-22 00:00:00.000000 +""" + +from typing import Sequence, Union + +import sqlalchemy as sa +from alembic import op + + +revision: str = "20260622_10_exposed_entities" +down_revision: Union[str, None] = "20260622_09_modbus_tables" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # exposed_entity_toggle — per-entity on/off switch for MQTT / HA Discovery. + op.create_table( + "exposed_entity_toggle", + sa.Column("id", sa.Integer(), autoincrement=True, nullable=False), + sa.Column("key", sa.String(length=255), nullable=False), + sa.Column("enabled", sa.Boolean(), nullable=False), + sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint("key", name="uq_exposed_entity_toggle_key"), + ) + + # Index on key for fast single-key lookups (toggle by key). + op.create_index( + "ix_exposed_entity_toggle_key", + "exposed_entity_toggle", + ["key"], + unique=True, + ) + + +def downgrade() -> None: + # Drop index then table — only removes what this revision created. + op.drop_index("ix_exposed_entity_toggle_key", table_name="exposed_entity_toggle") + op.drop_table("exposed_entity_toggle") diff --git a/app/integrations/expose.py b/app/integrations/expose.py new file mode 100644 index 0000000..3eef5fc --- /dev/null +++ b/app/integrations/expose.py @@ -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..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) diff --git a/app/models/expose.py b/app/models/expose.py new file mode 100644 index 0000000..f907a17 --- /dev/null +++ b/app/models/expose.py @@ -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..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..voltage" or "modbus..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 + ) diff --git a/docs/design/m5-iot-energy.md b/docs/design/m5-iot-energy.md index 547830c..90f1443 100644 --- a/docs/design/m5-iot-energy.md +++ b/docs/design/m5-iot-energy.md @@ -399,7 +399,7 @@ Phase C(MQTT / Discovery,依赖 B 的设备数据与 provider 接口) - **Reviewer checklist**: secret 不回显/不入 OpenAPI 示例;`_settings_payload` 未漏字段(否则运行期 override 丢失)。 ### M5-T09 — `exposed_entities` 表 + ExposableEntity/provider 框架 `[schema]` -- **Status**: `todo` · **Depends**: M5-T02 +- **Status**: `done` · **Depends**: M5-T02 - **Context**: 建"可暴露实体目录"的抽象与开关存储;modbus provider 把设备映射成 device/entities,**实体元数据从 profile 派生**。 - **Files**: `create app/integrations/expose.py`(`ExposableEntity`、provider 协议、注册表、modbus provider);`create app/models/expose.py`(`ExposedEntityToggle`:`key` unique + `enabled` + `updated_at`);`create alembic_app/versions/_08_exposed_entities.py`;`modify alembic_app/env.py`、`scripts/app_db_adopt.py`;`create tests/test_expose_catalog.py` - **Steps**: diff --git a/scripts/app_db_adopt.py b/scripts/app_db_adopt.py index 1ca952f..1155e29 100644 --- a/scripts/app_db_adopt.py +++ b/scripts/app_db_adopt.py @@ -15,7 +15,7 @@ if str(PROJECT_ROOT) not in sys.path: from app.config import get_settings -APP_BASELINE_REVISION = "20260622_09_modbus_tables" +APP_BASELINE_REVISION = "20260622_10_exposed_entities" class AppDatabaseAdoptionError(RuntimeError): diff --git a/tests/test_expose_catalog.py b/tests/test_expose_catalog.py new file mode 100644 index 0000000..c257713 --- /dev/null +++ b/tests/test_expose_catalog.py @@ -0,0 +1,681 @@ +"""Tests for M5-T09: exposed_entities table + ExposableEntity/provider framework. + +Covers: + 1. ExposableEntity dataclass: fields, key stability. + 2. Provider registry: register_provider, get_providers. + 3. build_catalog: entity enumeration, device grouping, metadata from profile, + toggle defaults (enabled=False), binary_sensor presence. + 4. Migration shape: upgrade creates the table + unique index; downgrade drops it. + 5. ORM round-trip: toggle insert/read/update; default enabled=False. + 6. APP_BASELINE_REVISION matches the new Alembic head. +""" + +from __future__ import annotations + +from datetime import datetime, timezone +from pathlib import Path +from typing import TYPE_CHECKING +import pytest +from alembic import command +from alembic.config import Config +from sqlalchemy import create_engine, inspect +from sqlalchemy.orm import Session + +if TYPE_CHECKING: + pass + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _make_app_alembic_config(database_url: str) -> Config: + cfg = Config("alembic_app.ini") + cfg.set_main_option("sqlalchemy.url", database_url) + return cfg + + +def _make_modbus_device(session: Session, *, friendly_name: str, uuid: str) -> object: + """Insert a ModbusDevice row into a test DB session and return it.""" + from datetime import datetime, timezone + + from app.models.modbus import ModbusDevice + + now = datetime.now(tz=timezone.utc) + device = ModbusDevice( + uuid=uuid, + friendly_name=friendly_name, + host="192.168.1.1", + port=502, + unit_id=1, + profile="sdm120", + poll_interval_s=5, + enabled=True, + created_at=now, + updated_at=now, + ) + session.add(device) + session.flush() + return device + + +# --------------------------------------------------------------------------- +# Fixtures +# --------------------------------------------------------------------------- + + +@pytest.fixture() +def expose_db(tmp_path: Path): + """Temporary SQLite DB upgraded to the current Alembic head (includes new toggle table).""" + db_path = tmp_path / "expose_test.db" + db_url = f"sqlite:///{db_path}" + alembic_cfg = _make_app_alembic_config(db_url) + command.upgrade(alembic_cfg, "head") + engine = create_engine(db_url, connect_args={"check_same_thread": False}) + yield engine + engine.dispose() + + +# --------------------------------------------------------------------------- +# 1. ExposableEntity / DeviceInfo dataclass tests +# --------------------------------------------------------------------------- + + +def test_exposable_entity_fields(): + """ExposableEntity must have all required fields with correct defaults.""" + from app.integrations.expose import DeviceInfo, ExposableEntity + + device = DeviceInfo(identifiers=("modbus", "abc-uuid"), name="Test Device") + entity = ExposableEntity( + key="modbus.abc-uuid.voltage", + component="sensor", + device=device, + device_class="voltage", + unit="V", + name="Test Device Voltage", + ) + + assert entity.key == "modbus.abc-uuid.voltage" + assert entity.component == "sensor" + assert entity.device.identifiers == ("modbus", "abc-uuid") + assert entity.device.name == "Test Device" + assert entity.device_class == "voltage" + assert entity.unit == "V" + assert entity.name == "Test Device Voltage" + assert entity.value_getter is None # default + assert entity.state_class is None # default + + +def test_exposable_entity_with_value_getter(): + """ExposableEntity.value_getter stores and calls the provided callable.""" + from app.integrations.expose import DeviceInfo, ExposableEntity + + device = DeviceInfo(identifiers=("modbus", "dev1"), name="D1") + getter = lambda: 230.5 # noqa: E731 + entity = ExposableEntity( + key="modbus.dev1.voltage", + component="sensor", + device=device, + device_class="voltage", + unit="V", + name="D1 Voltage", + value_getter=getter, + ) + assert entity.value_getter is not None + assert entity.value_getter() == 230.5 + + +def test_entity_key_stability_uses_uuid_not_id(): + """Keys must be derived from uuid (stable), never from auto-increment DB ids.""" + from app.integrations.expose import DeviceInfo, ExposableEntity + + uuid_val = "550e8400-e29b-41d4-a716-446655440000" + key = f"modbus.{uuid_val}.voltage" + device = DeviceInfo(identifiers=("modbus", uuid_val), name="Stable Device") + entity = ExposableEntity( + key=key, + component="sensor", + device=device, + device_class="voltage", + unit="V", + name="Stable Device Voltage", + ) + # Key is uuid-based: it must not contain any integer-only segment that + # looks like an auto-increment id (i.e., it contains the full uuid string). + assert uuid_val in entity.key + assert "modbus" in entity.key + assert "voltage" in entity.key + + +# --------------------------------------------------------------------------- +# 2. Provider registry tests +# --------------------------------------------------------------------------- + + +def test_register_provider_as_decorator(): + """register_provider used as a decorator must add the callable to the registry.""" + from app.integrations.expose import get_providers, register_provider + + before = len(get_providers()) + + @register_provider + def _dummy_provider(session): + return [] + + after_providers = get_providers() + assert len(after_providers) == before + 1 + assert _dummy_provider in after_providers + + # Clean up: remove the dummy from the module-level registry. + from app.integrations import expose as _expose_mod + _expose_mod._REGISTRY.remove(_dummy_provider) + + +def test_register_provider_direct_call(): + """register_provider called directly must also register the callable.""" + from app.integrations.expose import get_providers, register_provider + from app.integrations import expose as _expose_mod + + def _another_dummy(session): + return [] + + before = len(get_providers()) + returned = register_provider(_another_dummy) + assert returned is _another_dummy # returns the provider unchanged + assert len(get_providers()) == before + 1 + + # Clean up. + _expose_mod._REGISTRY.remove(_another_dummy) + + +# --------------------------------------------------------------------------- +# 3. build_catalog tests +# --------------------------------------------------------------------------- + + +def test_build_catalog_empty_with_no_devices(expose_db): + """build_catalog with no enabled devices must return an empty list.""" + from app.integrations.expose import build_catalog + + with Session(expose_db) as session: + catalog = build_catalog(session) + + # The modbus provider will find no enabled devices; other providers may + # add entries only if registered. Since we only register the modbus + # provider at module load, the result should be empty. + assert catalog == [] + + +def test_build_catalog_produces_entities_for_enabled_device(expose_db): + """build_catalog must yield entities (including binary_sensor) for each enabled device.""" + from app.integrations.expose import build_catalog, CatalogEntry + + # Insert a test device. + test_uuid = "aaaaaaaa-0000-0000-0000-000000000001" + with Session(expose_db) as session: + _make_modbus_device(session, friendly_name="SDM120 Test", uuid=test_uuid) + session.commit() + + with Session(expose_db) as session: + catalog = build_catalog(session) + + assert len(catalog) > 0, "Expected at least one entity for the enabled device" + + keys = [entry.entity.key for entry in catalog] + + # Must contain a 'modbus..online' binary_sensor. + online_key = f"modbus.{test_uuid}.online" + assert online_key in keys, f"Expected online entity key {online_key!r} in catalog" + + # Must contain at least one sensor entity (voltage is always in sdm120 profile). + voltage_key = f"modbus.{test_uuid}.voltage" + assert voltage_key in keys, f"Expected voltage entity key {voltage_key!r} in catalog" + + # Verify CatalogEntry type. + assert all(isinstance(e, CatalogEntry) for e in catalog) + + +def test_build_catalog_default_enabled_false(expose_db): + """Entities with no toggle row must default to enabled=False.""" + from app.integrations.expose import build_catalog + + test_uuid = "aaaaaaaa-0000-0000-0000-000000000002" + with Session(expose_db) as session: + _make_modbus_device(session, friendly_name="SDM120 B", uuid=test_uuid) + session.commit() + + with Session(expose_db) as session: + catalog = build_catalog(session) + + # No toggle rows inserted → all must be disabled. + assert all(not entry.enabled for entry in catalog), ( + "All catalog entries must default to enabled=False when no toggle row exists" + ) + + +def test_build_catalog_respects_toggle_enabled(expose_db): + """An entity with a toggle row enabled=True must appear as enabled in the catalog.""" + from datetime import datetime, timezone + + from app.integrations.expose import build_catalog + from app.models.expose import ExposedEntityToggle + + test_uuid = "aaaaaaaa-0000-0000-0000-000000000003" + voltage_key = f"modbus.{test_uuid}.voltage" + + with Session(expose_db) as session: + _make_modbus_device(session, friendly_name="SDM120 C", uuid=test_uuid) + # Enable the voltage entity explicitly. + toggle = ExposedEntityToggle( + key=voltage_key, + enabled=True, + updated_at=datetime.now(tz=timezone.utc), + ) + session.add(toggle) + session.commit() + + with Session(expose_db) as session: + catalog = build_catalog(session) + + enabled_keys = {entry.entity.key for entry in catalog if entry.enabled} + assert voltage_key in enabled_keys, ( + f"Expected {voltage_key!r} to be enabled after toggling" + ) + + # Other entities (no toggle row) must still default to disabled. + online_key = f"modbus.{test_uuid}.online" + disabled_keys = {entry.entity.key for entry in catalog if not entry.enabled} + assert online_key in disabled_keys, f"Expected {online_key!r} to remain disabled" + + +def test_build_catalog_device_grouping(expose_db): + """All entities for a device must share the same DeviceInfo (device grouping).""" + from app.integrations.expose import build_catalog + + test_uuid = "aaaaaaaa-0000-0000-0000-000000000004" + with Session(expose_db) as session: + _make_modbus_device(session, friendly_name="SDM120 D", uuid=test_uuid) + session.commit() + + with Session(expose_db) as session: + catalog = build_catalog(session) + + device_entities = [e for e in catalog if test_uuid in e.entity.key] + assert len(device_entities) > 0 + + # All entities for this device must use the same device identifiers tuple. + identifiers_set = {e.entity.device.identifiers for e in device_entities} + assert len(identifiers_set) == 1, ( + "All entities for one device must share the same DeviceInfo identifiers" + ) + assert identifiers_set.pop() == ("modbus", test_uuid) + + +def test_build_catalog_metric_metadata_from_profile(expose_db): + """Entity device_class and unit must match the sdm120 profile's metrics.""" + from app.integrations.expose import build_catalog + from app.integrations.modbus.profiles import load_profile + + test_uuid = "aaaaaaaa-0000-0000-0000-000000000005" + with Session(expose_db) as session: + _make_modbus_device(session, friendly_name="SDM120 E", uuid=test_uuid) + session.commit() + + profile = load_profile("sdm120") + metric_map = {m.key: m for m in profile.metrics} + + with Session(expose_db) as session: + catalog = build_catalog(session) + + sensor_entities = [ + e for e in catalog + if e.entity.component == "sensor" and test_uuid in e.entity.key + ] + + for entry in sensor_entities: + # Extract metric key from entity key: "modbus.." + _, _, metric_key = entry.entity.key.split(".", 2) + if metric_key in metric_map: + metric_spec = metric_map[metric_key] + assert entry.entity.device_class == (metric_spec.device_class or None), ( + f"device_class mismatch for {metric_key!r}: " + f"expected {metric_spec.device_class!r}, " + f"got {entry.entity.device_class!r}" + ) + assert entry.entity.unit == metric_spec.unit, ( + f"unit mismatch for {metric_key!r}: " + f"expected {metric_spec.unit!r}, got {entry.entity.unit!r}" + ) + assert entry.entity.component == metric_spec.ha_component, ( + f"component mismatch for {metric_key!r}: " + f"expected {metric_spec.ha_component!r}, " + f"got {entry.entity.component!r}" + ) + + +def test_build_catalog_online_binary_sensor_present(expose_db): + """Each enabled device must produce exactly one binary_sensor 'online' entity.""" + from app.integrations.expose import build_catalog + + uuid1 = "aaaaaaaa-0000-0000-0000-000000000006" + uuid2 = "aaaaaaaa-0000-0000-0000-000000000007" + + with Session(expose_db) as session: + _make_modbus_device(session, friendly_name="Device F1", uuid=uuid1) + _make_modbus_device(session, friendly_name="Device F2", uuid=uuid2) + session.commit() + + with Session(expose_db) as session: + catalog = build_catalog(session) + + binary_sensors = [e for e in catalog if e.entity.component == "binary_sensor"] + binary_sensor_keys = {e.entity.key for e in binary_sensors} + + assert f"modbus.{uuid1}.online" in binary_sensor_keys + assert f"modbus.{uuid2}.online" in binary_sensor_keys + # No sensor must masquerade as binary_sensor and vice versa. + assert all("online" in e.entity.key for e in binary_sensors), ( + "All binary_sensor entities should be 'online' entities" + ) + + +def test_build_catalog_has_non_sensor_component(expose_db): + """The catalog must contain at least one entity with component != 'sensor'.""" + from app.integrations.expose import build_catalog + + test_uuid = "aaaaaaaa-0000-0000-0000-000000000008" + with Session(expose_db) as session: + _make_modbus_device(session, friendly_name="SDM120 G", uuid=test_uuid) + session.commit() + + with Session(expose_db) as session: + catalog = build_catalog(session) + + non_sensor = [e for e in catalog if e.entity.component != "sensor"] + assert len(non_sensor) >= 1, ( + "Expected at least one non-sensor component (binary_sensor 'online')" + ) + + +def test_build_catalog_only_includes_enabled_devices(expose_db): + """Disabled devices must not contribute entities to the catalog.""" + from app.models.modbus import ModbusDevice + + now = datetime.now(tz=timezone.utc) + enabled_uuid = "aaaaaaaa-0000-0000-0000-000000000009" + disabled_uuid = "aaaaaaaa-0000-0000-0000-00000000000a" + + with Session(expose_db) as session: + enabled_device = ModbusDevice( + uuid=enabled_uuid, + friendly_name="Enabled Device", + host="10.0.0.1", + port=502, + unit_id=1, + profile="sdm120", + poll_interval_s=5, + enabled=True, + created_at=now, + updated_at=now, + ) + disabled_device = ModbusDevice( + uuid=disabled_uuid, + friendly_name="Disabled Device", + host="10.0.0.2", + port=502, + unit_id=2, + profile="sdm120", + poll_interval_s=5, + enabled=False, + created_at=now, + updated_at=now, + ) + session.add_all([enabled_device, disabled_device]) + session.commit() + + from app.integrations.expose import build_catalog + + with Session(expose_db) as session: + catalog = build_catalog(session) + + all_keys = {e.entity.key for e in catalog} + assert any(enabled_uuid in k for k in all_keys), "Enabled device must be in catalog" + assert not any(disabled_uuid in k for k in all_keys), ( + "Disabled device must NOT be in catalog" + ) + + +def test_build_catalog_key_uses_uuid_not_db_id(expose_db): + """Entity keys must contain the device uuid, not the integer DB primary key.""" + from app.integrations.expose import build_catalog + + test_uuid = "bbbbbbbb-1111-0000-0000-000000000001" + with Session(expose_db) as session: + device = _make_modbus_device(session, friendly_name="UUID Key Test", uuid=test_uuid) + session.commit() + db_id = device.id # type: ignore[union-attr] + + with Session(expose_db) as session: + catalog = build_catalog(session) + + entity_keys = [e.entity.key for e in catalog if test_uuid in e.entity.key] + assert len(entity_keys) > 0, "Expected entities for the test device" + + # Keys must contain the uuid string and not be keyed by integer id only. + for key in entity_keys: + assert test_uuid in key, f"Key {key!r} must contain the uuid {test_uuid!r}" + # Ensure the integer DB id does not appear where the uuid should be. + parts = key.split(".") + assert str(db_id) not in parts[1:2], ( + f"Key {key!r} must not use the integer DB id {db_id} in the uuid slot" + ) + + +# --------------------------------------------------------------------------- +# 4. Migration shape tests +# --------------------------------------------------------------------------- + + +def test_exposed_entity_toggle_table_exists_after_upgrade(expose_db): + """exposed_entity_toggle table must exist after upgrade to head.""" + inspector = inspect(expose_db) + assert "exposed_entity_toggle" in inspector.get_table_names(), ( + "exposed_entity_toggle table missing after upgrade to head" + ) + + +def test_exposed_entity_toggle_columns(expose_db): + """exposed_entity_toggle must have id, key, enabled, updated_at columns.""" + inspector = inspect(expose_db) + columns = {col["name"]: col for col in inspector.get_columns("exposed_entity_toggle")} + + assert "id" in columns + assert "key" in columns + assert "enabled" in columns + assert "updated_at" in columns + + # id must be non-nullable. + assert not columns["id"]["nullable"] + # key must be non-nullable. + assert not columns["key"]["nullable"] + # enabled must be non-nullable. + assert not columns["enabled"]["nullable"] + + +def test_exposed_entity_toggle_key_unique_constraint(expose_db): + """exposed_entity_toggle.key must have a unique constraint.""" + inspector = inspect(expose_db) + unique_constraints = inspector.get_unique_constraints("exposed_entity_toggle") + unique_cols = [col for uc in unique_constraints for col in uc["column_names"]] + assert "key" in unique_cols, "key column must have a unique constraint" + + +def test_exposed_entity_toggle_key_unique_index(expose_db): + """exposed_entity_toggle must have a unique index on key.""" + inspector = inspect(expose_db) + indexes = {idx["name"]: idx for idx in inspector.get_indexes("exposed_entity_toggle")} + assert "ix_exposed_entity_toggle_key" in indexes, ( + "Index ix_exposed_entity_toggle_key missing" + ) + assert indexes["ix_exposed_entity_toggle_key"]["unique"] + + +def test_downgrade_removes_toggle_table(tmp_path: Path): + """downgrade -1 from head must drop the exposed_entity_toggle table cleanly.""" + db_path = tmp_path / "downgrade_expose_test.db" + db_url = f"sqlite:///{db_path}" + alembic_cfg = _make_app_alembic_config(db_url) + + command.upgrade(alembic_cfg, "head") + + engine = create_engine(db_url, connect_args={"check_same_thread": False}) + inspector = inspect(engine) + assert "exposed_entity_toggle" in inspector.get_table_names() + engine.dispose() + + # Downgrade one step removes the exposed_entity_toggle revision. + command.downgrade(alembic_cfg, "-1") + + engine = create_engine(db_url, connect_args={"check_same_thread": False}) + inspector = inspect(engine) + table_names = inspector.get_table_names() + assert "exposed_entity_toggle" not in table_names, ( + "exposed_entity_toggle should be gone after downgrade -1" + ) + # Previous tables must still exist. + assert "modbus_device" in table_names + assert "modbus_reading" in table_names + engine.dispose() + + +# --------------------------------------------------------------------------- +# 5. ORM round-trip tests +# --------------------------------------------------------------------------- + + +def test_expose_toggle_in_base_metadata(): + """Base.metadata.tables must include exposed_entity_toggle.""" + from app.db import Base + + # Importing the model registers it with Base.metadata. + from app.models.expose import ExposedEntityToggle # noqa: F401 + + assert "exposed_entity_toggle" in Base.metadata.tables, ( + "exposed_entity_toggle not in Base.metadata after model import" + ) + + +def test_expose_toggle_insert_and_read(expose_db): + """ExposedEntityToggle can be inserted and retrieved correctly.""" + from app.models.expose import ExposedEntityToggle + + now = datetime.now(tz=timezone.utc) + key = "modbus.test-uuid.voltage" + + with Session(expose_db) as session: + toggle = ExposedEntityToggle(key=key, enabled=True, updated_at=now) + session.add(toggle) + session.commit() + toggle_id = toggle.id + + with Session(expose_db) as session: + fetched = session.get(ExposedEntityToggle, toggle_id) + assert fetched is not None + assert fetched.key == key + assert fetched.enabled is True + + +def test_expose_toggle_default_enabled_is_false(expose_db): + """ExposedEntityToggle.enabled must default to False after INSERT (column default). + + SQLAlchemy applies ``default=False`` at INSERT time, not at Python object + construction time. We verify the column default by inserting a row without + providing ``enabled`` and reading it back. + """ + from app.models.expose import ExposedEntityToggle + + now = datetime.now(tz=timezone.utc) + + with Session(expose_db) as session: + toggle = ExposedEntityToggle(key="default.enabled.test", updated_at=now) + session.add(toggle) + session.commit() + toggle_id = toggle.id + + with Session(expose_db) as session: + fetched = session.get(ExposedEntityToggle, toggle_id) + assert fetched is not None + assert fetched.enabled is False, ( + "ExposedEntityToggle.enabled must default to False (not True, not None)" + ) + + +def test_expose_toggle_key_uniqueness_enforced(expose_db): + """Inserting two rows with the same key must raise an IntegrityError.""" + import sqlalchemy.exc + + from app.models.expose import ExposedEntityToggle + + now = datetime.now(tz=timezone.utc) + key = "modbus.dup-uuid.power" + + with Session(expose_db) as session: + session.add(ExposedEntityToggle(key=key, enabled=False, updated_at=now)) + session.commit() + + with pytest.raises(sqlalchemy.exc.IntegrityError): + with Session(expose_db) as session: + session.add(ExposedEntityToggle(key=key, enabled=True, updated_at=now)) + session.commit() + + +def test_expose_toggle_update(expose_db): + """Updating an existing toggle row must persist the new enabled value.""" + from app.models.expose import ExposedEntityToggle + + now = datetime.now(tz=timezone.utc) + key = "modbus.update-uuid.current" + + with Session(expose_db) as session: + toggle = ExposedEntityToggle(key=key, enabled=False, updated_at=now) + session.add(toggle) + session.commit() + toggle_id = toggle.id + + with Session(expose_db) as session: + toggle = session.get(ExposedEntityToggle, toggle_id) + toggle.enabled = True + toggle.updated_at = datetime.now(tz=timezone.utc) + session.commit() + + with Session(expose_db) as session: + fetched = session.get(ExposedEntityToggle, toggle_id) + assert fetched.enabled is True + + +# --------------------------------------------------------------------------- +# 6. APP_BASELINE_REVISION +# --------------------------------------------------------------------------- + + +def test_app_baseline_revision_matches_new_head(tmp_path: Path): + """APP_BASELINE_REVISION must equal the Alembic head after the new migration.""" + from alembic.script import ScriptDirectory + + from scripts.app_db_adopt import APP_BASELINE_REVISION + + db_url = f"sqlite:///{tmp_path / 'rev_check.db'}" + alembic_cfg = _make_app_alembic_config(db_url) + script = ScriptDirectory.from_config(alembic_cfg) + heads = script.get_heads() + assert len(heads) == 1, f"Expected exactly 1 Alembic head, got {heads}" + head = heads[0] + assert APP_BASELINE_REVISION == head, ( + f"APP_BASELINE_REVISION={APP_BASELINE_REVISION!r} does not match " + f"Alembic head={head!r}" + ) + # Confirm the new revision is the head. + assert head == "20260622_10_exposed_entities", ( + f"Expected new head to be '20260622_10_exposed_entities', got {head!r}" + ) diff --git a/tests/test_modbus_models.py b/tests/test_modbus_models.py index 0231df9..9674dfd 100644 --- a/tests/test_modbus_models.py +++ b/tests/test_modbus_models.py @@ -144,7 +144,13 @@ def test_modbus_reading_recorded_at_index_exists(modbus_db): def test_downgrade_removes_modbus_tables(tmp_path: Path): - """downgrade -1 from head must drop both modbus tables cleanly.""" + """Downgrading past the modbus migration must drop both modbus tables cleanly. + + We downgrade to the explicit revision ``20260621_08_totp`` (the down_revision + of the modbus tables migration), so the test stays correct as new revisions + are added on top. Using an explicit target rather than ``-2`` avoids drift + with chain growth (same fix pattern as T02 applied to the TOTP downgrade test). + """ db_path = tmp_path / "downgrade_modbus_test.db" db_url = f"sqlite:///{db_path}" alembic_cfg = _make_app_alembic_config(db_url) @@ -157,8 +163,11 @@ def test_downgrade_removes_modbus_tables(tmp_path: Path): assert "modbus_reading" in inspector.get_table_names() engine.dispose() - # Downgrade one step removes the modbus migration. - command.downgrade(alembic_cfg, "-1") + # Downgrade to the explicit down_revision of the modbus tables migration + # ("20260621_08_totp"), consistent with how T02 fixed the TOTP downgrade test. + # Using an explicit target instead of "-2" ensures the test stays correct even + # as new revisions are added on top of this one (no drift with chain growth). + command.downgrade(alembic_cfg, "20260621_08_totp") engine = create_engine(db_url, connect_args={"check_same_thread": False}) inspector = inspect(engine)