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
+44 -16
View File
@@ -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
+79
View File
@@ -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
+15 -5
View File
@@ -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*.