Found while adopting .ebus-spec.json for our own publisher, so the provenance convention
earned its keep on the first outing.
The bug
_capability_of derives the lookup key from the node $type:
marker = ".capability."
if marker in node_type:
return node_type.rsplit(marker, 1)[-1]
return ctx.node_id or None # fallback: node id
The _EBUS_DEFAULTS table is keyed "meter", "battery", "info". Two of those are
capability names. battery is not — the specification has no battery capability. State
of charge lives in capabilities/soc.json (energy.ebus.capability.soc, v0.1), whose
properties are soc, soe, total-energy-storage, loadup-headroom.
So for a device publishing a node typed energy.ebus.capability.soc, the key is soc, the
lookup misses, and the entry never fires. It is reachable only through the node-id fallback —
a node whose $type is absent or non-eBus and whose id is literally battery.
The reference publisher does not produce that. A BESS profile publishes:
"soc": { "type": "energy.ebus.capability.soc",
"properties": { "soc": {"name": "State of charge"},
"soe": {"name": "State of energy", "unit": "kWh"} } }
The consequence is exactly what the entry's own comment says it exists to prevent:
# A bare percent is ambiguous to inference; here it is unmistakably SoC.
"soc": {"device_class": "battery", "state_class": "measurement"},
soc is a bare %, generic inference deliberately leaves a bare % unresolved, and the
override that would resolve it does not run. A battery SoC reaches Home Assistant with no
device_class.
The fix
Rename the key battery → soc. One line.
Worth checking the property set in the same pass, since it does not match the catalog either:
the table carries soc, state-of-charge, power, temperature, while soc v0.1 defines
soc, soe, total-energy-storage, loadup-headroom. soe is a kWh energy figure with no
entry; state-of-charge, power and temperature are not soc properties. Whether those
belong under other capabilities is your call — we did not want to guess.
The general shape, since this is the interesting part
This is table-versus-catalog drift, and the SDK's lockfile shows the same picture
independently. Running your own tool:
$ python3 tools/drift-report.py --manifest spec-manifest.json ../python-sdk/.ebus-spec.json
python-sdk/.ebus-spec.json [role=library, synced=9be790ba 2026-07-11]
BEHIND framework: pinned 0.5, current 0.7
BEHIND capabilities/info: pinned 0.1, current 0.2
BEHIND capabilities/meter: pinned 0.1, current 0.2
BEHIND devices/utility-meter: pinned 0.3, current 0.6
BEHIND registries/capability-types: pinned 0.11, current 0.19
(The two framework features not yet supported lines the tool also emits are the deliberate
exclusions your notes already document — broker-hosting and rest-configuration — so
they are not findings.)
customize.py encodes capability facts but is not covered by any of those pins — the
implements map reflects examples/utility-meter, as your notes say, and soc does not
appear in it at all. So nothing in CI compares that table against the catalogs it restates,
which is why a key can stop matching without anything failing.
Happy to do any of this
We are adopting the provenance convention for our own simulator this week, so the technique is
loaded either way:
- The one-line fix plus a regression test that drives a node typed
energy.ebus.capability.soc through the override and asserts device_class: battery comes
out. Small, and it pins the behaviour rather than the spelling.
- The lockfile re-sync — bump the pins above, refresh
synced_commit. Mechanical.
- A content check.
panel-sim's test_catalog_drift.py already does the harder half of
this (lockfile-vs-catalog versions always, vendored-vs-source content when a
../specification checkout is present). The same pattern applied to _EBUS_DEFAULTS —
assert every key names a real capability and every property exists in that capability's
catalog — would have caught this at authoring time, and would keep catching it. It works
regardless of how you decide the wider question of where capability data should live.
Say which, or none. We would rather offer than just report.
Found while adopting
.ebus-spec.jsonfor our own publisher, so the provenance conventionearned its keep on the first outing.
The bug
_capability_ofderives the lookup key from the node$type:The
_EBUS_DEFAULTStable is keyed"meter","battery","info". Two of those arecapability names.
batteryis not — the specification has nobatterycapability. Stateof charge lives in
capabilities/soc.json(energy.ebus.capability.soc, v0.1), whoseproperties are
soc,soe,total-energy-storage,loadup-headroom.So for a device publishing a node typed
energy.ebus.capability.soc, the key issoc, thelookup misses, and the entry never fires. It is reachable only through the node-id fallback —
a node whose
$typeis absent or non-eBus and whose id is literallybattery.The reference publisher does not produce that. A BESS profile publishes:
The consequence is exactly what the entry's own comment says it exists to prevent:
socis a bare%, generic inference deliberately leaves a bare%unresolved, and theoverride that would resolve it does not run. A battery SoC reaches Home Assistant with no
device_class.The fix
Rename the key
battery→soc. One line.Worth checking the property set in the same pass, since it does not match the catalog either:
the table carries
soc,state-of-charge,power,temperature, whilesocv0.1 definessoc,soe,total-energy-storage,loadup-headroom.soeis a kWh energy figure with noentry;
state-of-charge,powerandtemperatureare notsocproperties. Whether thosebelong under other capabilities is your call — we did not want to guess.
The general shape, since this is the interesting part
This is table-versus-catalog drift, and the SDK's lockfile shows the same picture
independently. Running your own tool:
(The two
framework features not yet supportedlines the tool also emits are the deliberateexclusions your
notesalready document —broker-hostingandrest-configuration— sothey are not findings.)
customize.pyencodes capability facts but is not covered by any of those pins — theimplementsmap reflectsexamples/utility-meter, as your notes say, andsocdoes notappear in it at all. So nothing in CI compares that table against the catalogs it restates,
which is why a key can stop matching without anything failing.
Happy to do any of this
We are adopting the provenance convention for our own simulator this week, so the technique is
loaded either way:
energy.ebus.capability.socthrough the override and assertsdevice_class: batterycomesout. Small, and it pins the behaviour rather than the spelling.
synced_commit. Mechanical.panel-sim'stest_catalog_drift.pyalready does the harder half ofthis (lockfile-vs-catalog versions always, vendored-vs-source content when a
../specificationcheckout is present). The same pattern applied to_EBUS_DEFAULTS—assert every key names a real capability and every property exists in that capability's
catalog — would have caught this at authoring time, and would keep catching it. It works
regardless of how you decide the wider question of where capability data should live.
Say which, or none. We would rather offer than just report.