Skip to content

Commit 9f3da11

Browse files
dcjclaude
andcommitted
examples/utility-meter: bench-rig fixes
Four fixes shaken out by running the example end-to-end against a local Mosquitto broker over mTLS: - homie.Unit.VOLT → homie.Unit.VOLTS (Unit enum uses VOLTS). - Replace internal _props.get_group(...).items() with the public _props.items(group) API. - Guard empty capability groups in _add_capability_node so a capability with no startup values (typical: doe) still publishes the Homie node without crashing. - Register a PROPERTY_ADDED observer on the meter's GroupedPropertyDict so properties added at runtime (e.g. doe values arriving via the HTTP endpoint after device startup) are mirrored to Homie. Existing PROPERTY_CHANGED path was already wired via add_property_on_change_callback. Also flips doe/power-import-limit datatype from FLOAT to INTEGER and coerces incoming watts to int in set_doe_import_limit, tracking the upstream eBus utility-meter data-model change (electrification-bus/specification#2). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 2304152 commit 9f3da11

1 file changed

Lines changed: 88 additions & 29 deletions

File tree

examples/utility-meter

Lines changed: 88 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,7 @@ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
4343
from typing import Any, Callable, Dict, Optional
4444

4545
from ebus_sdk import homie
46-
from ebus_sdk.property import GroupedPropertyDict, Property
46+
from ebus_sdk.property import ChangeEvent, GroupedPropertyDict, Property
4747

4848

4949
# ─── Defaults ─────────────────────────────────────────────────────────────────
@@ -163,18 +163,22 @@ class UtilityMeter:
163163

