Releases: tesseractAZ/zwave
Release list
v0.32.0
route-churn fired on a definition, not on the mesh.
The detector shipped in v0.29.6 and has never fired on the reference mesh. Why
it has not fired is still unmeasured — a stable mesh genuinely may not re-route
four times in ten minutes. What the investigation did establish is separate and
worse: the detector's input carried a false-positive vector, so the quiet
was not evidence of correctness. The codebase held TWO definitions of the
concept it counts.
zwaveData kept a private routeKey() that collapsed "no LWR data" and
"direct link" to the same empty string, while evidenceStore.routeKeyOf()
correctly distinguished null from 'direct'. Under the private copy, a routed
node whose lwr blinked scored two route changes — one when the data
vanished, one when it returned — for a mesh that had re-routed nothing.
route-churn fires at four in ten minutes, so two driver hiccups could have lit
up every routed node at once with an entirely fabricated symptom.
The two definitions are now one exported function. routeKeyOfLwr returns
null for absent statistics, 'direct' for an empty chain, 'r<a>-<b>'
otherwise; isRouteChange requires both endpoints known and different. A route
that cannot be seen has not moved — 'direct' is a fact about the mesh, null
is a fact about our knowledge of it. This is the discipline dS2Resync already
carried, where a dark log lane records null rather than a fabricated 0.
route-churn recoveries are now measured instead of written off.
The symptom mapped to the none recovery metric — permanently unverifiable —
justified in a comment as "multi-node or mesh-scoped". That justification was
false for it: route-churn is emitted per node, with a nodeId, off a per-node
event accumulator, structurally identical to s2-desync, which was always
scored. Its remedies being physical is no reason to refuse to measure them;
weak-signal and s2-desync are physical too.
It now scores on the route metric — re-routes subsiding — gated on
routeKnown, the count of samples where a route was actually on record. That
gate exists because the route-key fix creates the hole it closes: a node whose
lwr goes dark now correctly scores ZERO changes, and without the gate that
clean run of zeros would read as a cure with no evidence behind it. The
after-window additionally requires the node to still be alive, since a node that
stopped talking cannot re-route and has not settled anything.
Six mutants pin this release, all killing: the route-key conflation, the
both-endpoints-known guard, the metric mapping, the visibility floor, and the
two pre-existing detector guards. 640 tests.
What was measured, and what it does not show. Polling every node's
last-working-route on the reference mesh — 14 samples over 7.8 minutes, 494
node-transitions — found zero re-routes and zero visibility blinks. The
mesh is quiet. That does NOT downgrade the defect: an lwr blink is least
likely in steady state and most likely at a driver restart or a re-interview,
which is precisely the moment it would hit many nodes at once and manufacture
the mass false positive. A steady-state window cannot sample the failure mode.
It also cannot settle whether four-in-ten-minutes is the right threshold; that
needs the engine's own long-horizon evidence, which now records it correctly.
Documentation: §7.2.3 listed route-churn as declared, not built eleven
releases after it was built; the recovery-metric table omitted s2 entirely.
Both corrected, and §7.2.4 gains the detector's firing conditions and the route
identity contract.
v0.31.2
Auto-ping was working the whole time. Its evidence was in the wrong place.
v0.31.1 shipped a decision trace on the belief that auto-ping was a no-op. It was
not. It had been probing correctly since it was enabled:
34 of 36 ping buttons pressed, at exactly one-minute intervals, all at :19s
— the one-per-tick rate limit and tick offset, behaving as designed
And it worked. Ten nodes that had been silent for 35.7 HOURS were probed and
ANSWERED; their driver lastSeen now carries the probe timestamps
(n17 20:24:19, n24 20:25:19, n23 20:26:19, n49 20:28:19 — consecutive minutes).
The operator's original premise — that a quiet node comes back when pinged — is
confirmed by data.
The defect was observability, and it is real. logAction() writes ONLY to the
in-memory event ring behind the login gate (the TUI Log screen); it never touches
stdout. Auto-ping logged exclusively there, so every probe it fired was invisible
to anyone reading the add-on log — which is where an operator looks, and where
the diagnosis looked. The feature was declared broken because its evidence sat
somewhere the investigation never went.
Every autonomous action and every decision trace now goes to BOTH: the event ring
for the Log screen, and the server log for ha addons logs. A mutant pins it,
because ring-only logging is precisely the state that produced a confident wrong
conclusion.
Three separate measurement errors this session reached the same shape — a query
truncated by recorder retention read as "no dead episodes", a counter comparison
over an idle mesh read as "inconclusive", and a log grep in the wrong stream read
as "zero probes". In each case the system was fine and the instrument was wrong.
v0.31.1
The auto-ping runner now says why it did nothing.
v0.31.0 shipped enabled, healthy, and doing nothing — and there was no way to
tell that from the outside. The runner spoke only when it ACTED, so "there was
nothing to do" and "this is broken" produced byte-identical logs: an empty one.
Diagnosing it meant reading the source and guessing, and two of those guesses
were wrong.
Found by using it. With auto_ping_enabled: true on a live 39-node mesh, zero
probes fired. Forcing auto_ping_stale_min down to 2 and leaving it ~11 minutes
still produced zero — while the driver reported node 32 silent for 15.5 HOURS.
The boot log confirmed the runner had started (its config line prints from inside
the start branch), driver-ws was live with a 39-node state dump, statistics were
subscribed, and there were no errors on either boot. Everything observable said
healthy; nothing observable said what the runner was deciding.
Each tick now emits its decision and its inputs:
auto-ping: candidates=36 dead=0 stale-due=12 stalest=931m -> probing 1
auto-ping: candidates=36 dead=0 stale-due=0 -> suppressed: boot-window
Emitted on CHANGE, so a transition is never missed, plus a 30-minute heartbeat so
a steady state stays visible — at info level, because an operator should not have
to raise log_level to find out whether an autonomous feature is alive. Every tick
also goes to log.debug for real debugging.
The underlying cause of the no-op is NOT fixed here, deliberately. The earlier
hypothesis (that mergedStats masks staleness by taking the max of a
counter-derived stamp and the driver's own lastSeen) is weakened by the 2-minute
result: if staleness were merely being masked, a 2-minute window should still
have caught nearly every node. Instrument first, then fix what the instrument
shows — shipping a fix now would risk "fixing" the wrong thing and calling it
verified.
v0.31.0
A node nobody talks to was never proven alive.
Z-Wave JS sets Dead REACTIVELY — only when a transmission to a node FAILS. It
is not a timeout. A device nobody addresses produces no transmissions, therefore
no failures, therefore reports Alive indefinitely: a mains outlet could be
physically unplugged and still read "Alive" until something happened to reach it.
v0.30.0's auto-ping only helps AFTER a node has been proven dead that way, so for
a device nobody uses the trigger may simply never arrive.
Measured on the live 39-node mesh, and this is what prompted the feature:
10 of 38 nodes silent for 35.7 HOURS — every one reporting Alive
(n17 n23 n24 n30 n31 n40 n44 n45 n49 n50, all mains, all status=4)
Their lastSeen values cluster within TWO SECONDS of each other at
2026-08-02T15:37 — a batch event (the last controller restart), after which none
of them was ever heard from again.
auto_ping_stale_min (default 240) closes it. Each mains node is probed that
long after ITS OWN last contact, so the cadence is self-balancing: a device that
reports on its own keeps resetting its clock and is never probed, while a silent
one is checked every four hours. Silence becomes evidence — the node either
answers (refreshing lastSeen, and its route/RSSI statistics with it) or the send
fails and the driver marks it Dead, at which point v0.30.0's remediation path
takes over with its own dwell, backoff and attempt cap.
Guards, each tested and mutation-covered:
• ONE probe per tick, stalest first — 36 mains nodes coming due together would
otherwise fire 36 sends in a single second; ordering stops any node starving
• one probe per node per window: an unreachable node never refreshes lastSeen,
so without this it stays permanently "due" and would be re-probed on EVERY
tick, forever
• a node with no lastSeen at all is treated as maximally stale — never having
been heard from is the strongest reason to ask
• Dead nodes are skipped: they belong to the remediation path, and probing them
here would bypass its dwell, backoff and cap
• mains only, and the same storm / boot-window / rebuild / write-actions gates
• auto_ping_stale_min: 0 disables it outright
v0.30.0
The engine acts for the first time — narrowly, and off by default.
Everything this engine did until now was advisory: detect, explain, recommend,
and a human presses the key. auto_ping_enabled breaks that rule on purpose and
in exactly one place, so the rule stays meaningful everywhere else.
Ping is the right and only candidate: it is already the one action the TUI runs
WITHOUT a typed CONFIRM, because it is idempotent and has nothing to undo. A ping
to a live node is a no-op; to a dead one it is a probe. Nothing here can remove a
node, rewrite a route, or change a device's configuration.
The 10-minute dwell is measured, not guessed. Six dead episodes on the live
mesh across three days:
West Closet Motion 0.8 min -> self-recovered
Hallway Closet Motion 1.5 min -> self-recovered
Dining Room Lamp 5.0 min -> self-recovered
Garage Workroom 5.1 min -> self-recovered
Garage Workroom 361.4 min -> cleared by hand
Hallway Closet Motion 531.4 min -> cleared by hand
The distribution has a clean gap: self-healing finishes inside ~5 minutes, while
a stuck node runs SIX TO NINE HOURS. A 10-minute dwell sits in that gap — long
enough never to interrupt the mesh healing itself, short enough to turn a
six-hour outage into a ten-minute one. All four affected nodes are mains-powered
and expose a working ping button, so all four would have been eligible.
That evidence was nearly missed. A 14-day history query returned almost nothing
and was reported as "this mesh never fails" — but the recorder silently DEGRADES
a query whose start predates retention, returning one synthesized row per entity
instead of an error. The tell is cheap: a SHORTER window returning MORE rows
means the longer one is lying (3 days = 534 rows; 7 days = 153).
Whether a ping actually clears those long outages remains unproven, so the
feature instruments itself: every attempt lands in the M5 ledger against the
node's open episode, and efficacyFor('dead-flap', 'ping') turns "usually wakes
them up" into a measured recovery rate on REMEDY. If that rate comes back poor,
the honest answer is to switch this off, and the data will say so.
Gates, every one of them tested and mutation-covered:
• auto_ping_enabled — OFF by default
• obeys write_actions_enabled even when its own switch is on: auto-ping is a
write, and a read-only add-on that pings would be lying
• MAINS-POWERED nodes only. ASLEEP IS NOT DEAD — battery and FLiRS devices
sleep by design and answer on their own wakeup; a ping cannot reach one
before then and spends charge to fail. isListening === null means "not
interviewed", which is not a licence to probe on an assumption
• 10-minute dwell, then 10/30/60-minute backoff, capped at 3 per outage
• STORM GUARD: a quarter of the mesh Dead at once is a controller wedge or a
driver restart, not per-device failure — probing 20 nodes into a struggling
controller only adds traffic. Absolute floor of 4, so 1-of-4 on a small mesh
is not a "storm"
• suppressed in the restart window and while routes are rebuilding
• recovery clears the attempt budget, so a device that fails again next month
is helped again rather than inheriting an exhausted one
Also: route-churn finally has a detector. The SymptomKind, its planner card
and its outcomes handling have existed since the planner was written, but nothing
ever emitted it — grep "kind: 'route-churn'" returned 0 — so REMEDY could never
surface route churn and the card was unreachable. The evidence was being
collected the whole time: dRouteChanges is an event-accumulator drain on every
sample, exactly like dFlaps. Fires at ≥4 LWR changes in 10m with the same dwell
and recency conjunct dead-flap uses, and never for Long-Range nodes — they hold
one direct link with no routes to churn, which the planner card already says.
What the harness caught (all self-inflicted)
An off-by-one against this file's own docstring: BACKOFF_MS[tries] made the
first gap 30 minutes while the ladder documented 10/30/60.
The sleeping-node guard — the most safety-critical one — was unprotected, and
it took three rounds to make it testable. The check lived in an isEligible()
helper, in the decision filter, AND in trackEpisodes; each was individually
sufficient, so removing any one changed nothing observable and no test could pin
it. Duplicated safety checks are not defence in depth: they are three places to
believe a rule is enforced while none of them provably is. Collapsed to one
isPingCandidate predicate used by both call sites.
And the first runner test asserted that nothing was pinged after stopping the
handle before its timer could fire — true, and proof of nothing. The runner now
exposes tick() so a test drives it deterministically.
v0.29.5
Security: fast-uri host confusion (2x HIGH, GHSA-7p8r-x3mc-p8w7).
Dependabot raised two high-severity alerts against fast-uri, reachable twice
in the tree: fastify → @fastify/ajv-compiler → ajv → fast-uri@3.1.4 and
fastify → fast-json-stringify → fast-uri@4.1.1. The flaw is host confusion via
a backslash authority introducer — a URL parser can be talked into reading a
different host than a reader expects.
That is the same class of bug fixed by hand in v0.29.3, where the ingress
redirect had to start rejecting \ and // prefixes precisely so a header
could not redirect a browser to another origin. Worth noting the app's own guard
was written before the library one surfaced: the validation there does not defer
to fast-uri for the security decision.
Patched to 3.1.5 / 4.1.2 via the lockfile only. Fastify itself stays at 5.10.0 —
no runtime or API change — and npm audit --omit=dev now reports 0
vulnerabilities.
v0.29.4
Home Assistant now pulls a prebuilt image instead of building on your Pi.
publish-release.yml has published multi-arch GHCR images since v0.29.2, but
config.yaml deliberately carried no image: key while they were still private.
Both packages are public now and were verified anonymously pullable before this
key was added, so Supervisor pulls ghcr.io/tesseractaz/{arch}-zwave-tui and an
update takes seconds rather than minutes of on-device npm build.
The ORDER matters and is recorded in config.yaml so a future change keeps it.
The failure is asymmetric: publishing images nobody consumes is harmless, while
declaring image: against a package that is missing or private makes Supervisor
pull a tag it cannot fetch and EVERY install and update fails. So: publish, make
public, prove the pull, then point at it.
Verifying that pull took three wrong attempts, all mine, and each one looked
like a broken package:
• a bare manifest GET returns 401 even for a PUBLIC image — the Registry v2
flow answers with WWW-Authenticate and expects you to fetch a token. What
distinguishes public from private is whether an ANONYMOUS token is granted.
• with a token it returned 404, because the Accept header omitted
application/vnd.oci.image.manifest.v1+json. buildx with provenance:false
pushes a plain image manifest, not an index, and the registry 404s when it
cannot satisfy the offered types.
• the contract test then failed for a fourth reason: its regex matched the
owner segment with [^\s:]*, but ${{ steps.owner.outputs.name }} contains
SPACES, so it matched nothing and would have passed vacuously in exactly the
direction that matters. GitHub expressions are now collapsed before matching,
and the guard is re-verified against a planted mismatch.
v0.29.3
The Home Assistant sidebar panel showed a bare "404: Not Found".
Everything about it pointed away from the real cause. The panel was registered
correctly — get_panels showed local_zwave_tui with the same shape as the
working Power panel — and the add-on served every route it should
(/ → 302, /console → 200, /api/health → 200). The address bar stayed on
/local_zwave_tui throughout, so it read as a broken panel registration.
It was the redirect target. Home Assistant loads an ingress panel at
/api/hassio_ingress/<token>/, and the landing route replied
Location: /console — an ABSOLUTE path. The browser discards the ingress prefix
and asks Home Assistant itself for /console, which HA does not serve. Hence a
404 rendered inside an otherwise-healthy HA, from a redirect that had thrown its
own path away.
The rest of the console page was already ingress-safe — relative asset URLs
(./console/xterm.js) and a WebSocket URL derived from location.pathname.
This one line was not, and nothing covered it.
The fix reads X-Ingress-Path and prefixes the redirect. It lives in
auth.ts as ingressRedirectTarget() rather than inline at the route, so the
test and the mutant target the SAME code: the first version of the test
re-implemented the rule in the test file, which proves only that the rule is
self-consistent and would have let the mutant survive.
That header is attacker-controllable, and the first cut of this fix handled it
badly in three ways. CodeQL's gate caught one — js/polynomial-redos,
security-severity 7.5: trailing slashes were trimmed with /\/+$/, which
backtracks quadratically on a long run of / supplied by the caller. Reviewing
it turned up a worse one the scan did NOT flag: //evil.com is a
protocol-relative URL, so Location: //evil.com/console would have sent the
browser to another origin — an open redirect, introduced by the fix. A clean
scan is not the same as a safe input path.
The header is now validated rather than sanitised: rooted single slash only
(a second one disqualifies it), no CR/LF/backslash, a 256-character cap, and a
linear charCodeAt trim instead of a regex. Anything that does not look like an
ingress path falls back to /console — a redirect is not worth guessing at.
Direct (non-ingress) access on :8788 carries no header and still lands on
/console.
v0.29.2
Release automation, container images, and a crash on shutdown.
The release pipeline had a missing link, and it was not theoretical: v0.29.0 and
v0.29.1 both merged to main and sat UNTAGGED and unreleased, because
publish-release.yml only fires on a pushed tag and nothing pushed one. Tagging
was a manual step that looked automatic. tag-release.yml now closes it —
a "Release v…" commit touching config.yaml creates the tag and starts the
release. It dispatches explicitly rather than relying on the tag push, because a
tag pushed with GITHUB_TOKEN does not trigger other workflows; miss that and you
get a tag with no release, which is a silent half-failure. release.yml adds the
one-click bump (both version files — the suite pins them together).
publish-release.yml now also builds and pushes multi-arch GHCR images
(amd64 + aarch64 on native runners), reading BUILD_FROM from build.yaml rather
than a hardcoded matrix so an image can't be built on a different base than a
source install gets. The .docx/.pdf manual was already attached to every
Release and still is. image: is deliberately NOT set in config.yaml yet — see
the note there for why the order matters.
A crash on shutdown, found by CI failing the v0.29.0 release. stop() called
removeAllListeners() and then close(); on a socket still CONNECTING, ws emits
'error', and an 'error' event with no listener is re-thrown by EventEmitter as an
uncaught exception on a later tick — outside the try that wrapped the close. So
shutting down while a reconnect was mid-handshake killed the process, which is
exactly the flapping-Core churn this client exists to survive.
Also: seven contract tests now pin the release relay itself — the watched path,
the "Release v" subject shared by two workflows, that the dispatched workflow
exists and takes a version, that both version files move together, that
config.yaml and the publisher agree on the image name, that every workflow
parses, and that no action is on a floating tag. Every one of those joins is a
string match against another file, and none of them was checked before.
A driver 0 was being drawn as the strongest link on the mesh.
Found the moment the TUI became reachable for testing. Live on the 39-node
network, node 30 reported repeaterRSSI [0, 0] — and Topology rendered it as
n3(+100)→n5(+100), a +100 dB margin on a row whose genuine hops read +14 to
+32. Every real reading on the mesh sits between -68 and -86 dBm; there is
nothing between -67 and -1.
0 is the driver's other "no reading" placeholder, and it is not in the
documented set (127 not-available / 126 receiver-saturated / 125 no-signal). So
every call site that ENUMERATED those markers let it through, and a positive
value passed to a margin calculation ranks as the best link on the network —
the exact defect the sentinel guard exists to prevent, one value short.
The guard was also duplicated in seven files, and only dataProvider.ts
had it right: it alone tested v < 0. The domain rule had already been
discovered once and never propagated, so the same question — "is this a
reading?" — had two different answers in one codebase. health.ts even
documented the correct intent ("a finite RSSI in real dBm range") while
checking only the marker list.
There is now ONE definition, rssiReading(), and it tests the DOMAIN RULE
rather than a list: a reading is a finite negative number. A marker list can
only ever be as current as the last driver release; the physics cannot go
stale. Every screen, sort key, history filter and statistic routes through it,
and the six duplicate definitions are deleted.
Beyond Topology, this also fixed: the Overview signal cell, the Heatmap margin,
Detail's per-hop chain and both history sparklines, the health score's RSSI
gate, and the sort by signal key — where a 0 sorted as the single strongest
node on the mesh, i.e. the opposite end of the list from where an unknown
belongs.
v0.29.1
The startup log told the truth about the telnet listener's auth posture.
Found while live-verifying 0.29.0: the boot line read
telnet TUI on :::2324 (no auth — trusted LAN only)
on a deployment where the login gate is ON, required on ingress, with write
actions enabled — and the very next thing that happened was the telnet session
presenting a login prompt. The suffix was a hardcoded string printed on every
boot regardless of the policy handed to the listener two lines earlier, so it
was a false statement about a security control in the one place an operator
goes to check that control. (It was equally uninformative when auth was off:
right by accident, not by derivation.)
Nothing covered it because a log line assembled inline during boot is not
reachable from a unit test. The description is now describeTelnetAuth() — a
pure function beside the policy it describes — with a test and a mutant.