Skip to content

Land panel-sim's wire layer as ebus_sdk.wire: declarative profiles, catalog hydration, device-graph building #47

Description

@dcj

Status: proposal, with a working spike
Relates to: #46 (independent), and internal tracking SDK-6do / SDK-6do.4 / SDK-03o

Why this doc exists

An earlier internal plan argued the generalization case from one body of evidence: the SPAN Gen2 adapter's spancommon.PropertyDefinition, 135 definitions across 15 lists, and what it would take to migrate it onto PropertySpec.

This is a second, independent body of evidence for the same question, arrived at from the opposite direction. ebus-panel-sim grew a wire/ layer that solves an overlapping problem in a different way, it has been in production across four PyPI releases, and it has now been extracted and proved portable. Neither analysis knew about the other's evidence: the July plan contains no mention of panel-sim, profiles, or the wire layer.

Two independent implementations converging on the same missing SDK capability is a stronger signal than either alone.

What was extracted, and what the spike proved

Eight modules, 1089 lines, lifted from ebus-panel-sim into a scratch package:

profile_loader   catalog hydration + per-phase pattern expansion
mapping_loader   placement descriptors (root-device vs child-of-parent, topic rendering)
graph_builder    manifest + mappings + profiles -> SDK Device tree
property_bag     PropertyBag + PropertyDiffer
publisher        per-tick diff-only publish loop
set_router       setter registry, coverage checking, per-datatype /set coercion
manifest         DeviceInstance / DeviceManifest
exceptions       one root + three leaves

Two measurements, both reproducible from the spike branches:

  • It stands alone. The extracted package builds a real device tree (7 profiles hydrated, 44 properties bound) with a sys.meta_path hook installed that raises on any ebus_panel_sim import. There is no hidden coupling.
  • Its origin still passes. ebus-panel-sim consuming the extracted package instead of owning the code: 165 passed, unchanged, plus ruff, ruff-format and mypy --strict clean, with no test file edited. src/ went 166 insertions, 899 deletions across 8 files, which became re-export shims.

The spike is local and unpublished; the distribution-enclosure-simulator side is on an unmerged branch that points at it by path. Both are reference material, not proposed changes.

How this maps onto work the SDK has already planned

This is the part that matters, and the reason this should not land as a parallel submodule dropped in beside the existing plan.

Extracted module Status in the SDK today
property_bag + publisher SDK-03o already proposes this, and names these exact files. Should be fulfilled as publish-on-change, not added alongside.
graph_builder Overlaps SDK-6do.4 (DeviceSpec + tree-aware incremental builder). Two independent solutions to multi-device materialization; reconcile rather than ship both.
manifest (DeviceInstance/DeviceManifest) Relates to the DeviceSpec shape SDK-6do.4 proposes. Same problem, different vocabulary.
set_router Partial overlap. The SDK owns /set subscription and payload decode; this adds a registry, per-datatype coercion, and coverage checking that fails at construction when a settable property has no handler.
profile_loader No SDK equivalent. See below.
mapping_loader No SDK equivalent.
exceptions The SDK has no exception hierarchy at all today. This would introduce the first.

profile_loader is the genuinely new capability

declaration.PropertySpec is the hand-written path: a caller states capability, prop_id, datatype, unit in Python, per property. profile_loader is the data-driven path: a profile selects capability properties by name, and the vendored spec capability catalogs supply datatype, unit, $format and settability.

The difference is not convenience. A selection that carries no datatype cannot drift from the specification by hand-copying one. That is the same class of guarantee .ebus-spec.json and the catalog-drift test provide at the repository level, pushed down to the property.

It also solves a gap the SDK has already conceded in writing. ha/customize.py:50:

# Power factor is unitless, so inference has nothing to go on. The table has
# no pattern support, so the per-phase forms are spelled out.
"power-factor":   {...},
"power-factor-a": {...},
"power-factor-b": {...},
"power-factor-c": {...},

profile_loader._expand_pattern reads property_patterns straight from the catalogs (voltage-{a,b,c}, current-{a,b,c,n}, and eight more in meter.json alone) and expands them. Demonstrated in the spike: a profile selecting voltage-a by name alone had datatype=float, unit=V hydrated from the catalog with nothing hand-written.

This capability is also the defining shape of the next simulator. A utility meter's whole model is per-phase suffixes; see utility-meter-simulator.

