2026-06-22 14:50:40 +02:00
|
|
|
|
"""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)."""
|
|
|
|
|
|
|
2026-06-24 11:54:49 +02:00
|
|
|
|
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.
|
|
|
|
|
|
"""
|
|
|
|
|
|
|
2026-06-22 14:50:40 +02:00
|
|
|
|
|
|
|
|
|
|
@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
|
2026-06-22 15:32:43 +02:00
|
|
|
|
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]``.
|
2026-06-22 14:50:40 +02:00
|
|
|
|
|
|
|
|
|
|
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
|
2026-06-22 15:32:43 +02:00
|
|
|
|
value_getter: Optional[Callable[["Session"], Any]] = field(default=None, repr=False)
|
2026-06-22 14:50:40 +02:00
|
|
|
|
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}"
|
|
|
|
|
|
|
2026-06-22 15:32:43 +02:00
|
|
|
|
# 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
|
2026-06-22 14:50:40 +02:00
|
|
|
|
_metric_key = metric.key
|
|
|
|
|
|
|
2026-06-22 15:32:43 +02:00
|
|
|
|
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)
|
2026-06-22 14:50:40 +02:00
|
|
|
|
|
|
|
|
|
|
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()}",
|
2026-06-22 15:32:43 +02:00
|
|
|
|
value_getter=_make_value_getter(_device_id, _metric_key),
|
2026-06-22 14:50:40 +02:00
|
|
|
|
state_class=metric.state_class,
|
|
|
|
|
|
)
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
# One binary_sensor entity for device "online" status.
|
|
|
|
|
|
online_key = f"modbus.{device.uuid}.online"
|
2026-06-22 15:32:43 +02:00
|
|
|
|
_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"
|
2026-06-22 14:50:40 +02:00
|
|
|
|
|
|
|
|
|
|
return _getter
|
|
|
|
|
|
|
|
|
|
|
|
entities.append(
|
|
|
|
|
|
ExposableEntity(
|
|
|
|
|
|
key=online_key,
|
|
|
|
|
|
component="binary_sensor",
|
|
|
|
|
|
device=device_info,
|
|
|
|
|
|
device_class="connectivity",
|
|
|
|
|
|
unit="",
|
|
|
|
|
|
name=f"{device.friendly_name} Online",
|
2026-06-22 15:32:43 +02:00
|
|
|
|
value_getter=_make_online_getter(_dev_id_online),
|
2026-06-22 14:50:40 +02:00
|
|
|
|
state_class=None,
|
|
|
|
|
|
)
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
return entities
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# Register the modbus provider at module load time.
|
|
|
|
|
|
register_provider(_modbus_provider)
|
2026-06-23 22:44:41 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
# Energy Cost provider
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _energy_cost_provider(session: Session) -> list[ExposableEntity]:
|
|
|
|
|
|
"""Enumerate ExposableEntity objects for the energy cost subsystem.
|
|
|
|
|
|
|
|
|
|
|
|
Produces 4 sensor entities grouped under a single HA device "Energy Cost":
|
|
|
|
|
|
|
|
|
|
|
|
- ``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_increasing, monetary).
|
|
|
|
|
|
- ``export_revenue_total`` — cumulative export revenue (total_increasing, monetary).
|
|
|
|
|
|
|
|
|
|
|
|
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. 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"``
|
|
|
|
|
|
|
|
|
|
|
|
DeviceInfo identifiers
|
|
|
|
|
|
----------------------
|
|
|
|
|
|
**Two-element tuple** ``("energy-cost", "energy-cost")`` so that
|
|
|
|
|
|
``ha_discovery.py``'s ``entity.device.identifiers[1]`` is always valid
|
|
|
|
|
|
(the service uses index [1] as the node_id throughout).
|
|
|
|
|
|
"""
|
|
|
|
|
|
from app.models.energy import EnergyCostPeriod # local import to avoid circular
|
|
|
|
|
|
|
|
|
|
|
|
# --- Determine currency and representative pricing 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 (2-element identifiers — required by ha_discovery.py [1] access) ---
|
2026-06-24 11:54:49 +02:00
|
|
|
|
# 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.)
|
2026-06-23 22:44:41 +02:00
|
|
|
|
device_info = DeviceInfo(
|
|
|
|
|
|
identifiers=("energy-cost", "energy-cost"),
|
|
|
|
|
|
name="Energy Cost",
|
2026-06-24 11:54:49 +02:00
|
|
|
|
provides_availability=False,
|
2026-06-23 22:44:41 +02:00
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
# --- value_getter: current buy price ---
|
|
|
|
|
|
|
|
|
|
|
|
def _make_buy_price_getter() -> Callable[["Session"], Any]:
|
2026-06-24 15:39:21 +02:00
|
|
|
|
"""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.
|
|
|
|
|
|
"""
|
2026-06-23 22:44:41 +02:00
|
|
|
|
def _getter(sess: "Session") -> Any:
|
|
|
|
|
|
from app.models.energy import EnergyCostPeriod as _ECP
|
2026-06-24 15:39:21 +02:00
|
|
|
|
from app.services.dsmr_ingest import get_current_tariff
|
2026-06-23 22:44:41 +02:00
|
|
|
|
|
|
|
|
|
|
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":
|
2026-06-24 15:39:21 +02:00
|
|
|
|
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")
|
2026-06-23 22:44:41 +02:00
|
|
|
|
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]:
|
2026-06-24 15:39:21 +02:00
|
|
|
|
"""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``.
|
|
|
|
|
|
"""
|
2026-06-23 22:44:41 +02:00
|
|
|
|
def _getter(sess: "Session") -> Any:
|
|
|
|
|
|
from app.models.energy import EnergyCostPeriod as _ECP
|
2026-06-24 15:39:21 +02:00
|
|
|
|
from app.services.dsmr_ingest import get_current_tariff
|
2026-06-23 22:44:41 +02:00
|
|
|
|
|
|
|
|
|
|
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":
|
2026-06-24 15:39:21 +02:00
|
|
|
|
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")
|
2026-06-23 22:44:41 +02:00
|
|
|
|
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
|
|
|
|
|
|
|
2026-06-24 14:26:32 +02:00
|
|
|
|
# --- value_getter: cumulative import cost (incl. standing charges) ---
|
2026-06-23 22:44:41 +02:00
|
|
|
|
|
|
|
|
|
|
def _make_import_cost_getter() -> Callable[["Session"], Any]:
|
2026-06-24 14:26:32 +02:00
|
|
|
|
"""Return a getter for the cumulative import cost plus prorated standing charges.
|
|
|
|
|
|
|
|
|
|
|
|
Value = SUM(import_cost, non-degraded) + standing_charges_cumulative
|
|
|
|
|
|
|
|
|
|
|
|
where standing_charges_cumulative = elapsed_days × (network_fee + management_fee) / 30.
|
|
|
|
|
|
Elapsed days are counted from the active contract version's effective_from date
|
|
|
|
|
|
(inclusive of today). Returns None when no non-degraded periods exist.
|
|
|
|
|
|
"""
|
2026-06-23 22:44:41 +02:00
|
|
|
|
def _getter(sess: "Session") -> Any:
|
2026-06-24 14:26:32 +02:00
|
|
|
|
from decimal import Decimal
|
|
|
|
|
|
from datetime import UTC, datetime as _dt
|
|
|
|
|
|
|
2026-06-23 22:44:41 +02:00
|
|
|
|
from app.models.energy import EnergyCostPeriod as _ECP
|
2026-06-24 14:26:32 +02:00
|
|
|
|
from app.services.contracts import active_contract_version_at, _as_utc
|
2026-06-23 22:44:41 +02:00
|
|
|
|
from sqlalchemy import func as _func
|
|
|
|
|
|
|
|
|
|
|
|
total = sess.query(_func.sum(_ECP.import_cost)).filter(
|
|
|
|
|
|
_ECP.degraded.is_(False)
|
|
|
|
|
|
).scalar()
|
|
|
|
|
|
if total is None:
|
|
|
|
|
|
return None
|
2026-06-24 14:26:32 +02:00
|
|
|
|
|
|
|
|
|
|
# Add prorated standing charges from the active contract version.
|
|
|
|
|
|
standing_cumulative = Decimal("0")
|
|
|
|
|
|
now_utc = _dt.now(UTC)
|
|
|
|
|
|
version = active_contract_version_at(sess, now_utc)
|
|
|
|
|
|
if version is not None:
|
|
|
|
|
|
vals = version.values or {}
|
|
|
|
|
|
standing = vals.get("standing", {})
|
|
|
|
|
|
network_fee = Decimal(str(standing.get("network_fee") or 0))
|
|
|
|
|
|
management_fee = Decimal(str(standing.get("management_fee") or 0))
|
|
|
|
|
|
daily_standing = (network_fee + management_fee) / Decimal("30")
|
|
|
|
|
|
|
|
|
|
|
|
effective_from = _as_utc(version.effective_from)
|
|
|
|
|
|
days = (now_utc.date() - effective_from.date()).days + 1
|
|
|
|
|
|
days = max(days, 0)
|
|
|
|
|
|
standing_cumulative = daily_standing * days
|
|
|
|
|
|
|
|
|
|
|
|
return float(Decimal(str(total)) + standing_cumulative)
|
2026-06-23 22:44:41 +02:00
|
|
|
|
|
|
|
|
|
|
return _getter
|
|
|
|
|
|
|
2026-06-24 14:26:32 +02:00
|
|
|
|
# --- value_getter: cumulative export revenue (incl. tax credit) ---
|
2026-06-23 22:44:41 +02:00
|
|
|
|
|
|
|
|
|
|
def _make_export_revenue_getter() -> Callable[["Session"], Any]:
|
2026-06-24 14:26:32 +02:00
|
|
|
|
"""Return a getter for the cumulative export revenue plus prorated tax credit.
|
|
|
|
|
|
|
|
|
|
|
|
Value = SUM(export_revenue, non-degraded) + heffingskorting_cumulative
|
|
|
|
|
|
|
|
|
|
|
|
where heffingskorting_cumulative = elapsed_days × heffingskorting / 365.
|
|
|
|
|
|
Elapsed days are counted from the active contract version's effective_from date
|
|
|
|
|
|
(inclusive of today). Returns None when no non-degraded periods exist.
|
|
|
|
|
|
"""
|
2026-06-23 22:44:41 +02:00
|
|
|
|
def _getter(sess: "Session") -> Any:
|
2026-06-24 14:26:32 +02:00
|
|
|
|
from decimal import Decimal
|
|
|
|
|
|
from datetime import UTC, datetime as _dt
|
|
|
|
|
|
|
2026-06-23 22:44:41 +02:00
|
|
|
|
from app.models.energy import EnergyCostPeriod as _ECP
|
2026-06-24 14:26:32 +02:00
|
|
|
|
from app.services.contracts import active_contract_version_at, _as_utc
|
2026-06-23 22:44:41 +02:00
|
|
|
|
from sqlalchemy import func as _func
|
|
|
|
|
|
|
|
|
|
|
|
total = sess.query(_func.sum(_ECP.export_revenue)).filter(
|
|
|
|
|
|
_ECP.degraded.is_(False)
|
|
|
|
|
|
).scalar()
|
|
|
|
|
|
if total is None:
|
|
|
|
|
|
return None
|
2026-06-24 14:26:32 +02:00
|
|
|
|
|
|
|
|
|
|
# Add prorated heffingskorting credit from the active contract version.
|
|
|
|
|
|
credit_cumulative = Decimal("0")
|
|
|
|
|
|
now_utc = _dt.now(UTC)
|
|
|
|
|
|
version = active_contract_version_at(sess, now_utc)
|
|
|
|
|
|
if version is not None:
|
|
|
|
|
|
vals = version.values or {}
|
|
|
|
|
|
credits = vals.get("credits", {})
|
|
|
|
|
|
heffingskorting = Decimal(str(credits.get("heffingskorting") or 0))
|
|
|
|
|
|
daily_credit = heffingskorting / Decimal("365")
|
|
|
|
|
|
|
|
|
|
|
|
effective_from = _as_utc(version.effective_from)
|
|
|
|
|
|
days = (now_utc.date() - effective_from.date()).days + 1
|
|
|
|
|
|
days = max(days, 0)
|
|
|
|
|
|
credit_cumulative = daily_credit * days
|
|
|
|
|
|
|
|
|
|
|
|
return float(Decimal(str(total)) + credit_cumulative)
|
2026-06-23 22:44:41 +02:00
|
|
|
|
|
|
|
|
|
|
return _getter
|
|
|
|
|
|
|
|
|
|
|
|
# Price unit string: "<currency>/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,
|
2026-06-24 14:26:32 +02:00
|
|
|
|
name="Energy Import Cost (incl. standing)",
|
2026-06-23 22:44:41 +02:00
|
|
|
|
value_getter=_make_import_cost_getter(),
|
2026-06-24 14:26:32 +02:00
|
|
|
|
state_class="total",
|
2026-06-23 22:44:41 +02:00
|
|
|
|
),
|
|
|
|
|
|
ExposableEntity(
|
|
|
|
|
|
key="energy.export_revenue_total",
|
|
|
|
|
|
component="sensor",
|
|
|
|
|
|
device=device_info,
|
|
|
|
|
|
device_class="monetary",
|
|
|
|
|
|
unit=currency,
|
2026-06-24 14:26:32 +02:00
|
|
|
|
name="Energy Export Revenue (incl. tax credit)",
|
2026-06-23 22:44:41 +02:00
|
|
|
|
value_getter=_make_export_revenue_getter(),
|
2026-06-24 14:26:32 +02:00
|
|
|
|
state_class="total",
|
2026-06-23 22:44:41 +02:00
|
|
|
|
),
|
|
|
|
|
|
]
|
|
|
|
|
|
|
|
|
|
|
|
return entities
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# Register the energy cost provider at module load time.
|
|
|
|
|
|
register_provider(_energy_cost_provider)
|