Repository navigation
Releases: sm26449/multi-bus-gateway
Release list
Multi-Bus Gateway 3.92.0
Upgrade notes:
- The Device Builder is gone (breaking). ESP flashing, ESPHome builds
and the node list moved to the separate multi-bus-gateway-esphome
service./api/builder/*, the USB flasher, theESPHOME_*variables and
the bundledesphomecompose service are removed; anesphome:block in
config.yamlis ignored and dropped by the next save. ESPHome LAN
discovery stays. - InfluxDB measurement of unclassified fields. A field the gateway
cannot classify used to land in measurementjanitzaon every device; it
now lands in its row's category, orother. Bucket and field names
do not change. A Flux query of your own that filters
_measurement == "janitza"on a device other than the UMG needs the new
name. - Saving through the API keeps what it was not told. A
PUTthat leaves
a key out keeps the stored value;nullclears it; an unchanged save
writes nothing and restarts nothing. A client that relied on a missing key
meaning "back to the default" must now sendnull. - Deleting is refused while something uses the object (409 with what
uses it): a device, an installation or a template that a rule, a sequence,
a formula, a virtual meter or another device's meter link names. Disable
it instead, or change those first. - Yes/no fields take only JSON
trueorfalse.enabledand the
other switches (the write master switch, raw writes, anonymous OPC UA, a
rule'senabled) now refuse strings and numbers such as"true",
"false",1or"on"with 422 and name the field. Before, any
non-empty string,"false"too, counted as true. Turning a rule or a
command on or off needsenabledin the body. A command'slease_sis a
whole number from 0 to 86400, and a bad one is refused before anything is
written.
Control
- Rules: a
schedulekind, timers on every kind (for_s, minimum on/off
time, maximum starts per hour), forcing a rule for a while, time and
per-rule counters in expressions, several rules on one target as layers
(safety, manual, schedule, optimize), and sequences — commands in order
across devices, undone on failure. A new rule runs in shadow until
armed; disarming or disabling an armed rule puts its target back to its
safe values first (unless the rule sayson_disable: hold). One rule
that cannot be read inrules.yamlno longer stops the others: it is
shown in error, alerts, and can be fixed or deleted. Opt-in per-rule
keys wait for the read-back after a restart, resume the step the device
holds and resend an urgent want sooner. A rule that leaves armed never
sends a write it still had queued, and a command's lease reverts to safe
even after the command was turned off. - Commands over MQTT — a device read over MQTT runs its template's
commands; a new Victron GX template brings the ESS commands. - Meters with roles — a device measured by other meters: consumption, or
input and output (a UPS, a charger), with power, energy, loss, efficiency
and load published only when the readings can be trusted. A device with no
connection of its own can be added, measured by its meters alone.
Devices and templates
- Components — the batteries and sensors a Victron GX holds, each its
own device on the GX's single connection, found on the device. - Asset attributes — location, grid connection limits, PV inputs,
ratings, battery limits, from the gateway's catalog; the sun's position and
attr()in formulas and rules. - Formulas:
fresh,count_fresh,age,absent,steps,ema,
hysteresis; a copy/paste character is named before saving. - Templates: notes for users, setup words written at connect, rule
examples, pauses between writes, single-register writes; new maps for a
Samsung heat pump, a Delta VFD-EL drive and the Victron GX. - Devices found over MQTT can be adopted from Devices; topic variables
per device; TLS to a device with its own certificate.
Outputs
- OPC UA server for SCADA and HMI panels: every device and value with its
quality and read time, logins with the gateway's accounts, encryption on by
default, chosen per device; tested with FUXA (guide). - A device may publish on its own rhythm; a device's Outputs tab lists every
output with its state.
Pages
- Devices: kinds, search and filters; the device page in three groups
(Live, Configure, Diagnose); one connection form for adding and editing;
Used by and Disable instead of a refused delete. - Bridges say how busy each bus really was in the last minute.
- Installations: a save restarts only the units it changes.
- A page left open across an update says so and offers Reload.
- The whole UI in English and Romanian, in one vocabulary, with the
language's plural forms and dates.
Operations
- High-availability hooks: the gateway's role (active / standby), state
and configuration mirroring for a standby, behind a load balancer. - Service tokens for programs (
Authorization: Bearer …), with a role. - Device alarms declared by a template become alerts.
- Dependencies: certifi 2026.10.19 (PYSEC-2026-4320);
asyncuafor the
OPC UA server.
Multi-Bus Gateway 3.88.0
Nothing to do when upgrading.
2026-10-08 — canonical names beyond the grid, and your own
The canonical dictionary — the names that give a quantity the same MQTT
topic, InfluxDB measurement and unit on every device — covered only grid
and PV quantities: a battery, a water meter or a weather station had none.
-
95 new built-in names across six domains:
- battery:
soc,soh,cycles,pack_voltage,battery_current
andbattery_power(positive while charging), capacities, energy
stored/to full, charge limits,cell_1…cell_32, cell temperatures
and their min/max/avg; - environment: temperature, humidity, irradiance, module
temperature, wind, pressure, CO₂; - water and gas: volume and flow;
- heat: energy, power, flow, supply/return temperature;
- EV charging: power, session and lifetime energy, current limit,
state; - tank levels.
Names BMS maps already used are kept, so the bundled Seplos map is
canonical as it is. - battery:
-
Counters are declared, not guessed: the battery's
energy_remaining
andenergy_to_fullare not counters although they start withenergy_;
a water or gas volume is one. -
Your own canonical fields on Templates → Canonical fields: name,
category (InfluxDB measurement), unit, MQTT topic leaf, counter or not.
They are canonical everywhere a built-in one is (topic, measurement, unit
contract, editor guidance). Built-in names cannot be redefined; once a
device reads a field, its category and topic stay put. A template that
names them carries their definitions when exported and creates them on
the gateway it is uploaded to. -
InfluxDB: a battery unit's
energy_remaining/energy_to_full
are now written under thebatterymeasurement instead of
energy_active, where the unit heuristic had filed them. Nothing else
moves: rows with an explicit measurement keep it, and MQTT topics of
existing devices do not change (a stored topic, or the flat name). New
devices get the hierarchical topics.
2026-10-08 — a device template anyone can write, rules that travel
Writing a template for a new device needed JSON for the things that matter
most, and a few traps cost values silently. Now the whole path is in the UI
and documented:
- Template editor — How the device is read: the ways to reach it
(Modbus TCP/RTU, RTU listen-only, HTTP, MQTT), the word order of
32/64-bit values (ABCD / CDAB / BADC / DCBA, explained), the default unit
ID, the read size, and every poll group's interval. Rows take FC1 coils
and FC2 discrete inputs, any data type (string:N, sign-magnitude…),
and a json_path / topic column when HTTP or MQTT is ticked. - A changed word order takes effect at once. It used to be read only
when a device's client was made — a corrected template kept the device
reading garbage until the container restarted. Saving a template whose
protocolchanged restarts the devices reading with it (and says which). - Diagnostics → Probe decodes a register all four ways side by side;
each column now has a button that writes that order into the device's
template. - CSV / YAML import take Read over and Word order (a YAML file may
declarebyte_order/transportsitself, and a re-imported template keeps
its wholeprotocolblock — it used to be dropped); CSV takes FC1/FC2
aliases (coil,DO,DI…); an import whose addresses all look like the
1-based 40001/30001 notation warns instead of reading every value
from the wrong place. - Rules export and import. Rules → Export (all, or one from its card)
as YAML; Import checks each rule against this installation first —
new, replaces, already here, cannot import with the reason — then
saves the valid ones. Imported rules always arrive in shadow, and an
armed rule is never replaced by an import. - A viewer no longer sees the rule controls it cannot use (arm, clamp,
pause, edit, delete). - Found by walking the whole path as a new user (a new end-to-end test with
simulated Modbus TCP, RTU, HTTP and MQTT devices and a virtual meter):- Add Device → Modbus RTU over the network: Test connection wrote its
result into a hidden field — nothing appeared. - Diagnostics → Probe did not list RTU-over-network devices.
- Clicking the Devices tab with a device open stayed on that device;
it returns to the list now. - The editor showed no interval for rows without a poll group (they read
onnormal); an HTTP/MQTT map no longer carries Modbus settings. - Refusing an HTTP device URL said "SSRF guard"; it now says why:
127.0.0.1 is the gateway itself (inside Docker, the container) or
not on your local network. - Virtual Meters, first visit: it said "use Add instance" while that
button was disabled — it now says to make a template first, with a link.
- Add Device → Modbus RTU over the network: Test connection wrote its
- New guides: device-templates.md — from a
manual's register table to live values: addresses, word order with a worked
float, scale, the editor, CSV/YAML/JSON, HTTP and MQTT examples, testing,
changing and sharing, troubleshooting, every field.
rules.md — the safe way to bring a rule in, five recipes
(over-voltage, over-frequency, battery full and exporting, grid lost, a
value from another system), import/export, what each card state means.
Multi-Bus Gateway 3.87.0
Upgrade notes: the dashboard no longer colours a current or a power
by itself. Its implicit 90/100 "percent" defaults were compared with raw
amps and VA (any load above 100 VA showed red), and the threshold editor
filled them in — check thresholds saved that way (warning 90 / danger 100
on a power or a current) and set real ones or switch them off. For a bank
on seplos_bms_v3_rtu_tap, the installation page announces the template
update — review and apply it with the new calculated fields ticked (the new
warning_count); until then the bank's warnings are not counted.
2026-10-07 — a template update is announced, reviewed, then applied
The Update from template button was always there and said nothing about
what it would do — an operator could not tell whether to press it. Now:
- The installation page shows The template has been updated only when
the template has something the units lack (the devices list carries a
template update badge); otherwise there is nothing to press. - Review and apply opens a review computed with the very rule the apply
uses: every change with its old and new value and on how many units, the
calculated fields the template gained (tick to add them), what stays as
you set it — then applies it to every unit at once. - The Templates page says where an update from each map is waiting
(Update waiting in: Battery bank, with the review a click away), and
counts a map as in use by the units that reach it through a source too. - A unit whose template is missing (deleted, renamed, failed to load)
says so — Template not available on its page, a template missing
badge in the list — instead of quietly never being updatable; it keeps
reading with its own copy. GET /api/endpoints/{id}/template-changes(and/api/devices/{id}/…)
return that review without writing anything;/api/endpointscarries a
shorttemplate_updatesummary.- A unit created from a template now starts identical to it (its rows carry
the template's descriptions and sections), so a new installation is never
"out of date"; filling a section or a description an older unit never had
is done with the next update but announces nothing (the page reads them
from the template anyway); the log says how many rows changed and fields
were added.
2026-10-07 — thresholds that know when they apply
onlyWhenon a register's thresholds: judge them only while another
field of the same device is above a value — a current's THD at
near-zero load is noise, so{field: current_l1, above: 2}. Set in the
threshold editor (Only judge while … is above …); applied alike on the
dashboard, the fleet's alarm count and value alerts, and an alarm it was
in clears with an event.- No implicit current/power limits: a limit for those is a share of a
rating (a breaker, an inverter) the gateway cannot know. Voltage,
frequency, power factor and THD keep their grid defaults. - Update from template can add what the template gained (ticked in
the review, or?add_new=true): the template's calculated fields a unit
lacks; existing rows and formulas stay as they are. seplos_bms_v3_rtu_tap1.9.0:warning_count(the alarm flags without
the protections) is the declared warning, so one protection event is one
danger, not a warning and a danger.fronius_solar_api_site: the load reads Load seen by Fronius
(− consuming) — the datalogger's figure is negative while consuming and
leaves out a battery or an inverter behind the meter it does not know.
Multi-Bus Gateway 3.86.1
Fixes from the post-release audit of 3.86.0; nothing to do when upgrading.
2026-10-07 — audit fixes
- Home Assistant: Update from template withdrew an installation's HA
entities (stopping the units does) and never published them again until
the next broker reconnect. It republishes them now, and a device refresh
pushes its new labels to HA. - Alarms count the same everywhere: the fleet now ignores a value gone
stale, as the installation page did (a silent pack no longer alarms in
the fleet forever); a field the template declares as an alarm is not
counted a second time through a threshold on it;"0.0",NaNand—
read as nothing active, on the server as in the browser. - History of a grouped installation: a total's chart mixed the points of
every group that has a total of that name./api/historyreads the first
group by default and takesgroup=for the others. - Update from template keeps a row's unit when the operator changed its
scale or offset (a W register read as kW stays kW). - Cell grid: an unused or unread slot (0 V) is no longer marked as the
weakest cell; the marking is also announced to screen readers. A bitmask
read from a signed register lists its real bits; masks up to 53 bits. - The dashboard rebuilds its sections when the template's labels, categories
ordisplayblock change (after an Update from template, or switching
between two devices whose registers share addresses). - A
tilespattern must be plain syntax both Python and the browser read —
(?i)or(?P<…>)are refused at load instead of failing silently. - Hardening: a template's category name and a total's unit are escaped
where they reach the page. - A viewer no longer sees Test, Test push, Delete source or a
command's Run / Test (the server already refused them). - The alarm pill says what it means to a screen reader on a phone; two
section names missing from the translations added; a validation error from
Update from template reads as text, not[object Object]. GET /api/endpointskeepsaggregate_fieldsfor a one-unit installation
(empty since 3.86.0).
Multi-Bus Gateway 3.86.0
Upgrade notes (no config change needed): an installation total is no
longer computed twice — a field whose template says how it combines is not
also combined by the name rules. For a bank on the bundled
seplos_bms_v3_rtu_tap map, energy_remaining (same as
pack_energy_remaining) stops at once; power_active_total (same as
pack_total_power) stops after Update from template on the
installation, since an existing unit keeps the totals its rows were seeded
with. Their last retained MQTT message stays on the broker until you clear
it; read the pack_* names. The serial-bridge image now takes Debian security updates at build
time (the scan flagged perl-base and libpcre2).
2026-10-07 — the template decides how a unit is shown
An audit of a real battery bank, through a viewer account, found the UI
choosing what to show from lists hardcoded by role and by name. A battery
fit neither: its units showed "—" for power, its totals were raw names
without units, its headline read "Producing now", its cells read "3.29 V"
sixteen times, and 34 alarm flags at zero filled a section. What a unit of
a kind shows now comes from its template's display block — read live,
so improving a template improves every unit at once:
- Names and columns:
unit_label,icon,glance(the columns of a
unit row: power and SOC for a pack),hero(its fleet row),headline
(the installation's top line, from its totals, with a hint such as the
sign of a battery's power). - Alarms (
alarms: [{field, severity}]): fields whose non-zero value
is an alarm — a BMS's alarm, protection and failure counts. The fleet
counts them (a pack in alarm no longer shows 0), each installation unit
carriesalarms, the installationunits_alarming; the unit row gets a
pill, the unit page lists what is active. - Sections (
sections: {category: {widget}}):gridturns a
section into tiles with the lowest and highest marked — a pack's weak
cell at a glance — withtiles(which fields) anddecimals(read to the
millivolt, summaries included);active_onlyshows only what is active,
or Nothing active. - Bitmasks (
bitmasks: {field: "Cell {n}"}): a mask reads as what is
set — Cell 3, Cell 7,—when clear.
It is validated at load: the fields it names against the template's own
(headline names totals, so only its shape), section keys against the
template's categories. Without a block, the role decides as before. Reference:
config-reference display:, MANUAL §5.3.
- Totals speak: each inherits its source field's label and unit
(SOC (average) %); a total pooled over several fields is named after
itself; the grid lists the headline first, then the template's order,
folds after 12, and reads percentages and temperatures to one decimal.
New operationmode— the majority value, text included: a bank's
one-word status (text, so it has no history series). - Template categories reach the register list, so the dashboard and
the unit page group a vendor's fields as its template files them. - Update from template (admin, on the installation page; a standalone
device through the API): carries
improved labels, units, descriptions, categories and totals over to the
rows a unit already has; the selection, flags, sinks, thresholds and
formulas stay yours. - A viewer sees no control it cannot use; the unit table fits a phone;
the tap's wire health (frames, CRC errors, resyncs) shows under How it
is read.
seplos_bms_v3_rtu_tap 1.8.1 declares all of the above, with clear labels
for the BMS current limits and the signs of power and current, and a bank
pack_status.
Fixed along the way:
- Formulas loaded at boot only: a device or installation created or edited
at runtime ran no calculated field until the next restart. - An installation's history charts were always empty: its totals are
written as fields named after the total, and the history query looked for
anametag. It now reads them as they are written, and the picker offers
them. - The login no longer says "Session expired" on a first visit; a unit page
no longer shows "No values yet" over live values.
A post-release audit found a few issues (Home Assistant entities after Update from template, alarm counting, grouped history); they are fixed in 3.86.1 — upgrade to that.
Multi-Bus Gateway 3.84.2
2026-10-03 — direct serial that works, MQTT input that speaks BMS
- Direct serial mode could never open its port: the documented mode
(docs/rtu-serial.md §1) maps/dev/ttyUSBxinto the container, whose
node keeps the host's root:dialout 660 — and thembguser had no
dialout membership, while the entrypoint'ssetpriv --init-groups
drops any dockergroup_add. The image user joins dialout; the doc
gains a tip for stable/dev/serial/by-idmappings. Found moving a
Fronius Smart Meter 65A-3 from the serial-bridge to direct mode. - The MQTT input ingests what BMS collectors actually publish: an
enum register accepts its LABELS (ON,Standby) via reverse lookup
— symmetric with the HA write face — and astring:Nregister carries
free text (bounded) into the store; unknown labels drop. Register
source topics support a relative~/leafform resolved against the
device's base topic, which is what lets ONE template serve N units. - New built-in template
seplos_bms_mqtt: the full 58-field map of
one seplos-bms-mqtt
battery pack (battery / cells / temperature / alarms / diagnostics),
register topics relative — set the device's input topic to
seplos/battery_N/#and seed from the template. Per-register defaults
keep MQTT/Influx re-publish off: the collector stays the writer, the
gateway adds per-pack health, dashboards and thresholds. - The register span-overlap warning (a Modbus concept) no longer fires
for topic/json_path registers — synthetic consecutive addresses are
the norm there, and a Seplos pack printed 57 bogus lines per boot.
Multi-Bus Gateway 3.84.1
2026-10-02 — urllib3 2.8.0 (release gate caught PYSEC-2026-4175/76/77)
The v3.84.0 images were never published: between the audit work and the
tag, three urllib3 2.7.0 advisories landed (chunk-size buffering DoS,
a Deflate-stream infinite loop, and HTTPS-proxy TLS-context mix-ups) and
the release workflow's pip-audit/Trivy gate refused the build — exactly
what it exists for. 3.84.1 is 3.84.0 plus urllib3 2.8.0 in the lock.
3.84.0 (never published — see above)
2026-10-02 — dead code: the audit's pruning list
Everything below was grep-verified unreferenced (including from tests,
the UI's fetch calls and the docs) before deletion:
- Python:
MQTTPublisher.publish_status,InfluxDBPublisher.write_single
(+ its_get_measurement/_get_tagsdelegation shims — the one test
using them now calls the module-level function),
RegisterParser.parse_registers,Config.get_registers_by_poll_group,
RegisterEncoder.register_count(call sites readREGISTER_COUNTS
directly),EsphomeClient.unarchive. The test-seam trio
(_EndpointArbiter.turn,PqRecorder.ring, the_should_publish/
_confirm_publishlock-wrappers) stays — tests exercise them. - JS:
removeRegister(the live path isremoveRegisterFromTable), the
query-history feature remnant (renderQueryHistory, its unbounded
state and its vanished#queryHistoryelement),renderStatusDetails
(~70 lines re-rendered into a container deleted long ago, on every
status poll while on Settings),updateSettingsStatusDots, and the
null-guarded writes to the departed#registerCount,#dropzoneHint
and#monitorTableEmptyelements. - CSS: 39 rule blocks whose classes appear nowhere in the HTML/JS — the
old config-page layout (config-form*,connection-*,danger-zone*,
poll-group-*,json-preview,apply-config-banner),threshold-grid*,
widget-badge/tall,status-card*,btn-success/warning,
text-accent/success/warningand friends (−237 lines).chart-area
stays:chart-${...}class names are composed dynamically. - i18n: 63 keys no code references anymore, removed from en and ro
together (1496 keys, parity intact). datetime.utcnow()(deprecated) →datetime.now(timezone.utc)in the
self-signed-cert validity window.
2026-10-02 — i18n: the hardcoded-English sweep
t()learned{name}interpolation, so the three sentences that were
glued from fragments (lockout minutes, scan-result counts, rule
confirmations) are single translatable keys with placeholders — word
order is the translator's again.- 73 new keys cover what rendered in English even in Romanian: the
statusbar, the enum-builder modal, the virtual-meter page and its
instance/delete modals, the raw-config and customize-dashboard modals,
the History page's hints/axis notes/empty states, the Monitor and
Dashboard empty states, and the register editor's header and footer. - The phase-convention options carried Romanian wording in the ENGLISH
base ("maro / negru / gri"); the English labels are English now and the
Romanian wording lives in ro.json, where it belongs. Option values are
untouched, so saved configs read back identically. - en.json and ro.json remain in full key parity (1559 keys).
2026-10-02 — infra: the serial-bridge joins CI, pins, teardown
- The serial-bridge image is built and Trivy-scanned in CI — it never
was: a broken Dockerfile or a debian CVE surfaced first on release day.
Same HIGH/CRITICAL gate as the gateway image. - Grafana and ESPHome are pinned in docker-compose.yml (13.0.2 /
2026.5.3) instead of:latest— a bad upstream release no longer lands
on everycompose pull; Dependabot's newdocker-composeecosystem
keeps the pins moving (thedockerecosystem only watches Dockerfiles). - The gateway's entrypoint chowns the config volume only when it arrived
with foreign ownership (a recursive chown over snapshots and buffer
files on every start is slow and wears SD cards) and tolerates a
read-only config mount instead of failing the boot; the serial-bridge
entrypoint gets--no-new-privsfor standalonedocker runparity;
init: trueon the gateway service reaps would-be zombies. - Leaked rules threads are stopped between tests: the 9162777 flake's
root cause was an earlier test's rules thread still sweeping its units
under random ordering — that fix immunized one victim; an autouse
teardown now stops every live runtime after each test. --timeout=120moved into pytest.ini addopts (a bare localpytest
could hang forever); requirements.txt caps the next MAJOR of
fastapi/uvicorn/pydantic for bare-metal installs (the image ships the
hash-pinned lock regardless); rtu-serial.md states the control-API
binding precisely (host-local publish, not an in-container bind).
2026-10-02 — UI: correctness and accessibility
The audit's frontend findings — first the bugs, then the access gaps:
- One visit to the Status page froze the titlebar for the session. The
page's 2 s refresh reused the app-wide_statusTimerhandle, so leaving
the page cleared the global 5 s status loop: health dots, the
devices-online count and the connection-lost detection all stopped. The
page has its own timer now; its refresh also got the last-wins sequence
guard History/PQ already had, and it preserves keyboard focus across the
re-render (the embedded Test button was unreachable — focus dropped
every 2 s). - Settings → Alerts "Test" looked dead. Its result span shared an id
with the Status banner's, so the outcome rendered into a hidden element.
The handler resolves the span next to the button now. - Modal keyboard traps. Escape closed TWO stacked modals (the trap
didn't stop propagation, and the global fallback closed the first modal
in DOM order — now the top-most). Enter with focus on Cancel or ✕
SAVED the edit in three modals; it now confirms only from a text field. - Stale shown as live: on a WebSocket loss the dashboard kept its last
numbers looking live — it dims now, with a "values as of HH:MM" badge. - Rapid dashboard device switching could land a slow
/api/values
response in the wrong device's store (sequence guard);localStorage
access can throw in private windows — boot survives it now; the status
banner double-escaped device names (A&Bshowed asA&B);
setButtonLoadinghard-coded "Saving..." in English and stripped the
button's icon on restore. - Accessibility: the History measurement picker and its category
headers are keyboard-operable (role, tabindex, Enter/Space — they were
mouse-only, a keyboard user could not chart anything); the dashboard
customize list gained move up/down buttons (reorder was drag-only) and
its checkboxes/selects accessible names; status dots carry their state
as text (title + aria-label) everywhere, not color alone; warning/danger
TEXT uses the theme's--warning-text/--danger-texttokens (the
hard-coded ambers were ≈2.2:1 on the light theme); focus rings restored
whereoutline:nonehad no replacement, and the theme toggle got one
plus an accessible name; 12 form labels got theirfor; duplicate
aria-labelattributes, a nested<label>and an empty spacer label
fixed; secondary tab bars expose tablist semantics with
aria-selected; JS-built tables setscope="col"; and a new
data-i18n-ariapass translates aria-labels with the rest of the UI.
2026-10-02 — rules: clocks, stale semantics, threshold clears
The audit's remaining rules-layer findings, fixed with regression tests:
- Rule timing runs on the monotonic clock now. The rate-limit,
reassert and staleness windows were measured on the wall clock: a chrony
step backward (the 2026-07-28 +138 s jump class) froze them — including
the reassert that restores a Fronius limit after RvrtTms — and a step
forward prematurely expired overrides and clamps.evaluate()takes the
interval clock plus a separatewallfor Decision timestamps and the
wall-persisted absolutes (paused_until, clamp expiry), which the UI
shows as dates and which must survive restarts as wall time. - Threshold clears are delivered. Alarm and "back to normal" share a
rate-limit key, so withinmin_interval_sthe clear was swallowed and
alarm→normal→alarm delivered only the first alarm. Band transitions
(edge-triggered by design) now bypass the per-key limit; and a
danger-only threshold (no warning bound) gets the same clear-side
deadband every other band has, instead of flapping on every harvest. - Rules persistence is crash-safe.
rules_state.json(operator clamps
— setpoint ceilings! — and overrides) was written non-atomically and a
torn file silently loaded as empty;rules.yamlwas replaced without
fsync, so a power loss could leave it empty — and an unreadable
rules.yaml silently disabled EVERY rule with only a log line. Both files
write temp+fsync+replace now, an unreadable rules.yaml raises an alert,
a corrupt state file warns that clamps were lost, and a hand-edited
duplicate rule id says which copy wins. - Recovering from STALE straight into the dead band restored the pre-stale
state only AFTER the desired-state computation had already produced a
synthetic 'stale' state (severity 99, duplicate warn events,
ping-ponging with the restore every tick); the restore runs first now. - The operator clamp caps
on_stalefixed/safe wants too (they used to
bypass it); afast(emergency) step and a stale safe want no longer
sit out themin_interval_swindow a routine step opened moments
earlier. - A command verify/guard rule against a non-numeric read-back (an enum
decoded to text) answers arejectedverdict instead of raising a 500
out of the face; the alert rate-limit memo is bounded (it grew one entry
per key forever).
2026-10-02 — data path: the audit's correctness findings
The same audit's ...
Multi-Bus Gateway 3.83.1
Multi-Bus Gateway 3.83.1 fixes a UI regression introduced by the strict Content Security Policy of 3.82.0: the shared modal's Save/Run button (installations, groups, sources, command dialog) did nothing. Upgrade from 3.82.0, 3.82.1 or 3.83.0. Images: ghcr.io/sm26449/multi-bus-gateway:3.83.1 (amd64 + arm64, signed provenance).
2026-09-24 — the shared modal's Save button was dead under the strict CSP
- The installation, group, source and command editors share one modal
whose Save/Run button was repointed from script with a handler
attribute. Thescript-src 'self'policy of 3.82.0 refuses such a
handler exactly like a static one, so since 3.82.0 saving an
installation, a group or a source, and running a command from its
dialog, did nothing (the console said why; the page did not). The button
is repointed through the delegateddata-actionnow, and the CSP guard
test also catches handlers set withsetAttribute. Found by the
commands end-to-end script during the 3.83.0 verification.
Multi-Bus Gateway 3.83.0
Multi-Bus Gateway 3.83.0 is a security release: every Critical, High and Medium finding of an independent second-pass audit (2026-09-24) is fixed, each with a regression test. Upgrade before running a command with a lease on a production inverter — 3.82.1 and earlier could un-curtail a plant to 1 % after a crash. Images: ghcr.io/sm26449/multi-bus-gateway:3.83.0 (amd64 + arm64, signed provenance).
2026-09-24 — SECURITY: findings of an independent second-pass audit
An independent audit of the public repository (Copilot, 2026-09-24) found
what ours had missed. Every Critical and High finding is fixed here, each
with a regression test that fails on 3.82.1. Upgrade before running a
command with a lease on a production inverter.
- Critical — a crashed gateway un-curtailed a plant to 1 %. A dead-man
lease taken by a command (power_limitwithlease_s) was persisted
with a raw envelope; after a crash, boot recovery wrote the bare word
100into WMaxLimPct, which a SunSpec scale factor of −2 turns into a
1 % limit with the enable bit still set and no device-side timeout.
Recovery now runs the command's ownsaferecipe, exactly as the live
revert does. - High — raw
/writeon a scale-factored register. The declared write
path took the template'sscalebut notscale_from, so writing
power_limit_pct = 60sent the word 60 (0.6 %) and the read-back
"verified" it through the same wrong scale. A register with a live scale
factor, or one a command writes first, is refused with 422 and pointed
at the command — the Home Assistant face has always done this. - High — a lease was not armed when the frames went out but the
read-back failed. Anunverifiedormismatchcommand left the limit
on the device with no revert timer. The lease is armed whenever at least
one frame was sent. - High — the API key doubled as the admin role. With
API_KEYset,
every operator must present the key on every write, and the key alone
unlocked raw (unguarded) writes, secret exports and full snapshot
downloads. With login on those are admin-only now; the key stands in
only on a box without login.docs/API.md,SECURITY.mdand the
hardening checklist say so. - High — the "sanitized" export leaked nested credentials. Only
connection.*andrest_push.*were stripped; a device'shttp.headers
andmqtt_in.password, the credentials of multi-source devices and of
installation sources went out in clear in a backup any viewer could
download. Redaction now walks the wholedevices[]andendpoints[]
trees (secret-named fields dropped, every URL redacted) and the
merge-import restores the live values for all of them. - High — an infinite value froze a virtual meter with its fail-safe
disarmed.OverflowErrorandstruct.errorescaped the row guard,
aborting the block rebuild; an MQTT publisher could trigger it. They are
caught, and a non-finite source value is treated as missing rather than
served as a float32 infinity. - High — a device whose template did not load polled anyway with
byte_order: big. A little-endian meter would have published
word-swapped garbage as if it were fine. The source stays idle and the
log says which template is missing. - High —
round(x, -10**8)hung the poller or the rules thread.
CPython computes 10**|n| for it. The second argument ofround()is
bounded (±15) at save time and at evaluation time, and a result that is
not finite or wider than 64 bits never leaves the expression engine.
Medium findings fixed in the same release:
override_s(the seconds a controller may pause the rule that owns a
command) is bounded to 24 h and must be a finite number; the rule is
paused only once the command has passed every gate, so a rejected
command no longer silences a safety rule (F-09).- The Home Assistant number/select
setpath honours the device write
lock, like the HTTP and command faces (F-13). - Every
/api/write body is capped at 8 MB before it is read (import
keeps its own 25 MB cap); a chunked body without a length is refused
(F-19). - The bundled broker refuses to start with the
.env.exampleplaceholder
secret or with amosquitto.confthat still allows anonymous clients;
.env.exampleships the four required secrets commented out instead of
with placeholder values (F-22, F-23). GET /api/commands/historyis admin-only while login is on, as
documented — it is the audit log filtered (F-33).- Documentation: the primary device is write-locked by default, not
"always read-only" (F-34); the Romanian manual's stack paragraph
described the pre-3.81.0 anonymous broker and an always-on MQTT
Explorer (F-35).
More Medium findings fixed in the same release:
- Command guard and verify reads use each register's own function code —
a guard on an input register was read with FC3 and could never hold
(F-14). - Renaming or removing an account revokes every session and deletes the
old name's passkeys (F-16); a passkey credential id belongs to one
account — re-registering it under another is refused with 409 instead of
replacing the owner's passkey (F-17). validate_templaterejects boolean addresses, non-finite scales,
offsets and write bounds, string registers longer than 125 words, and a
write_safeoutside its own envelope (F-21).- The boot last-known-good seatbelt restores only when the config cannot
be parsed or validated; an OSError, ImportError or any other failure is
re-raised instead of rolling credentials and settings back (F-26). - The session store keeps at most 256 live sessions, oldest first (F-27).
- A clean shutdown reverts every live dead-man lease before the devices
disconnect; a revert that fails stays on disk for boot recovery (F-28). - Rule definitions reject NaN and ±inf in
on_stale, step values and
thresholds (F-30). - A source stamp up to one second ahead of the virtual meter's rebuild is a
thread race, not a corrupted stamp: it no longer causes a one-tick
meter stop (F-31). A virtual meter whose server cannot start (port in
use, bad interface) retries with backoff and logs on the first and every
tenth failure instead of flooding the event ring every tick;bindmust
be an IP address (F-32). - VENDOR-DATA.md says what the bundled maps actually carry — the register's
short functional label, not the manual's prose (F-36). - The import route's disk and Modbus work runs off the event loop (F-38);
the.good/.badself-heal copies of register selections are written
0600 and atomically (F-39). - A rule's release-to-safe reports the command's verdict: a safe recipe
that did not write or verify is logged and shown as FAILED, not as
"released to safe" (F-10). - Dead-man reverts are consistent on both paths: a revert runs even if
writes were disabled or the device locked after the limit was placed
(otherwise the limit would stay forever), and the raw-register revert is
audited like every other write (F-11). - Commands meet the written registers' own
write_*envelope, refuse to
write input or discrete registers, and refuse a value the encoder would
have to clamp; the raw/writeroute refuses the clamp case too and its
audit record carries the words that went out (F-12, F-15). - A backup import or snapshot restore is checked with the live routes'
rules before a byte is written: allowlist and trusted-proxy entries must
parse, the webhook must be http(s), write flags must be booleans, HTTP
sources must be on the LAN unless allowed, rules must validate. A live
allowlist entry that does not parse now denies every client until fixed
instead of silently admitting everyone (F-18). - Saving a user template removes any other file carrying the same id, and
deleting one removes every file carrying it, so a hand-copied twin can no
longer shadow a tightened envelope or resurrect a deleted template (F-20). - The release workflow runs the test suite, lint, the vendor checksum and
pip-audit on the tagged commit before it builds, and publishes a
Sigstore-signed build provenance for both images, verifiable with
gh attestation verify(F-25).
Low and informational findings fixed in the same release:
- The serial-bridge container gets the gateway's privilege model
(cap_drop: ALL+ the entrypoint's five capabilities, no-new-privileges,
digest-pinned base image) and its unauthenticated RS-485 endpoints sit
on a private internal network shared only with the gateway (F-24). - Virtual meters cap client connections per meter (
transport. max_connections, default 16; the surplus is closed) so a peer holding
sockets open cannot exhaust file descriptors (F-60);transport. strict_unit_id: truemakes a meter answer only its configured unit id
(F-61, opt-in — the default still answers any id, as before); the
holdwindow counts from the moment a row went stale, not from the
value's own timestamp, so rows whose freshness bound exceeded the hold
time hold at last (F-62). - MQTT-in sources cap the payload at 64 KB, survive a deliberately deep
document, and skip one hostile field instead of dropping the whole
message (F-63). - The last-known-good timer, unit freshness in installation aggregates
and the MQTT heartbeat use the monotonic clock, so an NTP step no longer
marks every unit offline or short-circuits the LKG deadline (F-58). - A failing lease revert logs one ERROR, then retries at debug level and
logs its recovery, instead of twenty ERROR lines a minute (F-59). mbg/rules/<id>/setmay disable a rule but never enable one (enabling
an armed rule is arming it, which stays with the API); a clamp must be a
finite non-negative number with an expiry of at most a week (F-74).- Command leases and raw-write leases live in separate namespaces, so a
raw leased write can never replace a command's recipe revert (F-75). /api/query/batchis capped a...
Multi-Bus Gateway 3.82.1
Multi-Bus Gateway 3.82.1 is a documentation release for the public repository: a README that says what the gateway is and where it runs, screenshots recaptured from the current UI, a regenerated device catalog, and one lint fix. Images: ghcr.io/sm26449/multi-bus-gateway:3.82.1 (amd64 + arm64).
2026-09-24 — documentation for the public release
- README (English and Romanian): the opening says what the gateway is and
where it runs — the author's own PV site since the 3.0.0 release, with
the hardware named and the less-exercised parts called out — and the
Janitza ancestry moved to a History section at the end. - Device catalog regenerated: it had been generated before the three
Fronius Solar API maps existed, so it listed 13 of the 16 bundled
templates; the READMEs quoted 11. One number everywhere now. - Screenshots recaptured from the current UI (installations, unit
workspace, commands, rules); the visual guide gained sections for the
pages that did not exist when it was written, and the capture script
(tools/e2e/capture_docs_screenshots.mjs) is in the repository so the
images can be regenerated consistently. - The plant-wizard e2e script selected a button by its former inline
handler; it uses thedata-actionattribute now. - The Templates page showed an amber "non-canonical field names" warning
for the bundled Fronius SunSpec inverter map: three scale-factor
registers that no hardware in the field implements are deliberately
left out of the curated set, and the lint mistook "not curated" for
"routed". Registers without defaults are skipped by the lint now.