"""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 datetime import UTC, datetime, timedelta 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. HA merges device cards if *any* identifier overlaps, so every logical device deliberately has exactly one, globally namespaced value. ``identity`` is an independent internal seed for MQTT topics and unique ids; it must never be inferred from the HA identifier list. ``name`` is the human-readable device name (friendly_name). """ identifiers: tuple[str, ...] """Stable HA identifiers, normally a one-item tuple. 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).""" identity: Optional[str] = None """Stable internal topic/unique-id identity, separate from HA identifiers. The fallback preserves compatibility for third-party providers still using the historic ``("kind", "uuid")`` construction while first-party providers use this field explicitly. """ provides_availability: bool = True """Whether this device publishes an availability ("online"/"offline") heartbeat. When True (the default — e.g. Modbus devices, which have an ``online`` binary_sensor driving availability), the HA Discovery config for each entity declares an ``availability`` topic and HA marks the entity unavailable until "online" is published. When False (e.g. the energy-cost device, which has only sensors and no heartbeat source), the config omits ``availability`` so HA treats the entity as *always available* and shows its state as soon as one arrives. Without this, such entities would stay perpetually ``unavailable`` in HA even though their state is being published. """ availability_id: Optional[str] = None """Stable id used for the shared availability topic, when different from this HA device's identity. A Meter is identified by its own UUID, while its liveness comes from the source/channel feeding it. """ availability_getter: Optional[Callable[["Session"], bool]] = field( default=None, repr=False ) """Return whether the source behind this device is currently usable.""" @property def internal_identity(self) -> str: """Return the topic/unique-id seed without exposing HA grouping details.""" return self.identity or self.identifiers[-1] @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 ``(session) -> object | None`` that returns the current entity value given an open SQLAlchemy session. ``None`` means "not yet wired". T11 calls this to obtain the current state before publishing. Signature: ``Callable[[Session], Any]``. 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[["Session"], 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=(f"home-automation:modbus:{device.uuid}",), name=device.friendly_name, identity=device.uuid, ) # 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 device id and metric key for the value_getter closure. # The getter queries the most recent ModbusReading for this device # and extracts the metric value from its payload. _device_id = device.id _metric_key = metric.key def _make_value_getter( dev_id: int, m_key: str ) -> Callable[["Session"], Any]: """Return a getter that fetches the latest reading value from DB.""" def _getter(sess: "Session") -> Any: from app.models.modbus import ModbusReading from sqlalchemy import desc reading = ( sess.query(ModbusReading) .filter(ModbusReading.device_id == dev_id) .order_by(desc(ModbusReading.recorded_at)) .first() ) if reading is None: return None return reading.payload.get(m_key) 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_id, _metric_key), state_class=metric.state_class, ) ) # One binary_sensor entity for device "online" status. online_key = f"modbus.{device.uuid}.online" _dev_id_online = device.id def _make_online_getter(dev_id: int) -> Callable[["Session"], Any]: """Return a getter that reports the device online status from DB.""" def _getter(sess: "Session") -> Any: from app.models.modbus import ModbusDevice dev = sess.query(ModbusDevice).filter( ModbusDevice.id == dev_id ).first() if dev is None: return None # Map last_poll_ok to HA binary_sensor payload strings. if dev.last_poll_ok is True: return "ON" return "OFF" 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_id_online), state_class=None, ) ) return entities # Register the modbus provider at module load time. register_provider(_modbus_provider) # --------------------------------------------------------------------------- # Energy Cost provider # --------------------------------------------------------------------------- # *_today 当日窗口翻天的宽限:本地午夜后 5 秒才切到新的一天,避免在 00:00:0x 把 # 归零后的值发出去、被慢几秒的 HA 钟记成前一天的 23:59:59(归错小时桶)。 _TODAY_RESET_GRACE = timedelta(seconds=5) def _energy_cost_provider(session: Session) -> list[ExposableEntity]: """Enumerate ExposableEntity objects for the energy cost subsystem. Produces 6 sensor entities grouped under a single HA device whose identity is anchored to the **current active electricity meter**: - ``buy_price_now`` — current effective buy price (EUR/kWh or local currency). - ``sell_price_now`` — current effective sell price (EUR/kWh or local currency). - ``import_cost_total`` — cumulative import cost (total, monetary). - ``export_revenue_total`` — cumulative export revenue (total, monetary). - ``import_cost_today`` — today's import cost (total_increasing, monetary). - ``export_revenue_today`` — today's export revenue (total_increasing, monetary). Active meter requirement ------------------------ **If no active electricity meter exists, the provider returns ``[]``.** No energy-cost entities are exposed to HA until a meter has been declared. This prevents spurious sensor creation with an undefined device identity. HA device identity (换表 → 新 sensor) -------------------------------------- The independent internal identity is set to the active meter's **uuid**. ``ha_discovery.py`` uses that internal identity as the MQTT node_id and as part of the ``unique_id`` for every entity; HA receives one separate, namespaced device identifier. Declaring a new active electricity meter produces a new uuid → new node_id / unique_id → HA creates a brand-new sensor, cleanly isolating post-swap data. Entity key stability -------------------- Entity keys remain the fixed stable strings (``"energy.buy_price_now"`` etc.), **not** derived from the meter uuid. The ``exposed_entity_toggle`` table uses keys as its primary handle; keeping them stable means toggled-on entities stay enabled after a meter swap without requiring the user to re-tick them. Current-price algorithm (source-agnostic, with fallback) --------------------------------------------------------- Strategy A: read the ``pricing`` snapshot from the most recent non-degraded ``energy_cost_period`` row. - For ``kind="tibber"``: the snapshot contains ``"buy"`` and ``"sell"`` keys (per-unit prices in the contract currency). - For ``kind="manual"``: ``"buy_normal"`` and ``"sell_normal"`` are used as representative effective per-unit prices. (Both tariff-slot prices differ only by the base rate; energy_tax and ODE are the same for both, so buy_normal is the higher/conservative single representative value.) When no non-degraded period exists or the pricing snapshot lacks the expected keys, ``value_getter`` returns ``None`` (``publish_states`` skips None values automatically). Cumulative totals ----------------- ``SUM(import_cost)`` and ``SUM(export_revenue)`` over **all non-degraded** ``energy_cost_period`` rows within the current meter's window. Degraded rows carry 0 costs and are excluded to avoid double-counting when they are later overwritten by real values. Currency -------- Taken from the most recent non-degraded period's ``currency`` column. Falls back to ``"EUR"`` when no such row exists. Key convention -------------- Fixed stable string keys (not derived from any mutable field or DB id): - ``"energy.buy_price_now"`` - ``"energy.sell_price_now"`` - ``"energy.import_cost_total"`` - ``"energy.export_revenue_total"`` - ``"energy.import_cost_today"`` - ``"energy.export_revenue_today"`` DeviceInfo identifiers ---------------------- One-element namespaced tuple ``("home-automation:energy-cost:",)``. The meter uuid used for MQTT topics and unique IDs is carried independently in ``DeviceInfo.identity``. """ from app.models.energy import EnergyCostPeriod, Meter # local import to avoid circular from sqlalchemy import select # --- Require an active electricity meter; return [] if none exists --- # Using an inline query (ended_at IS NULL) rather than a service-layer helper # to avoid a new public dependency and remain consistent with the value_getter # implementations below (which use the same inline pattern). active_meter: Meter | None = session.execute( select(Meter) .where( Meter.commodity == "electricity", Meter.ended_at.is_(None), ) .limit(1) ).scalar_one_or_none() if active_meter is None: # No active electricity meter → do not expose any energy-cost entities. # HA will not see these sensors until a meter is declared. return [] # --- Determine currency from the latest non-degraded row --- latest_period: EnergyCostPeriod | None = ( session.query(EnergyCostPeriod) .filter(EnergyCostPeriod.degraded.is_(False)) .order_by(EnergyCostPeriod.period_start.desc()) .first() ) currency: str = "EUR" if latest_period is not None and latest_period.currency: currency = latest_period.currency # --- Shared DeviceInfo anchored to the active meter's uuid --- # The internal identity is meter.uuid, while the HA identifier is a single # namespaced value. Swapping the meter produces a new HA sensor/card. # provides_availability=False: the energy-cost device has only sensors and no # online/offline heartbeat, so its entities must be "always available" in HA. # (Otherwise HA shows them unavailable despite state being published.) device_info = DeviceInfo( identifiers=(f"home-automation:energy-cost:{active_meter.uuid}",), name=active_meter.label, identity=active_meter.uuid, provides_availability=False, ) # --- value_getter: current buy price --- def _make_buy_price_getter() -> Callable[["Session"], Any]: """Return a getter for the current buy price per kWh. For ``kind="manual"`` contracts the getter selects the price slot that matches the current DSMR electricity tariff: - tariff == 1 (dal / off-peak) → ``buy_dal`` - tariff == 2 (normal / peak) → ``buy_normal`` - tariff is None / unknown → ``buy_normal`` (safe fallback = current status quo) For ``kind="tibber"`` (hourly dynamic pricing) the tariff slot is not applicable; the getter always uses the ``buy`` key from the snapshot. """ def _getter(sess: "Session") -> Any: from app.models.energy import EnergyCostPeriod as _ECP from app.services.dsmr_ingest import get_current_tariff period = ( sess.query(_ECP) .filter(_ECP.degraded.is_(False)) .order_by(_ECP.period_start.desc()) .first() ) if period is None or not period.pricing: return None snap = period.pricing kind = snap.get("kind") if kind == "tibber": raw = snap.get("buy") elif kind == "manual": tariff = get_current_tariff() if tariff == 1: raw = snap.get("buy_dal") else: # tariff == 2, None, or any unexpected value → normal (peak) rate. raw = snap.get("buy_normal") else: # Unknown kind — attempt common keys gracefully. raw = snap.get("buy") or snap.get("buy_normal") if raw is None: return None try: return float(raw) except (TypeError, ValueError): return None return _getter # --- value_getter: current sell price --- def _make_sell_price_getter() -> Callable[["Session"], Any]: """Return a getter for the current sell price per kWh. For ``kind="manual"`` contracts the getter selects the price slot that matches the current DSMR electricity tariff: - tariff == 1 (dal / off-peak) → ``sell_dal`` - tariff == 2 (normal / peak) → ``sell_normal`` - tariff is None / unknown → ``sell_normal`` (safe fallback = current status quo) For ``kind="tibber"`` the tariff slot is not applicable; always uses ``sell``. """ def _getter(sess: "Session") -> Any: from app.models.energy import EnergyCostPeriod as _ECP from app.services.dsmr_ingest import get_current_tariff period = ( sess.query(_ECP) .filter(_ECP.degraded.is_(False)) .order_by(_ECP.period_start.desc()) .first() ) if period is None or not period.pricing: return None snap = period.pricing kind = snap.get("kind") if kind == "tibber": raw = snap.get("sell") elif kind == "manual": tariff = get_current_tariff() if tariff == 1: raw = snap.get("sell_dal") else: # tariff == 2, None, or any unexpected value → normal (peak) rate. raw = snap.get("sell_normal") else: raw = snap.get("sell") or snap.get("sell_normal") if raw is None: return None try: return float(raw) except (TypeError, ValueError): return None return _getter # --- value_getter: cumulative import cost (incl. standing charges) --- def _make_import_cost_getter() -> Callable[["Session"], Any]: """Return a getter for the cumulative import cost plus prorated standing charges. D2 (M7): anchor = current active electricity meter's ``started_at``. After a meter swap the cumulative resets to zero for the new meter — old-meter periods have ``period_start < new_meter.started_at`` and fall outside the [anchor, now) window, so they are naturally excluded. Cross-meter boundary periods are already degraded and also excluded. No active electricity meter → returns None (cannot anchor; safer than returning a stale or wrong value). The check is done via an inline query (``ended_at IS NULL``) rather than ``meter_at(now)`` to be robust against the edge case where the active meter's ``started_at`` is in the future (``meter_at(now)`` would return None in that scenario). No non-degraded periods at all → returns None (has_data guard). No active contract → ``summarize`` still runs but fixed_costs = 0; the return value is the pure metered sum within the current meter window. This is consistent with D2: the anchor is the meter, not the contract. value = summarize(anchor → now).metered_import + summarize(anchor → now).fixed_costs """ def _getter(sess: "Session") -> Any: from datetime import UTC, datetime as _dt from app.models.energy import EnergyCostPeriod as _ECP, Meter as _Meter from app.services.energy_cost import summarize as _summarize from sqlalchemy import func as _func, select as _select # Quick check: any non-degraded period at all? has_data = sess.query(_func.sum(_ECP.import_cost)).filter( _ECP.degraded.is_(False) ).scalar() if has_data is None: return None # D2: anchor = active electricity meter's started_at. # Inline query (ended_at IS NULL) is more robust than meter_at(now) # because it avoids the edge case where started_at is in the future. active_meter = sess.execute( _select(_Meter) .where( _Meter.commodity == "electricity", _Meter.ended_at.is_(None), ) .limit(1) ).scalar_one_or_none() if active_meter is None: # No active electricity meter — cannot anchor; return None. return None from app.services.contracts import _as_utc as _cu anchor_utc = _cu(active_meter.started_at) now_utc = _dt.now(UTC) result = _summarize(sess, anchor_utc, now_utc) return result["metered_import"] + result["fixed_costs"] return _getter # --- value_getter: cumulative export revenue (incl. tax credit) --- def _make_export_revenue_getter() -> Callable[["Session"], Any]: """Return a getter for the cumulative export revenue plus prorated tax credit. D2 (M7): anchor = current active electricity meter's ``started_at``. Same reasoning as the import cost getter — see its docstring. value = summarize(anchor → now).metered_export + summarize(anchor → now).credits Returns None when no non-degraded period exists or no active electricity meter. """ def _getter(sess: "Session") -> Any: from datetime import UTC, datetime as _dt from app.models.energy import EnergyCostPeriod as _ECP, Meter as _Meter from app.services.energy_cost import summarize as _summarize from sqlalchemy import func as _func, select as _select # Quick check: any non-degraded period at all? has_data = sess.query(_func.sum(_ECP.export_revenue)).filter( _ECP.degraded.is_(False) ).scalar() if has_data is None: return None # D2: anchor = active electricity meter's started_at. active_meter = sess.execute( _select(_Meter) .where( _Meter.commodity == "electricity", _Meter.ended_at.is_(None), ) .limit(1) ).scalar_one_or_none() if active_meter is None: # No active electricity meter — cannot anchor; return None. return None from app.services.contracts import _as_utc as _cu anchor_utc = _cu(active_meter.started_at) now_utc = _dt.now(UTC) result = _summarize(sess, anchor_utc, now_utc) return result["metered_export"] + result["credits"] return _getter # --- value_getter: today's import cost (local-day window, resets at local midnight) --- def _make_import_cost_today_getter() -> Callable[["Session"], Any]: """Return a getter for today's import cost (metered + standing for today). Window: [local today 00:00, local tomorrow 00:00) in UTC. After local midnight the window rolls to the new day → value resets to that day's running cost, implementing "resets at local midnight" semantics for HA. Uses timezone module via module-attribute access to preserve monkeypatch safety. Returns None when no non-degraded period exists or no active contract. """ def _getter(sess: "Session") -> Any: from app.services.contracts import active_contract_versions from app.models.energy import EnergyCostPeriod as _ECP from app.services.energy_cost import summarize as _summarize from app.services import timezone as _tz_mod from sqlalchemy import func as _func from datetime import timedelta as _td # Quick check: any non-degraded period at all? has_data = sess.query(_func.sum(_ECP.import_cost)).filter( _ECP.degraded.is_(False) ).scalar() if has_data is None: return None versions = active_contract_versions(sess) if not versions: return None # Today's window in UTC, using monkeypatch-safe module attribute calls. # Grace: subtract _TODAY_RESET_GRACE so that in the first 5 seconds after # local midnight the getter still returns yesterday's window. This prevents # a "归零后的值" from being published while HA's clock (which may lag a few # seconds) would stamp it as 23:59:59 of the previous day. today_local = (_tz_mod.local_now() - _TODAY_RESET_GRACE).date() tomorrow_local = today_local + _td(days=1) today_start_utc = _tz_mod.local_midnight_utc(today_local) tomorrow_start_utc = _tz_mod.local_midnight_utc(tomorrow_local) result = _summarize(sess, today_start_utc, tomorrow_start_utc) return result["metered_import"] + result["fixed_costs"] return _getter # --- value_getter: today's export revenue (local-day window, resets at local midnight) --- def _make_export_revenue_today_getter() -> Callable[["Session"], Any]: """Return a getter for today's export revenue (metered + tax credit for today). Same window semantics as import_cost_today. Returns None when no non-degraded period exists or no active contract. """ def _getter(sess: "Session") -> Any: from app.services.contracts import active_contract_versions from app.models.energy import EnergyCostPeriod as _ECP from app.services.energy_cost import summarize as _summarize from app.services import timezone as _tz_mod from sqlalchemy import func as _func from datetime import timedelta as _td # Quick check: any non-degraded period at all? has_data = sess.query(_func.sum(_ECP.export_revenue)).filter( _ECP.degraded.is_(False) ).scalar() if has_data is None: return None versions = active_contract_versions(sess) if not versions: return None # Grace: same logic as import_cost_today — see that getter's comment. today_local = (_tz_mod.local_now() - _TODAY_RESET_GRACE).date() tomorrow_local = today_local + _td(days=1) today_start_utc = _tz_mod.local_midnight_utc(today_local) tomorrow_start_utc = _tz_mod.local_midnight_utc(tomorrow_local) result = _summarize(sess, today_start_utc, tomorrow_start_utc) return result["metered_export"] + result["credits"] return _getter # Price unit string: "/kWh" price_unit = f"{currency}/kWh" entities: list[ExposableEntity] = [ ExposableEntity( key="energy.buy_price_now", component="sensor", device=device_info, device_class=None, unit=price_unit, name="Energy Buy Price Now", value_getter=_make_buy_price_getter(), state_class="measurement", ), ExposableEntity( key="energy.sell_price_now", component="sensor", device=device_info, device_class=None, unit=price_unit, name="Energy Sell Price Now", value_getter=_make_sell_price_getter(), state_class="measurement", ), ExposableEntity( key="energy.import_cost_total", component="sensor", device=device_info, device_class="monetary", unit=currency, name="Energy Import Cost (incl. standing)", value_getter=_make_import_cost_getter(), state_class="total", ), ExposableEntity( key="energy.export_revenue_total", component="sensor", device=device_info, device_class="monetary", unit=currency, name="Energy Export Revenue (incl. tax credit)", value_getter=_make_export_revenue_getter(), state_class="total", ), # Daily entities: reset at local midnight, state_class=total_increasing. # # device_class=monetary is correct here. The HA developer docs (long-term # statistics section) prohibit MONETARY from being combined with # state_class=measurement — they do NOT prohibit it with total or # total_increasing. monetary+total_increasing is valid (the cumulative # entities above use monetary+total with no issue). # # total_increasing is appropriate because these daily quantities are # non-negative (import/export metered cost ≥ 0, fixed standing fee/tax # credit ≥ 0) and monotonically non-decreasing within any given local day. # When the local midnight rolls over the value drops back to ~1 day's fixed # fee; HA interprets that decrease as a new cycle, giving correct daily # aggregation without any explicit last_reset pipeline. ExposableEntity( key="energy.import_cost_today", component="sensor", device=device_info, device_class="monetary", unit=currency, name="Energy Import Cost Today (incl. standing)", value_getter=_make_import_cost_today_getter(), state_class="total_increasing", ), ExposableEntity( key="energy.export_revenue_today", component="sensor", device=device_info, device_class="monetary", unit=currency, name="Energy Export Revenue Today (incl. tax credit)", value_getter=_make_export_revenue_today_getter(), state_class="total_increasing", ), ] return entities # Register the energy cost provider at module load time. register_provider(_energy_cost_provider) # --------------------------------------------------------------------------- # M8 source / meter / thermal-cost provider # --------------------------------------------------------------------------- _SOURCE_STALE_AFTER = timedelta(minutes=5) def _utc_now() -> datetime: """Small clock seam for live-value bounds and deterministic tests.""" return datetime.now(UTC) def _as_utc(value: datetime) -> datetime: return value.replace(tzinfo=UTC) if value.tzinfo is None else value.astimezone(UTC) def _source_is_online(source: Any, channel: Any | None = None) -> bool: """Do not turn an old cumulative value into a plausible live HA state.""" now = _utc_now() if not source.enabled or source.status != "online" or source.last_seen_at is None: return False source_age = now - _as_utc(source.last_seen_at) if not timedelta(0) <= source_age <= _SOURCE_STALE_AFTER: return False if channel is None: return True if channel.latest_at is None or channel.latest_quality not in {"valid", "unverifiable"}: return False channel_age = now - _as_utc(channel.latest_at) return timedelta(0) <= channel_age <= _SOURCE_STALE_AFTER def _dsmr_latest(session: Session, source_id: int, *, start: datetime | None = None, end: datetime | None = None, not_after: datetime | None = None) -> Any: """Latest DSMR row in the source's (optionally bounded) cumulative domain.""" from app.models.energy import DsmrReading from sqlalchemy import select query = select(DsmrReading).where(DsmrReading.meter_source_id == source_id) if start is not None: query = query.where(DsmrReading.recorded_at >= start) if end is not None: query = query.where(DsmrReading.recorded_at < end) if not_after is not None: query = query.where(DsmrReading.recorded_at <= not_after) return session.execute(query.order_by(DsmrReading.recorded_at.desc()).limit(1)).scalar_one_or_none() def _dsmr_total(reading: Any) -> Any: """Return imported electricity total from a real DSMR telegram, or None.""" from decimal import Decimal, InvalidOperation try: payload = reading.payload or {} return Decimal(str(payload["electricity_delivered_1"])) + Decimal( str(payload["electricity_delivered_2"]) ) except (InvalidOperation, KeyError, TypeError, ValueError): return None def _m8_energy_provider(session: Session) -> list[ExposableEntity]: """Expose accepted source snapshots and current M8 meters. The provider intentionally reads the thermal service's public ``summarize`` result for money. Keeping formulas in ``meter_cost`` prevents HA from becoming a second, subtly different billing implementation. """ from app.models.energy import Meter from app.models.meter_source import MeterSource, MeterSourceBinding, MeterSourceChannel from sqlalchemy import select sources = session.execute(select(MeterSource)).scalars().all() entities: list[ExposableEntity] = [] for source in sources: source_info = DeviceInfo( identifiers=(f"home-automation:meter-source:{source.uuid}",), name=source.name, identity=source.uuid, availability_id=source.uuid, availability_getter=lambda sess, source_id=source.id: _source_online_by_id(sess, source_id), ) entities.append(ExposableEntity( key=f"source.{source.uuid}.online", component="binary_sensor", device=source_info, device_class="connectivity", unit="", name=f"{source.name} Online", value_getter=lambda sess, source_id=source.id: "ON" if _source_online_by_id(sess, source_id) else "OFF", )) active_meters = session.execute( select(Meter).where( Meter.ended_at.is_(None), Meter.commodity.in_(("electricity", "heating", "hot_water")) ) ).scalars().all() active_by_commodity = {meter.commodity: meter for meter in active_meters} for meter in active_meters: bound = session.execute( select(MeterSourceBinding, MeterSourceChannel, MeterSource) .join(MeterSourceChannel, MeterSourceChannel.id == MeterSourceBinding.channel_id) .join(MeterSource, MeterSource.id == MeterSourceChannel.source_id) .where(MeterSourceBinding.meter_id == meter.id, MeterSourceBinding.ended_at.is_(None)) ).one_or_none() if bound is None: continue binding, channel, source = bound info = DeviceInfo( identifiers=(f"home-automation:meter:{meter.uuid}",), name=meter.label, identity=meter.uuid, # Keep this opaque and Meter-anchored. In particular, do not use a # source UUID here: two channels of one source can be independently # stale/invalid and must not overwrite each other's availability. availability_id=f"meter-availability-{meter.uuid}", availability_getter=lambda sess, source_id=source.id, channel_id=channel.id: _bound_channel_online(sess, source_id, channel_id), ) if meter.commodity == "electricity": unit, device_class = "kWh", "energy" elif meter.commodity == "heating": unit, device_class = "GJ", "energy" else: unit, device_class = "m³", "water" for suffix, getter in ( ("total", _meter_total_getter(binding.id, source.id, channel.id)), ("today", _meter_today_getter(binding.id, source.id, channel.id)), ): entities.append(ExposableEntity( key=f"meter.{meter.uuid}.{suffix}", component="sensor", device=info, device_class=device_class, unit=unit, name=f"{meter.label} {suffix.title()}", value_getter=getter, state_class="total_increasing", )) heating, hot_water = active_by_commodity.get("heating"), active_by_commodity.get("hot_water") if heating is not None and hot_water is not None: # Keep the v1.6.1 canonical identity/key seed stable. Discovery topic # segments are sanitised independently by ha_discovery, so this dot is # never emitted in a topic while existing toggle rows remain usable. identity = ".".join(sorted((heating.uuid, hot_water.uuid))) currency = _thermal_currency(session) cost_info = DeviceInfo( identifiers=(f"home-automation:thermal-cost:{identity}",), name="Thermal Energy Cost", identity=identity, provides_availability=False, ) labels = { "heating": "Heating", "hot_water_heating": "Hot Water Heating", "water": "Water", "water_tax": "Water Tax", "hot_water_total": "Hot Water", "fixed": "Fixed", "all_in": "All-in", } for suffix, window in (("total", None), ("today", "today")): for metric, label in labels.items(): entities.append(ExposableEntity( key=f"thermal_cost.{identity}.{metric}_{suffix}", component="sensor", device=cost_info, device_class="monetary", unit=currency, name=f"Thermal {label} {suffix.title()}", value_getter=_thermal_cost_getter(metric, window), state_class="total" if suffix == "total" else "total_increasing", )) return entities def _source_online_by_id(session: Session, source_id: int) -> bool: from app.models.meter_source import MeterSource source = session.get(MeterSource, source_id) if source is not None and source.kind == "dsmr_mqtt": now = _utc_now() # Inspect the actual latest telegram before calculating freshness: a # clock-skewed future telegram must not make an older one look live. latest = _dsmr_latest(session, source_id) if not source.enabled or latest is None: return False age = now - _as_utc(latest.recorded_at) return timedelta(0) <= age <= _SOURCE_STALE_AFTER return source is not None and _source_is_online(source) def _bound_channel_online(session: Session, source_id: int, channel_id: int) -> bool: from app.models.meter_source import MeterSource, MeterSourceChannel source, channel = session.get(MeterSource, source_id), session.get(MeterSourceChannel, channel_id) if source is not None and source.kind == "dsmr_mqtt": return _source_online_by_id(session, source_id) return source is not None and channel is not None and _source_is_online(source, channel) def _meter_total_getter(binding_id: int, source_id: int, channel_id: int) -> Callable[[Session], Any]: def _getter(session: Session) -> Any: from app.models.energy import Meter from app.models.meter_source import MeterSource, MeterSourceBinding, MeterSourceChannel if not _bound_channel_online(session, source_id, channel_id): return None binding = session.get(MeterSourceBinding, binding_id) if ( binding is None or binding.ended_at is not None or binding.channel_id != channel_id or (meter := session.get(Meter, binding.meter_id)) is None or meter.ended_at is not None ): return None source = session.get(MeterSource, source_id) channel = session.get(MeterSourceChannel, channel_id) if source is None or channel is None: return None start = max(_as_utc(meter.started_at), _as_utc(binding.started_at)) # Both windows are half-open. The current records have no end, but # retaining this form makes a future close fail safely. end = min( (value for value in (_as_utc(meter.ended_at) if meter.ended_at else None, _as_utc(binding.ended_at) if binding.ended_at else None) if value is not None), default=None, ) now = _utc_now() if source.kind == "dsmr_mqtt": latest = _dsmr_latest(session, source_id, start=start, end=end, not_after=now) if latest is None: return None return _dsmr_total(latest) if channel.latest_at is None or channel.latest_quality not in {"valid", "unverifiable"}: return None latest_at = _as_utc(channel.latest_at) if latest_at < start or latest_at > now or (end is not None and latest_at >= end): return None return channel.latest_value return _getter def _meter_today_getter(binding_id: int, source_id: int, channel_id: int) -> Callable[[Session], Any]: def _getter(session: Session) -> Any: from app.models.energy import Meter from app.models.meter_source import MeterSource, MeterSourceBinding, WarmteLinkReading from app.services import timezone as tz from sqlalchemy import select binding = session.get(MeterSourceBinding, binding_id) if binding is None or binding.ended_at is not None or binding.channel_id != channel_id: return None meter = session.get(Meter, binding.meter_id) if meter is None or meter.ended_at is not None: return None now = _utc_now() day = (tz.local_now() - _TODAY_RESET_GRACE).date() start = max(tz.local_midnight_utc(day), _as_utc(meter.started_at), _as_utc(binding.started_at)) bounds = [tz.local_midnight_utc(day + timedelta(days=1))] bounds.extend(value for value in ( _as_utc(meter.ended_at) if meter.ended_at else None, _as_utc(binding.ended_at) if binding.ended_at else None, ) if value is not None) end = min(bounds) source = session.get(MeterSource, source_id) if source is None or not source.enabled: return None if source.kind != "dsmr_mqtt" and source.status != "online": return None if source.kind == "dsmr_mqtt": first = _dsmr_latest(session, source_id, start=start, end=end, not_after=now) if first is None: return None from app.models.energy import DsmrReading from sqlalchemy import select as dsmr_select rows = session.execute(dsmr_select(DsmrReading).where( DsmrReading.meter_source_id == source_id, DsmrReading.recorded_at >= start, DsmrReading.recorded_at < end, DsmrReading.recorded_at <= now, ).order_by(DsmrReading.recorded_at)).scalars().all() if len(rows) < 2: return None first_total, last_total = _dsmr_total(rows[0]), _dsmr_total(rows[-1]) if first_total is None or last_total is None: return None value = last_total - first_total return value if value >= 0 else None query = select(WarmteLinkReading).where( WarmteLinkReading.channel_id == channel_id, WarmteLinkReading.recorded_at >= start, WarmteLinkReading.quality.in_(("valid", "unverifiable")), ) if end is not None: query = query.where(WarmteLinkReading.recorded_at < end) query = query.where(WarmteLinkReading.recorded_at <= now) readings = session.execute(query.order_by(WarmteLinkReading.recorded_at)).scalars().all() if len(readings) < 2: return None value = readings[-1].value - readings[0].value return value if value >= 0 else None return _getter def _thermal_currency(session: Session) -> str: from app.services.contracts import active_contract_versions versions = active_contract_versions(session, scope="thermal") return versions[-1].contract.currency if versions else "EUR" def _thermal_cost_getter(metric: str, window: str | None) -> Callable[[Session], Any]: def _getter(session: Session) -> Any: from app.services import timezone as tz from app.services.meter_cost import summarize now = _utc_now() from app.models.energy import Meter meters = session.query(Meter).filter( Meter.commodity.in_(("heating", "hot_water")), Meter.ended_at.is_(None) ).all() if len(meters) != 2: return None epoch_start = max(_as_utc(m.started_at) for m in meters) if window == "today": day = (tz.local_now() - _TODAY_RESET_GRACE).date() start = max(tz.local_midnight_utc(day), epoch_start) # During the reset grace ``day`` is yesterday, whose local midnight # remains the cap; otherwise do not summarize readings from later today. end = min(tz.local_midnight_utc(day + timedelta(days=1)), now) else: # A combined thermal identity begins when its newest constituent # meter epoch begins; including pre-swap rows would mix identities. start, end = epoch_start, now result = summarize(session, start, end, now=now) if result["period_count"] == 0 and result["fixed_cost"] == 0: return None if metric == "fixed": return result["fixed_cost"] if metric == "all_in": return result["total_cost"] if metric == "water": return result["breakdown"]["hot_water"] if metric == "water_tax": return result["breakdown"]["hot_water_tax"] if metric == "hot_water_total": # Keep the existing component entities variable-only, while making # the two consumer-facing totals partition the all-in summary. # Standing costs are assigned by their contractual commodity: # hot-water network belongs to hot water; the remaining named # standing fees belong to heating. return ( result["breakdown"]["hot_water_heating"] + result["breakdown"]["hot_water"] + result["breakdown"]["hot_water_tax"] + result["fixed_breakdown"]["hot_water_network"] ) if metric == "heating": return result["breakdown"]["heating"] + sum( (result["fixed_breakdown"][key] for key in ( "heating_network", "metering", "delivery_set", "other" )), 0, ) return result["breakdown"][metric] return _getter register_provider(_m8_energy_provider)