Proposed shape

ebus_sdk.wire, delivered as the extra ebus-sdk[wire].

This is not a new pattern for the SDK, it is the established one. ha/ is already a 1751-line non-normative integration submodule (larger than this at 1089), reachable only by explicit import and absent from ebus_sdk/__init__.py. mdns and validation are already optional extras, the latter documented as optional precisely "so a constrained build (e.g. Yocto) can omit it without a hard dependency".

Requirements that follow from those precedents:

  1. Not imported from ebus_sdk/__init__.py. import ebus_sdk must stay free of it, matching ha/. Consumers write from ebus_sdk.wire import build_graph.
  2. pyyaml guarded at its use site in mapping_loader, the way homie.py:71 guards jsonschema. Unlike jsonschema, it should raise with an install hint rather than degrade: without YAML there is no mapping table at all, so silent degradation would surface far downstream as "exactly one root-device descriptor", which is what an empty table looks like. That failure was observed during the spike.
  3. wire = ["pyyaml>=6.0"] in optional-dependencies, with the same rationale comment the other two carry.
  4. No module-relative default data directories. load_profiles and load_mapping_table must require their directories. Inside the SDK a module-relative default resolves into the SDK's own install, so a consumer that omitted it would load nothing. Each consumer vendors its own catalogs, which is what keeps their spec-reconciliation cadences independent.
  5. No vendor name in a type signature. overlay: str | None, not Literal["span", "reference"]. A shared layer must not export one panel vendor's name to every consumer that imports it.

The SPAN panel is unaffected

It installs plain ebus-sdk, never imports ebus_sdk.wire, and gains no dependency. The code ships in the wheel the way ha/ already does.

Three things the spike found that analysis had asserted wrongly

Worth recording, because each was believed the other way round before the code was actually moved:

  • make_property is not a no-op wrapper over Node.add_property_from_dict. Its conditional key omission is the profile-to-SDK adapter: an absent unit must be missing from the spec dict rather than present as None, or the device declares an attribute it does not have. Inlined into graph_builder rather than dropped.
  • MissingSetterError carries a structured missing payload (the offending (entity_class, property_path) pairs) that a naive rewrite silently dropped. mypy --strict caught it.
  • mapping_loader had the same module-relative-default hazard as profile_loader, and it bit immediately when panel-sim was wired up: 47 failures reading "mapping table must have exactly one root-device descriptor". This is requirement 4 above, learned rather than reasoned.

Open questions

  1. Reconcile or replace? graph_builder and SDK-6do.4's DeviceSpec builder solve the same problem from different directions: one walks a declarative manifest, the other materialises an externally-owned model incrementally with late-bound ids. The SPAN adapter needs the latter's incremental/late-binding behaviour, which graph_builder does not have. Likely answer: SDK-6do.4's builder is the general one, and graph_builder becomes a thin declarative front end onto it. That needs a real design pass, not a guess.
  2. Does PropertyDiffer survive SDK-03o? If publish-on-change lands on Property.set_value, the differ largely dissolves and publisher shrinks to a loop. Doing SDK-03o first may delete work rather than move it.
  3. Exception hierarchy. The SDK has none. Options: scope WireError to the submodule (minimal, matches ha/ self-containment), or introduce an SDK-wide root now and hang wire errors off it. The second is a larger public-surface decision that should not be made incidentally.
  4. Where do the wire tests live? They must move with the code. panel-sim's tests/wire/ plus test_wire_units.py test wire behaviour; one reaches into a private _to_sdk_unit, which the spike re-exported as an expedient.
  5. Does manifest belong at all, or is it subsumed by DeviceSpec? Two vocabularies for the same idea should not both ship.

Sequencing

  1. SDK-03o first. Cheapest, self-contained, and may delete property_bag/publisher before they are moved.
  2. Design pass on graph_builder vs SDK-6do.4. Do not land two multi-device builders. This is the real work.
  3. profile_loader + mapping_loader + set_router. These have no SDK counterpart and can land as ebus_sdk.wire largely as-is, with the five requirements above.
  4. panel-sim migrates, deleting 899 lines. The spike shows the diff and proves the suite stays at 165.
  5. utility-meter-simulator consumes it from the start, which is the second consumer that validates the API against something other than panel-sim.

python-sdk#46 (teardown) is independent and blocks none of this: the wire layer deliberately contains no lifecycle code.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions