M8-T17: add Home Assistant thermal entities
This commit is contained in:
@@ -98,6 +98,15 @@ def _availability_topic(device_uuid: str, prefix: str) -> str:
|
||||
return f"{prefix}/modbus/{node}/availability"
|
||||
|
||||
|
||||
def _availability_id(entity: ExposableEntity) -> str:
|
||||
"""Return the identity which owns this entity's liveness topic.
|
||||
|
||||
M8 meters deliberately retain their own UUID as HA node/unique identity,
|
||||
while their availability is supplied by a MeterSource UUID.
|
||||
"""
|
||||
return entity.device.availability_id or entity.device.identifiers[1]
|
||||
|
||||
|
||||
def _unique_id(entity: ExposableEntity) -> str:
|
||||
"""Stable unique_id — device uuid + metric key (never from mutable fields)."""
|
||||
device_uuid = entity.device.identifiers[1]
|
||||
@@ -139,8 +148,7 @@ def build_discovery_payload(
|
||||
if state_prefix is None:
|
||||
state_prefix = discovery_prefix
|
||||
|
||||
device_uuid = entity.device.identifiers[1]
|
||||
avail_topic = _availability_topic(device_uuid, state_prefix)
|
||||
avail_topic = _availability_topic(_availability_id(entity), state_prefix)
|
||||
state_t = _state_topic(entity, state_prefix)
|
||||
topic = _discovery_topic(entity, discovery_prefix)
|
||||
|
||||
@@ -212,6 +220,21 @@ def publish_discovery(session: Session) -> None:
|
||||
logger.exception("publish_discovery: failed to build catalog; aborting")
|
||||
return
|
||||
|
||||
# Meter UUIDs are intentionally identity-changing epochs. Discovery config
|
||||
# is retained, so clear only the precisely enumerable old M8 identities;
|
||||
# never wildcard a provider/topic and risk removing another source's card.
|
||||
try:
|
||||
stale_entities = _stale_m8_entities(session)
|
||||
except Exception:
|
||||
logger.exception("publish_discovery: unable to enumerate old M8 identities")
|
||||
stale_entities = []
|
||||
for old_entity in stale_entities:
|
||||
try:
|
||||
old_topic, _ = build_discovery_payload(old_entity, discovery_prefix, state_prefix)
|
||||
mqtt_manager.publish(old_topic, b"", retain=True)
|
||||
except Exception:
|
||||
logger.exception("publish_discovery: unable to clear old identity %r", old_entity.key)
|
||||
|
||||
for entry in catalog:
|
||||
entity = entry.entity
|
||||
try:
|
||||
@@ -236,6 +259,62 @@ def publish_discovery(session: Session) -> None:
|
||||
)
|
||||
|
||||
|
||||
def _stale_m8_entities(session: Session) -> list[ExposableEntity]:
|
||||
"""Return synthetic discovery entries for superseded thermal identities.
|
||||
|
||||
This is deliberately a narrow, best-effort cleanup: ended Meter UUIDs and
|
||||
historically possible thermal combinations only; current identities are excluded.
|
||||
"""
|
||||
from app.integrations.expose import DeviceInfo
|
||||
from app.models.energy import Meter
|
||||
from sqlalchemy import select
|
||||
|
||||
meters = session.execute(select(Meter).where(
|
||||
Meter.commodity.in_(("electricity", "heating", "hot_water"))
|
||||
)).scalars().all()
|
||||
current = {meter.commodity: meter for meter in meters if meter.ended_at is None}
|
||||
old = [meter for meter in meters if meter.ended_at is not None]
|
||||
entities: list[ExposableEntity] = []
|
||||
for meter in old:
|
||||
info = DeviceInfo(identifiers=("meter", meter.uuid), name=meter.label)
|
||||
for suffix in ("total", "today"):
|
||||
entities.append(ExposableEntity(
|
||||
key=f"meter.{meter.uuid}.{suffix}", component="sensor", device=info,
|
||||
device_class=None, unit="", name="obsolete",
|
||||
))
|
||||
heatings = [meter for meter in meters if meter.commodity == "heating"]
|
||||
waters = [meter for meter in meters if meter.commodity == "hot_water"]
|
||||
current_identity = (
|
||||
".".join(sorted((current["heating"].uuid, current["hot_water"].uuid)))
|
||||
if current.get("heating") is not None and current.get("hot_water") is not None else None
|
||||
)
|
||||
for heating in heatings:
|
||||
for water in waters:
|
||||
if heating.ended_at is None and water.ended_at is None:
|
||||
continue
|
||||
# A thermal identity can only have been published when both Meter
|
||||
# epochs were current at the same instant. Do not form a Cartesian
|
||||
# product of historical records: that would tombstone identities
|
||||
# which have never existed in HA.
|
||||
heating_start, water_start = heating.started_at, water.started_at
|
||||
heating_end, water_end = heating.ended_at, water.ended_at
|
||||
if (heating_end is not None and water_start >= heating_end) or (
|
||||
water_end is not None and heating_start >= water_end
|
||||
):
|
||||
continue
|
||||
identity = ".".join(sorted((heating.uuid, water.uuid)))
|
||||
if identity == current_identity:
|
||||
continue
|
||||
info = DeviceInfo(identifiers=("thermal-cost", identity), name="obsolete")
|
||||
for metric in ("heating", "hot_water_heating", "water", "water_tax", "fixed", "all_in"):
|
||||
for suffix in ("total", "today"):
|
||||
entities.append(ExposableEntity(
|
||||
key=f"thermal_cost.{identity}.{metric}_{suffix}", component="sensor", device=info,
|
||||
device_class=None, unit="", name="obsolete",
|
||||
))
|
||||
return entities
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Public: publish states
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -288,7 +367,19 @@ def _publish_entity_state(
|
||||
Also publishes the availability topic for ``binary_sensor`` "online" entities.
|
||||
"""
|
||||
state_t = _state_topic(entity, prefix)
|
||||
device_uuid = entity.device.identifiers[1]
|
||||
# Source-backed entities can have a different liveness identity from their
|
||||
# HA device identity. Publish it before the state; a None value below is
|
||||
# intentionally not converted to a synthetic zero.
|
||||
if entity.device.provides_availability and entity.device.availability_getter is not None:
|
||||
try:
|
||||
available = bool(entity.device.availability_getter(session))
|
||||
mqtt_manager.publish(
|
||||
_availability_topic(_availability_id(entity), prefix),
|
||||
"online" if available else "offline",
|
||||
retain=False,
|
||||
)
|
||||
except Exception:
|
||||
logger.exception("availability_getter raised for entity %r", entity.key)
|
||||
|
||||
if entity.component == "binary_sensor" and "online" in entity.key:
|
||||
# The online sensor represents device availability.
|
||||
@@ -303,7 +394,7 @@ def _publish_entity_state(
|
||||
# Default to offline when no reading is available.
|
||||
online = (raw_value == "ON")
|
||||
avail_payload = "online" if online else "offline"
|
||||
avail_topic = _availability_topic(device_uuid, prefix)
|
||||
avail_topic = _availability_topic(_availability_id(entity), prefix)
|
||||
mqtt_manager.publish(avail_topic, avail_payload, retain=False)
|
||||
# The state of the binary_sensor itself
|
||||
state_payload = "ON" if online else "OFF"
|
||||
|
||||
Reference in New Issue
Block a user