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*.
|
||||
|
||||
Reference in New Issue
Block a user