M8-R15: fix HA discovery identities and thermal totals
frontend / frontend (push) Successful in 47s
pytest / test (push) Successful in 4m1s
docker-image / build-and-push (push) Successful in 1m38s

This commit is contained in:
2026-08-28 01:20:52 +02:00
parent 8180082f90
commit 018f13d73d
13 changed files with 1284 additions and 136 deletions
+448 -56
View File
@@ -34,16 +34,30 @@ from __future__ import annotations
import json
import logging
import re
from datetime import UTC, datetime
from typing import Any
from sqlalchemy.orm import Session
from sqlalchemy import inspect
from app.integrations.expose import ExposableEntity, build_catalog
from app.models.config import AppConfigEntry
from app.integrations.mqtt import mqtt_manager
from app.services.config_page import build_runtime_settings
logger = logging.getLogger(__name__)
_HA_SEGMENT_RE = re.compile(r"[^A-Za-z0-9_-]+")
# These durable, versioned application metadata entries prevent the minute
# publisher from repeatedly emitting already accepted known-bad v1.6.1 thermal
# topics. A failed publish is not acknowledged and remains retryable across
# restarts. They are intentionally application metadata, rather than broker
# state. An empty retained publish to an illegal v1.6.1 topic cannot be used
# as an acknowledgement: HA rejects that topic before it processes its payload.
_LEGACY_THERMAL_CLEANUP_KEY = "HA_DISCOVERY_LEGACY_THERMAL_CLEANUP_V1"
_REGISTRY_REPAIR_KEY = "HA_DISCOVERY_REGISTRY_REPAIR_V2"
# ---------------------------------------------------------------------------
# Internal helpers
@@ -59,14 +73,25 @@ def _should_publish(settings: Any) -> bool:
)
def _node_id(device_uuid: str) -> str:
"""Stable MQTT node_id derived from device uuid (replace hyphens for safety)."""
return device_uuid.replace("-", "_")
def _safe_segment(value: str) -> str:
"""Return a deterministic HA discovery topic segment.
Home Assistant accepts only ``[A-Za-z0-9_-]+`` for node/object ids.
Replacing each run of other characters keeps ordinary UUID/key output
readable while ensuring composite identities cannot produce invalid topics.
"""
result = _HA_SEGMENT_RE.sub("_", value).strip("_")
return result or "home_automation"
def _node_id(identity: str) -> str:
"""Stable, strictly valid MQTT node_id from an internal identity."""
return _safe_segment(identity.replace("-", "_"))
def _object_id(entity: ExposableEntity) -> str:
"""Stable MQTT object_id derived from entity key (dots + hyphens → underscores)."""
return entity.key.replace(".", "_").replace("-", "_")
"""Stable, strictly valid MQTT object_id derived from the entity key."""
return _safe_segment(entity.key.replace("-", "_"))
def _discovery_topic(entity: ExposableEntity, prefix: str) -> str:
@@ -74,7 +99,7 @@ def _discovery_topic(entity: ExposableEntity, prefix: str) -> str:
Format: ``<prefix>/<component>/<node_id>/<object_id>/config``
"""
node = _node_id(entity.device.identifiers[1]) # device uuid
node = _node_id(entity.device.internal_identity)
obj = _object_id(entity)
return f"{prefix}/{entity.component}/{node}/{obj}/config"
@@ -84,7 +109,7 @@ def _state_topic(entity: ExposableEntity, prefix: str) -> str:
Format: ``<prefix>/<component>/<node_id>/<object_id>/state``
"""
node = _node_id(entity.device.identifiers[1])
node = _node_id(entity.device.internal_identity)
obj = _object_id(entity)
return f"{prefix}/{entity.component}/{node}/{obj}/state"
@@ -104,16 +129,133 @@ def _availability_id(entity: ExposableEntity) -> str:
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]
return entity.device.availability_id or entity.device.internal_identity
def _unique_id(entity: ExposableEntity) -> str:
"""Stable unique_id — device uuid + metric key (never from mutable fields)."""
device_uuid = entity.device.identifiers[1]
device_uuid = entity.device.internal_identity
# entity.key is already "modbus.<uuid>.<metric_key>" — use it as the seed
return f"{device_uuid}_{entity.key.replace('.', '_')}"
def _migration_state(session: Session, key: str, default: str) -> str:
"""Read a versioned discovery-repair acknowledgement from ``app_config``."""
entry = session.query(AppConfigEntry).filter(AppConfigEntry.key == key).one_or_none()
return entry.value if entry is not None else default
def _has_repair_store(session: Session) -> bool:
"""Whether this session uses the app schema (not a lightweight unit DB)."""
return inspect(session.get_bind()).has_table("app_config")
def _set_migration_state(session: Session, key: str, value: str) -> None:
"""Durably acknowledge a completed discovery-repair step.
The state is operational metadata only; it never alters meters, readings,
contracts, costs, or expose toggles.
"""
entry = session.query(AppConfigEntry).filter(AppConfigEntry.key == key).one_or_none()
if entry is None:
session.add(AppConfigEntry(key=key, value=value, updated_at=datetime.now(UTC)))
else:
entry.value = value
entry.updated_at = datetime.now(UTC)
session.commit()
def _migration_json(session: Session, key: str) -> dict[str, Any]:
"""Read a versioned JSON migration ledger, treating corrupt values as pending."""
raw = _migration_state(session, key, "{}")
try:
value = json.loads(raw)
except json.JSONDecodeError:
logger.warning("Ignoring malformed HA discovery migration ledger %s", key)
return {}
return value if isinstance(value, dict) else {}
def _set_migration_json(session: Session, key: str, value: dict[str, Any]) -> None:
_set_migration_state(session, key, json.dumps(value, sort_keys=True))
def _frozen_legacy_inventory(ledger: dict[str, Any]) -> list[str] | None:
"""Return a structurally valid immutable cleanup inventory, if present.
The ledger predates a schema migration, so it must tolerate both an absent
entry and old ``topics``-only values. An inventory becomes immutable only
once it is a list of non-empty topic strings; anything else is compatibility
data to be frozen during startup, not state a publisher may reinterpret.
"""
value = ledger.get("inventory")
if not isinstance(value, list) or not all(isinstance(topic, str) and topic for topic in value):
return None
return list(dict.fromkeys(value))
def _publish_ok(topic: str, payload: str | bytes, *, retain: bool = True) -> bool:
"""Publish best-effort while treating only an explicit ``False`` as failure.
Production ``MqttManager.publish`` returns ``bool``. Accepting ``None``
keeps existing third-party/mock publishers best-effort compatible.
"""
return mqtt_manager.publish(topic, payload, retain=retain) is not False
def initialize_legacy_thermal_cleanup(session: Session) -> None:
"""Create the legacy-cleanup ledger before expose toggles can be edited.
A newly installed database has no v1.6.1 retained thermal topics. Marking
that case complete during startup prevents a later first-time toggle from
manufacturing an old, illegal topic. Conversely, an upgraded database
freezes its exact enabled legacy topics before the UI can change a toggle.
"""
if not _has_repair_store(session):
return
ledger = _migration_json(session, _LEGACY_THERMAL_CLEANUP_KEY)
if ledger.get("complete") is True:
return
inventory = _frozen_legacy_inventory(ledger)
if inventory is not None:
# A previously frozen non-empty inventory must never be expanded or
# recomputed from mutable toggle/prefix state. A zero-item inventory
# is just the fresh-install terminal state written by an older build.
if not inventory:
_set_migration_json(
session,
_LEGACY_THERMAL_CLEANUP_KEY,
{"complete": True, "inventory": [], "topics": []},
)
return
# This deliberately propagates. Startup runs before the expose UI can
# write a toggle, so swallowing an enumeration or durable-write error would
# create a window in which the cleanup scope could be changed or lost.
legacy_entities = _legacy_thermal_entities(session)
from app.config import get_settings
settings = build_runtime_settings(session, get_settings())
inventory = list(dict.fromkeys(
_legacy_discovery_topic(entity, settings.ha_discovery_prefix)
for entity in legacy_entities
))
_set_migration_json(
session,
_LEGACY_THERMAL_CLEANUP_KEY,
{
"complete": not inventory,
"inventory": inventory,
"topics": sorted({
topic for topic in ledger.get("topics", [])
if isinstance(topic, str) and topic in inventory
}),
},
)
# ---------------------------------------------------------------------------
# Public: build discovery payload
# ---------------------------------------------------------------------------
@@ -220,34 +362,90 @@ 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:
# Clear the *illegal* v1.6.1 thermal keys once. Startup freezes the exact
# inventory while the legacy toggle state still describes v1.6.1. Thus a
# later UI toggle/prefix/meter change cannot erase or expand this repair.
# ``topics`` is only durable success progress; ``inventory`` is immutable.
# Startup freezes compatibility ledgers before the UI can mutate the
# toggle/prefix inputs. The publisher only consumes that frozen snapshot.
repaired_keys: set[str] = set()
if _has_repair_store(session):
ledger = _migration_json(session, _LEGACY_THERMAL_CLEANUP_KEY)
if not ledger.get("complete"):
inventory = _frozen_legacy_inventory(ledger)
if inventory is None:
logger.error(
"publish_discovery: legacy cleanup ledger was not frozen at startup; refusing cleanup"
)
if inventory is not None:
completed = {
topic for topic in ledger.get("topics", [])
if isinstance(topic, str) and topic in inventory
}
for topic in (topic for topic in inventory if topic not in completed):
try:
if _publish_ok(topic, b""):
completed.add(topic)
# Commit each accepted illegal-topic tombstone before
# attempting another one: a crash must not replay it.
_set_migration_json(
session,
_LEGACY_THERMAL_CLEANUP_KEY,
{
"complete": False,
"inventory": inventory,
"topics": sorted(completed),
},
)
else:
logger.warning("publish_discovery: broker rejected v1.6.1 cleanup for %s", topic)
except Exception:
logger.exception("publish_discovery: unable to clear v1.6.1 topic %s", topic)
if set(inventory).issubset(completed):
_set_migration_json(
session,
_LEGACY_THERMAL_CLEANUP_KEY,
{"complete": True, "inventory": inventory, "topics": sorted(completed)},
)
# Current-format stale topics are legal and deliberately remain retryable:
# they cover post-repair meter swaps and include all 14 thermal metrics.
try:
old_topic, _ = build_discovery_payload(old_entity, discovery_prefix, state_prefix)
mqtt_manager.publish(old_topic, b"", retain=True)
stale_entities = _stale_m8_entities(session)
except Exception:
logger.exception("publish_discovery: unable to clear old identity %r", old_entity.key)
logger.exception("publish_discovery: unable to enumerate stale M8 identities")
stale_entities = []
for old_entity in stale_entities:
try:
if not _publish_ok(_discovery_topic(old_entity, discovery_prefix), b""):
logger.warning("publish_discovery: broker rejected stale cleanup for %r", old_entity.key)
except Exception:
logger.exception("publish_discovery: unable to clear stale identity %r", old_entity.key)
# v1.6.1 used the same legal topics/unique_ids for Meter, Source, Modbus
# and electricity-cost entities, but attached their registry entries to
# wrongly merged HA devices. HA does not move those entries when only a
# discovery ``device`` block changes. One durable unload -> re-add cycle
# lets HA rebuild the existing unique_id entries against the new singleton
# identifiers, preserving entity keys, toggles and user customizations.
repaired_keys = _run_registry_repair(session, catalog, discovery_prefix, state_prefix, settings)
for entry in catalog:
entity = entry.entity
if entity.key in repaired_keys:
continue
try:
topic, config = build_discovery_payload(entity, discovery_prefix, state_prefix)
if entry.enabled:
payload = json.dumps(config)
mqtt_manager.publish(topic, payload, retain=True)
_publish_ok(topic, payload)
logger.debug(
"publish_discovery: published config for %r%s", entity.key, topic
)
else:
# Clear retained config for disabled entities.
mqtt_manager.publish(topic, b"", retain=True)
_publish_ok(topic, b"")
logger.debug(
"publish_discovery: cleared config for disabled entity %r%s",
entity.key,
@@ -259,39 +457,163 @@ def publish_discovery(session: Session) -> None:
)
def _stale_m8_entities(session: Session) -> list[ExposableEntity]:
"""Return synthetic discovery entries for superseded thermal identities.
def _is_registry_repair_entity(entity: ExposableEntity) -> bool:
"""Whether a legal v1.6.1 config needs an unload/re-add device split."""
return entity.key.startswith(("modbus.", "source.", "meter.", "energy.", "thermal_cost."))
This is deliberately a narrow, best-effort cleanup: ended Meter UUIDs and
historically possible thermal combinations only; current identities are excluded.
def _ha_registry_bindings(settings: Any, unique_ids: set[str]) -> dict[str, set[str]] | None:
"""Read HA's registry, returning ``None`` when it is not observable."""
from app.integrations.homeassistant import (
HomeAssistantClient,
HomeAssistantConfigError,
HomeAssistantRequestError,
)
# Runtime settings from old callers/tests may not expose outbound HA
# fields. Treat absent/non-string credentials as intentionally optional.
if not isinstance(getattr(settings, "home_assistant_base_url", None), str) or not isinstance(
getattr(settings, "home_assistant_auth_token", None), str
):
return None
client = HomeAssistantClient(settings)
if not client.is_configured():
return None
try:
return client.discovery_registry_bindings(unique_ids)
except (HomeAssistantConfigError, HomeAssistantRequestError, TypeError, ValueError):
logger.warning("HA registry repair remains pending: HA registry is unavailable")
return None
def _run_registry_repair(
session: Session,
catalog: list[Any],
discovery_prefix: str,
state_prefix: str,
settings: Any,
) -> set[str]:
"""Repair each v1.6.1 entity only after HA confirms every phase.
MQTT acknowledges broker receipt, not Home Assistant processing. The
ledger therefore holds each target at ``unloaded`` until HA's entity
registry no longer contains its stable unique_id, then at ``republished``
until HA reports the expected singleton device identifier. A partial
catalog simply adds targets on a later run; it cannot complete others.
When HA's registry is unavailable, ordinary discovery remains untouched.
"""
from app.integrations.expose import DeviceInfo
targets = [entry for entry in catalog if _is_registry_repair_entity(entry.entity)]
if not targets:
return set()
ledger = _migration_json(session, _REGISTRY_REPAIR_KEY)
pending_targets = [
entry for entry in targets if ledger.get(_unique_id(entry.entity), {}).get("phase") != "complete"
]
if not pending_targets:
return set()
unique_ids = {_unique_id(entry.entity) for entry in pending_targets}
bindings = _ha_registry_bindings(settings, unique_ids)
if bindings is None:
return set()
blocked: set[str] = set()
for entry in pending_targets:
entity = entry.entity
unique_id = _unique_id(entity)
expected = set(entity.device.identifiers)
phase = ledger.get(unique_id, {}).get("phase", "pending")
observed = bindings.get(unique_id)
if phase == "unloaded":
if observed is not None:
# HA was disconnected or otherwise missed the retained
# tombstone. Keep it retained until HA itself confirms delete.
try:
_publish_ok(_discovery_topic(entity, discovery_prefix), b"")
except Exception:
logger.exception("publish_discovery: unable to repeat registry unload for %r", entity.key)
blocked.add(entity.key)
continue
if not entry.enabled:
_set_registry_phase(session, ledger, unique_id, "complete", expected)
continue
_publish_registry_config(session, ledger, entry, discovery_prefix, state_prefix, expected, blocked)
elif phase == "republished":
if observed == expected:
_set_registry_phase(session, ledger, unique_id, "complete", expected)
elif observed is None and entry.enabled:
_publish_registry_config(session, ledger, entry, discovery_prefix, state_prefix, expected, blocked)
else:
# A stale/wrong device binding must pass through deletion again.
try:
if _publish_ok(_discovery_topic(entity, discovery_prefix), b""):
_set_registry_phase(session, ledger, unique_id, "unloaded", expected)
except Exception:
logger.exception("publish_discovery: unable to unload wrong registry entity %r", entity.key)
blocked.add(entity.key)
elif phase != "complete":
if observed == expected or (observed is None and not entry.enabled):
_set_registry_phase(session, ledger, unique_id, "complete", expected)
elif observed is None:
_publish_registry_config(session, ledger, entry, discovery_prefix, state_prefix, expected, blocked)
else:
try:
if _publish_ok(_discovery_topic(entity, discovery_prefix), b""):
_set_registry_phase(session, ledger, unique_id, "unloaded", expected)
blocked.add(entity.key)
else:
logger.warning("publish_discovery: broker rejected registry unload for %r", entity.key)
except Exception:
logger.exception("publish_discovery: unable to unload registry entity %r", entity.key)
return blocked
def _set_registry_phase(
session: Session, ledger: dict[str, Any], unique_id: str, phase: str, expected: set[str]
) -> None:
ledger[unique_id] = {"phase": phase, "identifier": sorted(expected)}
_set_migration_json(session, _REGISTRY_REPAIR_KEY, ledger)
def _publish_registry_config(
session: Session, ledger: dict[str, Any], entry: Any, discovery_prefix: str,
state_prefix: str, expected: set[str], blocked: set[str],
) -> None:
entity = entry.entity
topic, config = build_discovery_payload(entity, discovery_prefix, state_prefix)
try:
if _publish_ok(topic, json.dumps(config)):
_set_registry_phase(session, ledger, _unique_id(entity), "republished", expected)
else:
logger.warning("publish_discovery: broker rejected registry re-add for %r", entity.key)
except Exception:
logger.exception("publish_discovery: unable to re-add registry entity %r", entity.key)
blocked.add(entity.key)
def _legacy_discovery_topic(entity: ExposableEntity, prefix: str) -> str:
"""Return the exact v1.6.1 config topic for a known stale M8 entity.
This intentionally preserves the old hyphen-only conversion because the
point is to remove that exact retained broker key once, never to publish a
wildcard or manufacture a new invalid topic.
"""
node = entity.device.internal_identity.replace("-", "_")
obj = entity.key.replace(".", "_").replace("-", "_")
return f"{prefix}/{entity.component}/{node}/{obj}/config"
def _overlapping_thermal_pairs(session: Session) -> list[tuple[Any, Any]]:
"""Return only heating/water Meter epochs that could have coexisted."""
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
)
pairs: list[tuple[Any, Any]] = []
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
@@ -302,16 +624,86 @@ def _stale_m8_entities(session: Session) -> list[ExposableEntity]:
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",
))
pairs.append((heating, water))
return pairs
def _thermal_cleanup_entities(
pairs: list[tuple[Any, Any]], *, include_hot_water_total: bool
) -> list[ExposableEntity]:
"""Build exact synthetic config entries for known thermal identities."""
from app.integrations.expose import DeviceInfo
metrics = ["heating", "hot_water_heating", "water", "water_tax", "fixed", "all_in"]
if include_hot_water_total:
metrics.insert(2, "hot_water_total")
entities: list[ExposableEntity] = []
for heating, water in pairs:
identity = ".".join(sorted((heating.uuid, water.uuid)))
info = DeviceInfo(
identifiers=(f"home-automation:thermal-cost:{identity}",),
name="obsolete",
identity=identity,
)
for metric in metrics:
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
def _legacy_thermal_entities(session: Session) -> list[ExposableEntity]:
"""Return only v1.6.1 illegal topics that could retain a non-empty config.
v1.6.1 published an illegal thermal config only while its expose toggle was
enabled. A disabled toggle published its own tombstone, so querying the
durable toggle state prevents a fresh install from manufacturing warnings
for twelve never-used illegal topics.
"""
from app.models.expose import ExposedEntityToggle
entities = _thermal_cleanup_entities(_overlapping_thermal_pairs(session), include_hot_water_total=False)
keys = [entity.key for entity in entities]
enabled_keys = {
row.key for row in session.query(ExposedEntityToggle).filter(
ExposedEntityToggle.key.in_(keys), ExposedEntityToggle.enabled.is_(True)
)
}
return [entity for entity in entities if entity.key in enabled_keys]
def _stale_m8_entities(session: Session) -> list[ExposableEntity]:
"""Return *safe-format* configs that became stale after a later epoch swap.
Unlike the v1.6.1 cleanup, this intentionally excludes the active thermal
pair and includes the post-repair ``hot_water_total`` metric (14 configs
per stale pair). All generated topics use ``_discovery_topic``.
"""
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()
entities: list[ExposableEntity] = []
for meter in (meter for meter in meters if meter.ended_at is not None):
info = DeviceInfo(
identifiers=(f"home-automation:meter:{meter.uuid}",), name=meter.label, identity=meter.uuid
)
for suffix in ("total", "today"):
entities.append(ExposableEntity(
key=f"meter.{meter.uuid}.{suffix}", component="sensor", device=info,
device_class=None, unit="", name="obsolete",
))
stale_pairs = [
(heating, water)
for heating, water in _overlapping_thermal_pairs(session)
if heating.ended_at is not None or water.ended_at is not None
]
entities.extend(_thermal_cleanup_entities(stale_pairs, include_hot_water_total=True))
return entities
@@ -468,7 +860,7 @@ def publish_device_state(session: Session, device: Any) -> None:
continue
entity = entry.entity
# Only process entities belonging to this device.
if entity.device.identifiers[1] != device_uuid:
if entity.device.internal_identity != device_uuid:
continue
# Skip the online binary_sensor itself (availability handled above).
if entity.component == "binary_sensor" and "online" in entity.key:
@@ -543,7 +935,7 @@ def clear_device_discovery(session: Session, device_uuid: str) -> None:
for entry in catalog:
entity = entry.entity
# Only clear entities belonging to this device.
if entity.device.identifiers[1] != device_uuid:
if entity.device.internal_identity != device_uuid:
continue
try:
topic, _config = build_discovery_payload(entity, discovery_prefix, state_prefix)