M8-R15: fix HA discovery identities and thermal totals
This commit is contained in:
+44
-16
@@ -45,12 +45,15 @@ 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.
|
||||
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 identifiers for HA device grouping (e.g. ``("modbus", "<uuid>")``).
|
||||
"""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.
|
||||
@@ -59,6 +62,14 @@ class DeviceInfo:
|
||||
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.
|
||||
|
||||
@@ -85,6 +96,11 @@ class DeviceInfo:
|
||||
)
|
||||
"""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:
|
||||
@@ -276,8 +292,9 @@ def _modbus_provider(session: Session) -> list[ExposableEntity]:
|
||||
|
||||
for device in devices:
|
||||
device_info = DeviceInfo(
|
||||
identifiers=("modbus", device.uuid),
|
||||
identifiers=(f"home-automation:modbus:{device.uuid}",),
|
||||
name=device.friendly_name,
|
||||
identity=device.uuid,
|
||||
)
|
||||
|
||||
# Load the profile to get metric metadata.
|
||||
@@ -407,9 +424,10 @@ def _energy_cost_provider(session: Session) -> list[ExposableEntity]:
|
||||
|
||||
HA device identity (换表 → 新 sensor)
|
||||
--------------------------------------
|
||||
``identifiers[1]`` is set to the active meter's **uuid** (not the fixed
|
||||
string ``"energy-cost"``). ``ha_discovery.py`` uses ``identifiers[1]`` as
|
||||
the MQTT node_id and as part of the ``unique_id`` for every entity.
|
||||
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.
|
||||
|
||||
@@ -460,9 +478,9 @@ def _energy_cost_provider(session: Session) -> list[ExposableEntity]:
|
||||
|
||||
DeviceInfo identifiers
|
||||
----------------------
|
||||
**Two-element tuple** ``("energy-cost", meter.uuid)`` so that
|
||||
``ha_discovery.py``'s ``entity.device.identifiers[1]`` resolves to the
|
||||
meter uuid (used as the MQTT node_id and unique_id seed throughout).
|
||||
One-element namespaced tuple ``("home-automation:energy-cost:<uuid>",)``.
|
||||
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
|
||||
@@ -499,14 +517,15 @@ def _energy_cost_provider(session: Session) -> list[ExposableEntity]:
|
||||
currency = latest_period.currency
|
||||
|
||||
# --- Shared DeviceInfo anchored to the active meter's uuid ---
|
||||
# identifiers[1] = meter.uuid drives the MQTT node_id and unique_id in
|
||||
# ha_discovery.py. Swapping the meter produces a new uuid → new HA sensor.
|
||||
# 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=("energy-cost", active_meter.uuid),
|
||||
identifiers=(f"home-automation:energy-cost:{active_meter.uuid}",),
|
||||
name=active_meter.label,
|
||||
identity=active_meter.uuid,
|
||||
provides_availability=False,
|
||||
)
|
||||
|
||||
@@ -967,7 +986,8 @@ def _m8_energy_provider(session: Session) -> list[ExposableEntity]:
|
||||
|
||||
for source in sources:
|
||||
source_info = DeviceInfo(
|
||||
identifiers=("meter-source", source.uuid), name=source.name,
|
||||
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),
|
||||
)
|
||||
@@ -994,7 +1014,8 @@ def _m8_energy_provider(session: Session) -> list[ExposableEntity]:
|
||||
continue
|
||||
binding, channel, source = bound
|
||||
info = DeviceInfo(
|
||||
identifiers=("meter", meter.uuid), name=meter.label,
|
||||
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.
|
||||
@@ -1021,15 +1042,20 @@ def _m8_energy_provider(session: Session) -> list[ExposableEntity]:
|
||||
|
||||
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=("thermal-cost", identity), name="Thermal Energy Cost",
|
||||
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", "fixed": "Fixed", "all_in": "All-in",
|
||||
"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():
|
||||
@@ -1203,6 +1229,8 @@ def _thermal_cost_getter(metric: str, window: str | None) -> Callable[[Session],
|
||||
return result["breakdown"]["hot_water"]
|
||||
if metric == "water_tax":
|
||||
return result["breakdown"]["hot_water_tax"]
|
||||
if metric == "hot_water_total":
|
||||
return result["breakdown"]["hot_water_heating"] + result["breakdown"]["hot_water"]
|
||||
return result["breakdown"][metric]
|
||||
return _getter
|
||||
|
||||
|
||||
@@ -2,10 +2,14 @@ from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
from time import monotonic
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any
|
||||
from urllib import error, parse, request
|
||||
|
||||
from websockets.exceptions import WebSocketException
|
||||
from websockets.sync.client import connect
|
||||
|
||||
from app.config import Settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -57,6 +61,81 @@ class HomeAssistantClient:
|
||||
|
||||
self._post_json(f"/api/webhook/{webhook_id}", body, operation="trigger_webhook")
|
||||
|
||||
def discovery_registry_bindings(self, unique_ids: set[str]) -> dict[str, set[str]]:
|
||||
"""Return HA device identifiers currently bound to MQTT unique IDs.
|
||||
|
||||
This is deliberately a read-only WebSocket query. MQTT only confirms
|
||||
broker receipt; the entity/device registries are the authoritative HA
|
||||
observation that a discovery unload/re-add was actually processed.
|
||||
"""
|
||||
self._require_config()
|
||||
if not unique_ids:
|
||||
return {}
|
||||
try:
|
||||
deadline = monotonic() + self.timeout_seconds
|
||||
with connect(self._websocket_url(), open_timeout=self.timeout_seconds,
|
||||
close_timeout=self.timeout_seconds) as websocket:
|
||||
greeting = json.loads(self._websocket_recv(websocket, deadline))
|
||||
if greeting.get("type") != "auth_required":
|
||||
raise HomeAssistantRequestError("Unexpected Home Assistant WebSocket greeting")
|
||||
websocket.send(json.dumps({"type": "auth", "access_token": self.settings.home_assistant_auth_token}))
|
||||
auth = json.loads(self._websocket_recv(websocket, deadline))
|
||||
if auth.get("type") != "auth_ok":
|
||||
raise HomeAssistantRequestError("Home Assistant WebSocket authentication failed")
|
||||
entities = self._websocket_command(websocket, 1, "config/entity_registry/list", deadline)
|
||||
devices = self._websocket_command(websocket, 2, "config/device_registry/list", deadline)
|
||||
except (OSError, WebSocketException, TimeoutError, ValueError, KeyError, TypeError) as exc:
|
||||
raise HomeAssistantRequestError("Home Assistant registry query failed") from exc
|
||||
|
||||
devices_by_id = {
|
||||
device["id"]: {
|
||||
identifier[1]
|
||||
for identifier in device.get("identifiers", [])
|
||||
if (
|
||||
isinstance(identifier, (list, tuple))
|
||||
and len(identifier) == 2
|
||||
and identifier[0] == "mqtt"
|
||||
and isinstance(identifier[1], str)
|
||||
and identifier[1]
|
||||
)
|
||||
}
|
||||
for device in devices
|
||||
if isinstance(device, dict) and isinstance(device.get("id"), str)
|
||||
}
|
||||
return {
|
||||
entity["unique_id"]: devices_by_id.get(entity.get("device_id"), set())
|
||||
for entity in entities
|
||||
if entity.get("platform") == "mqtt" and entity.get("unique_id") in unique_ids
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _websocket_recv(websocket: Any, deadline: float) -> str:
|
||||
remaining = deadline - monotonic()
|
||||
if remaining <= 0:
|
||||
raise TimeoutError("Home Assistant WebSocket registry query timed out")
|
||||
return websocket.recv(timeout=remaining)
|
||||
|
||||
@classmethod
|
||||
def _websocket_command(
|
||||
cls, websocket: Any, message_id: int, command: str, deadline: float
|
||||
) -> list[dict[str, Any]]:
|
||||
websocket.send(json.dumps({"id": message_id, "type": command}))
|
||||
while True:
|
||||
response = json.loads(cls._websocket_recv(websocket, deadline))
|
||||
if response.get("id") != message_id:
|
||||
continue
|
||||
if not response.get("success"):
|
||||
raise HomeAssistantRequestError(f"Home Assistant WebSocket {command} failed")
|
||||
return response.get("result", [])
|
||||
|
||||
def _websocket_url(self) -> str:
|
||||
parsed = parse.urlsplit(self.settings.home_assistant_base_url)
|
||||
if parsed.scheme not in {"http", "https"} or not parsed.netloc:
|
||||
raise HomeAssistantConfigError("HOME_ASSISTANT_BASE_URL must be an HTTP(S) URL")
|
||||
scheme = "wss" if parsed.scheme == "https" else "ws"
|
||||
path = f"{parsed.path.rstrip('/')}/api/websocket"
|
||||
return parse.urlunsplit((scheme, parsed.netloc, path, "", ""))
|
||||
|
||||
def _require_config(self) -> None:
|
||||
if self.is_configured():
|
||||
return
|
||||
|
||||
@@ -174,23 +174,33 @@ class MqttManager:
|
||||
*,
|
||||
retain: bool = False,
|
||||
qos: int = 0,
|
||||
) -> None:
|
||||
) -> bool:
|
||||
"""Publish a message to *topic*.
|
||||
|
||||
If the client is not connected the call is silently skipped.
|
||||
Errors are logged but do not raise.
|
||||
Return whether paho accepted the message for publication. If the
|
||||
client is not connected, or paho raises/rejects it, return ``False``;
|
||||
errors still do not escape this best-effort boundary.
|
||||
"""
|
||||
with self._lock:
|
||||
client = self._client
|
||||
|
||||
if client is None or not self._connected:
|
||||
logger.debug("MQTT publish skipped — not connected (topic=%s).", topic)
|
||||
return
|
||||
return False
|
||||
|
||||
try:
|
||||
client.publish(topic, payload=payload, qos=qos, retain=retain)
|
||||
result = client.publish(topic, payload=payload, qos=qos, retain=retain)
|
||||
# paho returns MQTTMessageInfo with an integer rc. Keep fakes and
|
||||
# alternate clients compatible by treating an absent/non-int rc as
|
||||
# accepted after the call itself succeeded.
|
||||
rc = getattr(result, "rc", None)
|
||||
if isinstance(rc, int) and rc != mqtt.MQTT_ERR_SUCCESS:
|
||||
logger.warning("MQTT publish rejected (topic=%s, rc=%s).", topic, rc)
|
||||
return False
|
||||
return True
|
||||
except Exception:
|
||||
logger.exception("MQTT publish error (topic=%s).", topic)
|
||||
return False
|
||||
|
||||
def subscribe(self, topic: str, handler: Callable[[bytes], None]) -> None:
|
||||
"""Register *handler* to be called when a message arrives on *topic*.
|
||||
|
||||
+2
-1
@@ -38,7 +38,7 @@ from app.services.config_page import build_runtime_settings, seed_missing_config
|
||||
from app.services.dsmr_ingest import apply_dsmr_subscription
|
||||
from app.services.public_ip import check_public_ipv4_and_notify
|
||||
from app.services.modbus_poll import poll_all_enabled_devices, BASE_POLL_TICK_SECONDS
|
||||
from app.services.ha_discovery import publish_discovery, publish_states
|
||||
from app.services.ha_discovery import initialize_legacy_thermal_cleanup, publish_discovery, publish_states
|
||||
from app.services.tibber_prices import run_tibber_refresh_best_effort
|
||||
from app.services.energy_cost import compute_closed_periods
|
||||
from app.services.meter_cost import compute_closed_periods as compute_closed_meter_cost_periods
|
||||
@@ -203,6 +203,7 @@ def ensure_auth_db_ready() -> None:
|
||||
initialize_auth_schema(session, get_settings())
|
||||
seed_missing_config_from_bootstrap(session, get_settings())
|
||||
sync_app_hostname_from_bootstrap(session, get_settings())
|
||||
initialize_legacy_thermal_cleanup(session)
|
||||
except AppDatabaseAdoptionError as exc:
|
||||
raise RuntimeError(str(exc)) from exc
|
||||
except AuthBootstrapError as exc:
|
||||
|
||||
+448
-56
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user