164164
def set_doe_import_limit(
165165
self,
166-
watts: Optional[float],
166+
watts: Optional[int],
167167
source: Optional[str] = None,
168168
valid_until: Optional[str] = None,
169169
):
170170
"""
171171
Update the DOE import-limit triplet atomically.
172172
173173
`watts=None` clears the limit (publishes an absent / empty value
174-
per the eBus interface spec). `source` is one of CONTRACT / REGULATOR
175-
/ EQUIPMENT / GRID / UNKNOWN. `valid_until` is ISO-8601 UTC or None.
174+
per the eBus interface spec). Non-None watts are coerced to int —
175+
the eBus utility-meter data model defines `power-import-limit` as
176+
integer Watts (non-negative; whole watts). `source` is one of
177+
CONTRACT / REGULATOR / EQUIPMENT / GRID / UNKNOWN. `valid_until`
178+
is ISO-8601 UTC or None.
176179
"""
177-
self._set_or_clear("doe", "power-import-limit", watts, float)
180+
watts = int(watts) if watts is not None else None
181+
self._set_or_clear("doe", "power-import-limit", watts, int)
178182
self._set_or_clear("doe", "power-import-limit-source", source, str)
179183
self._set_or_clear(
180184
"doe", "power-import-limit-valid-until", valid_until, str
@@ -270,7 +274,7 @@ HOMIE_PROPERTY_HINTS: Dict[str, Dict[str, Dict[str, Any]]] = {
270274
},
271275
"voltage-a": {
272276
"datatype": homie.PropertyDatatype.FLOAT,
273-
"unit": homie.Unit.VOLT,
277+
"unit": homie.Unit.VOLTS,
274278
},
275279
"current-a": {
276280
"datatype": homie.PropertyDatatype.FLOAT,
@@ -285,7 +289,7 @@ HOMIE_PROPERTY_HINTS: Dict[str, Dict[str, Dict[str, Any]]] = {
285289
},
286290
"doe": {
287291
"power-import-limit": {
288-
"datatype": homie.PropertyDatatype.FLOAT,
292+
"datatype": homie.PropertyDatatype.INTEGER,
289293
"unit": homie.Unit.WATT,
290294
},
291295
"power-import-limit-source": {
@@ -351,31 +355,79 @@ class UtilityMeterAdapter:
351355
node = self._homie_device.add_node_from_dict(
352356
{"id": node_id, "name": node_name, "type": node_type}
353357
)
354-
hints = HOMIE_PROPERTY_HINTS.get(meter_group, {})
355-
for prop_id, prop in self._meter._props.get_group(meter_group).items():
356-
hint = hints.get(prop_id, {})
357-
property_dict = {
358-
"id": prop_id,
359-
"value": prop.value(),
360-
}
361-
if "datatype" in hint:
362-
property_dict["datatype"] = hint["datatype"]
363-
else:
364-
inferred = homie.datatype_from_type(prop.type())
365-
if inferred:
366-
property_dict["datatype"] = inferred
367-
if "unit" in hint:
368-
property_dict["unit"] = hint["unit"]
369-
homie_property = node.add_property_from_dict(property_dict)
370-
self._meter.add_property_on_change_callback(
371-
meter_group,
372-
prop_id,
373-
partial(_set_homie_property_from_property, homie_property),
374-
)
358+
self._homie_nodes[meter_group] = node
359+
if not self._meter._props.has_group(meter_group):
360+
# Empty capability node — properties may be added at runtime
361+
# (e.g. doe values arriving via the HTTP endpoint). The
362+
# PROPERTY_ADDED observer registered below will mirror them
363+
# to Homie when they arrive.
364+
return node
365+
for prop_id, prop in self._meter._props.items(meter_group):
366+
self._add_homie_property(node, meter_group, prop_id, prop)
375367
return node
376368

369+
def _add_homie_property(
370+
self,
371+
node: homie.Node,
372+
meter_group: str,
373+
prop_id: str,
374+
prop: Property,
375+
):
376+
"""
377+
Add a single Homie property to `node`, mirroring the meter
378+
Property `prop`, and wire a change callback so subsequent
379+
set_value() calls on the meter property republish to Homie.
380+
Used both at startup (for properties seeded from config) and at
381+
runtime (for properties added by the PROPERTY_ADDED observer).
382+
"""
383+
if prop_id in self._homie_property_ids.get(meter_group, set()):
384+
return # already mirrored
385+
hint = HOMIE_PROPERTY_HINTS.get(meter_group, {}).get(prop_id, {})
386+
property_dict = {"id": prop_id, "value": prop.value()}
387+
if "datatype" in hint:
388+
property_dict["datatype"] = hint["datatype"]
389+
else:
390+
inferred = homie.datatype_from_type(prop.type())
391+
if inferred:
392+
property_dict["datatype"] = inferred
393+
if "unit" in hint:
394+
property_dict["unit"] = hint["unit"]
395+
homie_property = node.add_property_from_dict(property_dict)
396+
self._meter.add_property_on_change_callback(
397+
meter_group,
398+
prop_id,
399+
partial(_set_homie_property_from_property, homie_property),
400+
)
401+
self._homie_property_ids.setdefault(meter_group, set()).add(prop_id)
402+
403+
def _on_meter_property_event(
404+
self,
405+
event_type: ChangeEvent,
406+
group_name: str = None,
407+
property_id: str = None,
408+
property: Property = None,
409+
**kwargs,
410+
):
411+
"""
412+
Observer callback for the meter's GroupedPropertyDict. Mirrors
413+
runtime-added properties (e.g. doe values arriving via HTTP after
414+
startup) to Homie.
415+
"""
416+
if event_type != ChangeEvent.PROPERTY_ADDED:
417+
return
418+
node = self._homie_nodes.get(group_name)
419+
if node is None:
420+
self._logger.warning(
421+
"reason=runtimePropertyAddUnknownGroup,group=%s,propertyId=%s",
422+
group_name, property_id,
423+
)
424+
return
425+
self._add_homie_property(node, group_name, property_id, property)
426+
377427
def _create_device_and_nodes(self):
378428
self._homie_device = self._create_homie_device()
429+
self._homie_nodes: Dict[str, homie.Node] = {}
430+
self._homie_property_ids: Dict[str, set] = {}
379431
self._add_capability_node(
380432
"info", CAPABILITY_INFO, "Meter identity", "info"
381433
)
@@ -394,6 +446,13 @@ class UtilityMeterAdapter:
394446
"Utility-signalled operating envelope",
395447
"doe",
396448
)
449+
# Observe runtime property additions so values arriving after
450+
# device startup (typical for doe via the HTTP endpoint) are
451+
# mirrored onto Homie.
452+
self._meter._props.add_observer(
453+
self._on_meter_property_event,
454+
event_types=[ChangeEvent.PROPERTY_ADDED],
455+
)
397456

398457

399458
# ─── DOE HTTP endpoint ────────────────────────────────────────────────────────
@@ -428,7 +487,7 @@ class _DoeRequestHandler(BaseHTTPRequestHandler):
428487
# TODO: validate `source` is one of the enum values defined by
429488
# the eBus utility-meter data model's `doe/power-import-limit-source`;
430489
# validate `validUntil` parses as ISO-8601 UTC; validate `watts` is
431-
# a non-negative number (or null to clear).
490+
# a non-negative integer (or null to clear).
432491
try:
433492
self.meter.set_doe_import_limit(watts, source, valid_until)
434493
except Exception as exc:

0 commit comments

Comments
 (0)