Skip to content

Release 2.6.0 — SPAN firmware release 202639 - #24

Merged
cayossarian merged 95 commits into
mainfrom
feat/ebus-panel-sim-release
Oct 8, 2026
Merged

cayossarian merged 95 commits into
mainfrom
feat/ebus-panel-sim-release

Conversation

@cayossarian

@cayossarian cayossarian commented Oct 8, 2026 •

Copy link
Copy Markdown
Member

What 2.6.0 brings

PanelBench now emulates SPAN firmware release 202639 on the ebus-panel-sim 0.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.

  • Release 202639 behaviour: commissioned PV and battery system circuits are modelled and locked, a config with several solar
    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.
  • Battery link health: the dashboard can set the health of the panel's link to its battery, and Home Assistant's dominant power source control is ignored
    while that link is healthy, as on a SPAN panel.
  • Panel definition files: the dashboard imports one, such as a panel-sim-capture of a live panel, and exports a saved config as one.
  • Clones keep the source panel's identity: name, firmware and hardware version, each inverter's identity, the battery's model and each SPAN Drive's serial
    and firmware.
  • Validation warnings: the log warns about a solar circuit on a single tab, and, from release 202639, about a solar circuit whose template is not a
    commissioned PV system; neither stops a config loading.
  • Dashboard fixes: an edit the panel would not load is refused with the reason and the config kept, the dashboard still starts when the active config is
    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.md and, for the add-on store, panelbench/CHANGELOG.md.

Upgrade notes

  • The add-on now refreshes its shipped default_* templates on every start, so an upgraded install publishes the templates this release ships, on release
    202639: 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.
  • To emulate release 202633, clone a template, set its firmware_version to a 202633 release such as spanos3/r202633/02, and remove
    commissioned_system from its solar template.
  • A config or clone whose firmware_version names no SPAN release, including a clone of the earlier MAIN 40 template (sim/v0.1.0), now publishes release
    202639's conventions. To keep the earlier ones, set its firmware_version to a 202633 release such as spanos3/r202633/02, or, for a clone of a panel, to
    the source panel's firmware.
  • On release 202639, a config with two or more solar circuits publishes each inverter as its own device under new device ids.
  • Every solar device is named after its own device id, as SPAN firmware names an inverter, where it was named "Solar", "Solar 2" and so on.
  • A new clone of a panel whose battery publishes a serial number identifies that battery, and its MID, by the serial; existing clone configs are unchanged.

Version

2.6.0 in pyproject.toml, src/panelbench/__init__.py, panelbench/config.yaml, panelbench/Dockerfile and uv.lock, with both changelogs headed 2.6.0.
ebus-panel-sim==0.9.0 and ebus-sdk==0.22.0 are pinned in pyproject.toml, uv.lock and the pre-commit mypy hook, and both are on PyPI.

Checks

  • pytest in 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.
  • Prettier (the repository's 160-column prose settings) and markdownlint on both changelogs: clean.
  • uv build: sdist and wheel for 2.6.0.
  • The add-on image built locally for aarch64 from a clean archive of this branch, with the workflow's base image; inside it panelbench reports 2.6.0 on
    ebus-panel-sim 0.9.0 and ebus-sdk 0.22.0. The amd64 image was not built locally.

Merging releases 2.6.0

Merging this pull request is the release. The push to main runs Build HA Add-on Image, which builds the aarch64 and amd64 images and pushes
ghcr.io/spanpanel/panelbench/{arch}:2.6.0 and :latest. The add-on store reads panelbench/config.yaml from main, so installs are offered 2.6.0 once it
merges. No tag or GitHub release is involved.

…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.
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.
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.
@cayossarian cayossarian changed the title Release 2.6.0 — SPAN firmware release 202639, where a config names it Release 2.6.0 — SPAN firmware release 202639 Oct 8, 2026
@cayossarian
cayossarian merged commit 21e8bbd into main Oct 8, 2026
3 checks passed
@cayossarian
cayossarian deleted the feat/ebus-panel-sim-release branch October 10, 2026 04:13
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant