Repository navigation
Release 2.6.0 — SPAN firmware release 202639 - #24
Merged
Merged
Conversation
…t ebus-panel-sim brings The next ebus-panel-sim changes four things a PanelBench panel publishes, and each is now pinned by a test: - the built-in shed/asserted-islanding-state handler ignores an assertion while every battery link is healthy; - the BESS meter/active-power sign and the EVSE config/user-max-charge-current preset follow the panel's firmware-version either side of SPAN release 202639; - a panel without a battery publishes no shed or shed-forecast node. Shared clone-and-capture helpers live in tests/_helpers.py for later tests. golden_wire.json is re-recorded with scripts/capture-wire.py. Beyond noisy power values and $description version timestamps, both EVSEs drop config/user-max-charge-current and declare its $format as 6:32, and the BESS meter is now the negative of power-flows/battery. golden_tree.json is derived from that wire capture, by feeding its $description documents through check-conformance.py --from-stdin, rather than from a broker session, as 2dc76f1 did; golden_report.json is regenerated from it. The tree moves only by the two EVSE $format values, and the report's counts, observations, violations and omissions are unchanged, since `config` is not a specification capability. The unvalued-by-both baseline gains both EVSEs' config/user-max-charge-current. The upstream CHANGELOG states it: "`config/user-max-charge-current` is unpublished until a user sets it." Both producers read the config's sim/v0.1.0, which names no release, so both publish the current convention.
…lt firmware on HTTP and mDNS
…nd type the validators
The config's hardware_version is published as info/hardware-version and, from release 202639, reported as the status endpoint's hardwareVersion (1.2 or 2.0, else UNKNOWN). The shipped MAIN 32 config names 1.2, and a clone keeps the source panel's value. The Homie schema endpoint now reports the panel's own firmwareVersion rather than the bundled document's.
Every Home Assistant SPAN integration released before r202639 support negates the BESS meter unconditionally, so a default publishing the r202639 frame shows its users an inverted battery. The three shipped configs now name spanos3/r202633/02, the string the public MAIN 32 fixture in ebus-panel-sim records for that release, and publish the earlier conventions. Setting firmware_version to a 202639 string, such as spanos3/r202639/03, emulates the new firmware. A test pins every shipped config: it predates 202639, its BESS meter equals power-flows/battery while discharging, and every EVSE presets its user limit at its maximum. The night-panel battery helpers move to tests/_helpers.py so the conventions tests and this one share them. The commissioned-system tests now state r202639, since SPAN locks those circuits only from that release. Fixture movements, all from MAIN_40: - golden_wire.json, re-captured with scripts/capture-wire.py: info/firmware-version moves from sim/v0.1.0 to spanos3/r202633/02; both SPAN Drives publish config/user-max-charge-current 32 again; the BESS meter/active-power equals power-flows/battery (the panel's frame). Other value changes are capture noise. - golden_tree.json, derived from that capture through check-conformance.py --from-stdin: only the version stamps move, as no $description changed. - golden_report.json, regenerated with --capture --json: unchanged. - unvalued_by_both_baseline.json: the two SPAN Drive config/user-max-charge-current lines leave, because both producers now value the property at release 202633. This reverses the lines added when the defaults named no release, as the test's docstring predicted; the docstring now records them as lines that left and return if the config names 202639 or later. Two expected moves did not happen. The EVSE $format for user-max-charge-current stays 6:32, because ebus-panel-sim 0.9.0 derives it from the EVSE's maximum current whatever the firmware. identity_value_baseline.json is unchanged, because its sim/v0.1.0 entries are the EVSEs' own firmware default, not the panel's.
…rmware The shipped-config tests globbed configs/*.yaml, which also matches the user clones .gitignore keeps locally beside the templates; a clone of an r202639 panel, or one without a battery, would fail them spuriously. Both now take default_*.yaml, the dashboard's rule for a shipped template. The README now says where to set firmware_version: templates are read-only and a clone copies their string, so the emulation is set in a clone's YAML.
…elease 202639 Vendor the pinned release's masked capture of a MAIN 32 on spanos3/r202639/03 and add it as the captured parity cell, with checks that the import keeps the panel's release, its commissioned-system circuit and its inverter. Real firmware names a battery, its MID and an inverter after their own device ids, so the comparator now keys those by where they hang (the feeding circuit's spaces, the lugs an upstream battery feeds, the MID's battery) rather than by name, and refuses a device whose place cannot be resolved. The unvalued and device-id baselines are re-keyed to match; every cell is at full structural and settable parity.
An EVSE joins the battery, its MID and the inverter in being keyed by where it hangs rather than by name, since firmware that names a Drive after its own device id would otherwise misalign it. The example cell's two Drives re-key to their circuits' spaces in the unvalued and device-id baselines; an unfed Drive is refused.
…t, export and the rebuilt fidelity suite
An upgraded add-on kept the default_*.yaml copies an earlier release seeded, so a template that now names SPAN release 202633 went on publishing whatever the old copy named. The templates are read-only in the dashboard, so run.sh now refreshes any that differ from the release's copy and logs each refresh; every other file in the config directory is left alone.
…ith 400 A body that was not JSON, or was JSON but not an object, raised out of the handler and came back as a 500.
… rehearsal A load circuit switched to producer kept the load's power range and shape. The rehearsal now says to copy the PV template or set a nameplate and producer range, stops saying nothing else changes, names the Update eBus Energy button the step relied on, and drops a certificate-trust clause that was not accurate.
…nel publishes A commissioned circuit is now recognised by its two locks and what it feeds: a doubly locked circuit at NEVER feeding an inverter is the PV system, feeding a battery the backup system. SPAN's names for these circuits decide only for a circuit that publishes no connection, since a user may rename them. The panel size comes from a published info/panel-size, else the numeric suffix of info/model, as upstream's capture reads it. Only without either, or when the circuits would not fit, does the highest occupied space decide, which undercounted any panel whose top spaces are empty. The clone copies the panel's status/time-zone and its line voltages, the legs' mean as the per-leg voltage and twice it as the service voltage. The postal code is not copied. The lookup for the circuit feeding an inverter or SPAN Drive reads only this panel's circuits, so another panel's tree on the same broker cannot take its nameplate or identity. A breaker rating the panel does not publish is logged when the default replaces it.
…nfigs Every clone, sync and restore registered a new sim-clone-<uuid4> client on the real panel, which its owner had to remove by hand. scrape_panel now reads the panel's serial from its status endpoint, registers once as panelbench-clone-<serial>, keeps the broker credentials that returns and reuses them, registering again under the same name only when the broker refuses them. The clone form also takes broker credentials the user already holds (serial, username, password, port, and a CA that is fetched from the panel when left blank), and then registers nothing. The passphrase was written into the clone's panel_source, so it went into every export and showed in a text field. It and the broker credentials now live in a PanelSecretsStore keyed by origin serial: a 0600 JSON file in <config dir>/.secrets (gitignored), or SECRETS_DIR, which the add-on sets to /data/secrets. The form asks for it in a password field and never sends it back. A config that carries one gives it up on the next scan of the config directory or when the dashboard loads it, and the dashboard never holds it, so no export or save writes it again. A restore or replay toggle that has to re-scrape the source panel used to swallow any failure and leave the circuit unchanged without a word; it now shows why, as a sync does. A clone keeps the time zone the panel publishes rather than one derived from Home Assistant's home location.
…hind An operation that timed out on a socket the broker still held open was reported as an outage but never acted on: the reader saw nothing wrong until paho's keepalive gave up, up to two minutes later, so the link stayed on a dead session, the queue filled to its bound and the SDK logged a warning per property per tick. A frozen broker reproduced it. A failed operation now fails its session. The session is taken down at once, so what is queued behind it fails fast, and its read deadline is brought forward to now, so the supervisor leaves the client, reconnects, replays the subscriptions and republishes the tree. The restore runs under the same deadline and its subscriptions are operations of the session, so a failure cuts it short too. Only an expired deadline turns into TimeoutError; disconnect's cancellation stays a cancellation. A connect that disconnect cancels is shielded and left to finish, and a client it connected is then closed, so no orphan keeps the panel's client id and will. A link opens once. A SUBACK refusal is logged. A connection that only dropped is reported as dropped rather than as paho's "Unspecified error". _dispatch's delivery to every matching filter is documented as differing from the SDK's client. A panel whose first tick fails stops its runtime, and start_clone's cleanup now covers emitter.start(), so neither leaves a link reconnecting under the panel's client id. A config whose serial is already running is refused before it connects, naming the config that runs it, where both used to run and take the client id from each other.
Nothing in CI ran the tests: lint.yaml runs pre-commit, whose mypy hook covers src/panelbench only. The new Tests workflow syncs the locked environment with uv, installs mosquitto from apt so the real-broker tests run, and sets PANELBENCH_REQUIRE_MOSQUITTO so a missing broker fails the job rather than skipping those tests. lint.yaml now sets up Python 3.14, matching requires-python.
A live clone failed because the scrape dialled the broker host the panel advertises, its span-….local mDNS name, which does not resolve across subnets; it never connected, and was reported as a panel that had published none of its circuits. Every scrape path, registered, stored and supplied credentials alike, now dials the host the user gave, with TLS still verified against the panel's CA, as the Home Assistant integration connects. The stored credentials no longer keep a broker host at all. Discovery runs a tree-rooted Controller on the same BrokerLink and transport a simulated panel publishes through (both gain unsubscribe, which a controller needs). Connecting is a step of its own with a timeout, and a broker that refuses or never answers is a connecting error naming host:port. The scrape then waits for the SDK's is_tree_complete and a quiet period counted from the first message, so neither a slow start nor a tree arriving piece by piece is cut short. Validation requires the root's $description, since the SDK creates the root's entry before anything arrives, and names any declared device that never described itself. The real-broker tests now share a mosquitto helper and fixture.
A clone is held to the panel it was taken from. A circuit that publishes no breaker/rating is cloned with breaker_rating: null rather than an invented 20 A; a modelling size still bounds its simulated power. The pinned emitter requires a rating and publishes the placeholder it is given, which the round-trip fidelity test pins as an upstream difference. The clone now also keeps each circuit's pcs/priority, or its absence, where it took the position-derived one; the panel's vendor name; the off-grid SOC shed threshold from shed/policy; and the MID's own serial, model and firmware, where the MID's serial was derived from the battery's. A battery's charge and discharge limits come from its info/nominal-power, else 5 kW per 13.5 kWh, a Powerwall 2's continuous rating, where every battery got 3.5 kW: the captured 81 kWh system was capped at 3.5 kW. The energy a clone reads from its panel now reaches the emitter. The seeds were written to the config but never into the manifest, so a clone's energy registers started at zero.
…iff the trees The public fidelity test for MAIN 32. Each source's retained tree is cloned as a live scrape clones it, the clone is published, and the two trees are compared by where each device sits: its devices, every declaration, and every value, literal form included. Device ids are compared through the clone's mapping, so a reference to the wrong device still fails. A difference fails unless it is time-varying (measurements and live envelope state), by design (the clone's own serial and ids, and the postal code it does not copy), or one of the named known differences. Those are strict expected failures, each with its cause: eight where the pinned emitter publishes what it publishes whatever PanelBench gives it, and one PanelBench convention to decide, naming devices for people where SPAN release 202639 names them after their ids. A known difference that goes away fails, so its expectation comes out. The clone's energy registers are checked to start where the panel's stood.
SPAN release 202639 names every device it proxies or synthesizes after its own id; the captured MAIN 32 publishes its battery, MID and lugs that way. PanelBench named them "Battery", "Microgrid Interconnect Device", "Upstream lugs" and "Downstream lugs". These names reach the wire only as each device's $description name, so naming them by id loses nothing a person reads, and the round trip now passes them strictly. A circuit keeps its own name. The pinned emitter publishes one display name as both a circuit's $description name and its info/name, and SPAN keeps the circuit's own name in info/name, so naming the device by id would lose it. The round trip carries that as a strict upstream expected failure until the emitter separates the two. The fidelity comparator keys lugs by their published direction, as it keys every other proxied device by topology, so its alignment no longer depends on the name either producer gives them.
… a local file The round trip read configs/rehearsal-after.yaml, which .gitignore keeps out of every checkout but the one it was made in, so a fresh checkout, CI's first among them, failed on a missing file. The config is now built from default_MAIN_40.yaml by the README's rehearsal recipe, which moves into tests/_helpers.py so the README's own test and the round trip follow one recipe.
…y allowance The fidelity round trip masked status/wifi, status/wifi-ssid and the meter voltages as time-varying, and its known-difference checks matched far more than the differences they named: dropping or renaming the panel's display name, or dropping its voltages, still passed. A clone now keeps the panel's envelope: its Wi-Fi and Ethernet links, its SSID or the absence of one, and its door and cloud state, which the emitter's envelope publishes, so a panel on Ethernet no longer clones with Wi-Fi up on sim-wifi. It keeps info/model verbatim, where the model was re-derived from the size, and stops reading info/panel-size, which no panel publishes. The MID keeps its own vendor. A non-finite number a panel publishes reads as unpublished, where inf aborted the clone. The round trip masks only measurements and timestamps. Every upstream allowance is pinned to the property, device and value pair it was observed with, and the panel's own name and voltages are compared. Mutations prove it: dropping or renaming the display name, dropping the voltages or the envelope, renaming the battery, or dropping PCS priorities all fail it. Upstream's example publishes no SSID, so its import now keeps that, and the shared-unvalued baseline records it.
…ials A scrape with kept credentials registered again on any failure: a broker that did not answer, a tree that never completed, a panel with no circuits. None of those is a refusal, re-registering fixed none of them, and each left another client on the panel and replaced credentials that worked. A refused CONNACK (4 or 5 under MQTT 3.1.1, which paho reports as 0x86 and 0x87) now raises BrokerRefused, and only that leads to a new registration. A real mosquitto refusing anonymous clients pins the classification, and the test double no longer models a refusal as a generic scrape failure. Supplied credentials are kept only once a scrape with them succeeds, so a mistyped password replaces nothing, and their serial must be the one the panel's status endpoint reports, so they are never kept under another panel's. Everything after a scrape connects now runs inside its try, and the link's disconnect sits in a finally of its own, so no failure or second cancellation leaves a self-reconnecting link behind with the source panel's credentials.
…ion with its will aiomqtt gives up waiting for a CONNACK without closing paho's socket, so a broker that answered late connected a client nobody owned, under the panel's id and will. Against a frozen mosquitto one stayed connected after the panel stopped, fired its will on the next takeover, and left a "Future exception was never retrieved" behind. The link now sends DISCONNECT behind such a pending CONNECT, so the broker closes the session and discards the will. That is an aiomqtt issue to report upstream. A session an operation failed used to leave with aiomqtt's clean DISCONNECT, which a recovered broker reads as "discard the will", so a panel stopped meanwhile kept a retained ready. It now leaves by closing its socket, and the will stands until the reconnect republishes the tree. A session the reader saw drop, or one disconnect ends, still leaves through aiomqtt's own exit. Both reach into the paho client aiomqtt wraps, kept to two helpers. Real-broker tests: a frozen broker's failed session leaves its will retained, and connects that timed out before a stop leave no orphan to fire one after it, and no unretrieved future.
…a reload A secrets file that existed but did not parse read as empty, so the next write replaced every other panel's passphrase and credentials with the one entry it held. An unreadable store, or one with an entry it cannot read, is now refused as PanelSecretsUnreadable, naming the file, and left for its owner to repair; a scrape reports it as a scrape error. A write is fsynced before it replaces the store, and the directory after, so a power loss cannot leave an empty file in its place. The passphrase migration ran unguarded at the top of every reload, so one config that was not UTF-8, could not be read or written, or had nowhere to put its passphrase made every reload fail and no panel could start or stop. Each file is now migrated on its own: one that cannot be is reported by name and left as it was, and the rest carry on.
Two entries described this cycle's own breakage as the "before". In 2.5.3 a duplicate serial's second panel silently took over the first's broker connection and topics, and a panel that failed to start left its connection open until it started again, without reconnecting; the entries now say so. The registration entry says it registers again only when the broker refuses the credentials, and new entries cover the panel's model and its network, door and cloud state reaching the clone. Also: PANELBENCH_REQUIRE_MOSQUITTO=0 no longer counts as set, and _is_circuit's docstring says a device with no $description has None.
…publishes A panel naming no hardware version published `rev2`, which no SPAN panel reports and which the release 202639 status endpoint can only report as `UNKNOWN`. The captured MAIN 32 publishes `1.2` on `info/hardware-version`, so that is now the default, read by MQTT and the status endpoint alike, and the shipped MAIN 40 template no longer pins `rev2`. A config's `hardware_version` still overrides it, and a config naming a release before 202639 still leaves the status field out.
… is proven on The broker link reaches into aiomqtt's private `_client` and paho's private `_sock_close` to withdraw a timed-out connect and to leave a failed session without a DISCONNECT. The add-on installs from pyproject.toml, not the lock, and neither dependency had a ceiling, so a build could take a release that moved either attribute while CI tested the locked versions. Bound them to aiomqtt >=2.5.1,<2.6 and paho-mqtt >=2.1,<2.2, mirror the bounds in the mypy hook, and add a contract test that fails on a bump that moves the surface the link reaches into.
A session an operation failed was dropped only when the session's own deadline ended it. When the failure was in stop_clone's drain, the stop's cancellation landed in the same loop turn and won, the session left through aiomqtt's exit with a clean DISCONNECT, the broker discarded the will, and the retained $state stayed `ready` for a stopped panel. The session now records that it failed, and is left by its state rather than by the exception that ends its block: a failed session's socket is closed with no DISCONNECT, a stop included. Pinned with the fake and against a frozen mosquitto. The reaches into aiomqtt and paho no longer hide a moved attribute behind a blanket suppress. A missing `_client` or `_sock_close` is reported once, naming what it costs, and the link falls back to aiomqtt's own exit; only socket errors are suppressed.
Only a refused CONNACK was pinned, so treating every MqttConnectError as a refusal passed the whole suite, and would re-register with a panel whose broker was merely restarting. A broker answering CONNECT with server unavailable (3, which paho reports as 0x88) or a rejected id now stays a plain connecting error, beside bad credentials and not authorised as refusals.
The clone copied door/state and status/cloud-connection into the config, and the envelope published them on every tick, freezing live state at the moment of the clone: a door opened to register the clone, or a cloud outage then, reported for good. Both are live state the emulator simulates, as it does power, so the clone no longer copies them, the config keys are gone, and the envelope always publishes its own. The round trip treats them as the panel's live state, pinned to the panel and those two properties. Its network links and SSID are configuration, and are still copied and compared.
MQTT published the config's model, as a clone keeps it, while mDNS re-derived one from total_tabs, so a clone whose model names no size, or whose size was edited after cloning, advertised a different model from the one it published. One helper, panel_model, now answers for both, as panel_firmware_version and panel_hardware_version do; the unsupported size check stays, now stated on its own.
The tests workflow synced and ran with --frozen, which installs whatever uv.lock records even after pyproject.toml has moved on, so a bound changed without a re-lock was tested against the old resolution and passed. Both steps now use --locked, which fails instead. Dependabot moves from the pip updater to uv's: pip's bumps pyproject.toml and leaves uv.lock behind, which --locked would now fail on every such PR. The ebus-sdk ignore carries over unchanged.
The three default templates now name spanos3/r202639/03, the form a panel on that release publishes (tests/fidelity/fixtures/upstream/main32_r202639-tree-v1.json), so they emulate a panel after the upgrade. Each solar circuit is the locked "Commissioned PV System" circuit, with commissioned_system: pv, and every template states hardware_version 1.2. A config naming a 202633 release keeps that release's conventions once its templates drop commissioned_system, which an earlier release refuses. Tests that choose a firmware go through name_firmware, which does exactly that, and the shipped templates are pinned to load without a warning, to publish release 202639's conventions, and to publish 202633's when named back. The wire capture is regenerated from the new MAIN 40 template.
The 2.6.0 changelogs now say the shipped templates publish release 202639's conventions, that a config naming a 202633 release keeps that release's, and that integration 2.1.2 or later is needed. The README's firmware section and upgrade rehearsal, and the add-on's docs, explain how to emulate the firmware before the upgrade.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What 2.6.0 brings
PanelBench now emulates SPAN firmware release 202639 on the
ebus-panel-sim0.9.0 emitter, and the shipped templates publish it (spanos3/r202639/03),so a default panel looks like a panel after the upgrade. A config naming a 202633 release keeps that release's conventions. Use it with SPAN integration
2.1.2 or later.
circuits publishes each inverter as its own device, the battery's power reading is positive while discharging, a SPAN Drive's user charge limit is
unpublished until set, and the status endpoint reports the hardware version.
while that link is healthy, as on a SPAN panel.
panel-sim-captureof a live panel, and exports a saved config as one.and firmware.
commissioned PV system; neither stops a config loading.
unloadable, a solar nameplate edit survives a restart, a locked relay stays locked however its config spells it, and Add Entity places a new circuit on a
free space.
The full list is in
CHANGELOG.mdand, for the add-on store,panelbench/CHANGELOG.md.Upgrade notes
default_*templates on every start, so an upgraded install publishes the templates this release ships, on release202639: their solar circuit is locked and named "Commissioned PV System", the battery meter reads positive while discharging, and the hardware version is
1.2. Integration 2.1.1 shows the battery's Meter Power with its sign flipped against release 202639.firmware_versionto a 202633 release such asspanos3/r202633/02, and removecommissioned_systemfrom its solar template.firmware_versionnames no SPAN release, including a clone of the earlier MAIN 40 template (sim/v0.1.0), now publishes release202639's conventions. To keep the earlier ones, set its
firmware_versionto a 202633 release such asspanos3/r202633/02, or, for a clone of a panel, tothe source panel's firmware.
Version
2.6.0 in
pyproject.toml,src/panelbench/__init__.py,panelbench/config.yaml,panelbench/Dockerfileanduv.lock, with both changelogs headed 2.6.0.ebus-panel-sim==0.9.0andebus-sdk==0.22.0are pinned inpyproject.toml,uv.lockand the pre-commit mypy hook, and both are on PyPI.Checks
pytestin CI with mosquitto: 815 passed, 1 skipped (the upstream drift check, which needs an emitter checkout), 11 xfailed.ruff check: clean.mypy --strict src: no issues in 78 source files.pre-commit run --all-files: every hook passed.uv build: sdist and wheel for 2.6.0.panelbenchreports 2.6.0 onebus-panel-sim0.9.0 andebus-sdk0.22.0. The amd64 image was not built locally.Merging releases 2.6.0
Merging this pull request is the release. The push to
mainruns Build HA Add-on Image, which builds the aarch64 and amd64 images and pushesghcr.io/spanpanel/panelbench/{arch}:2.6.0and:latest. The add-on store readspanelbench/config.yamlfrommain, so installs are offered 2.6.0 once itmerges. No tag or GitHub release is involved.