@@ -43,7 +43,7 @@ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
4343from typing import Any , Callable , Dict , Optional
4444
4545from 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