A telnet control-room terminal UI for a Home Assistant Z-Wave JS mesh — and a learned remediation engine that watches that mesh over time, learns each node's normal, and turns anomalies into grounded, ranked recommendations. Recommendations are never acted on automatically; the one autonomous mesh write is the opt-in, off-by-default auto-ping (see Write actions & safety).
It talks to the Home Assistant Core WebSocket (the node roster, live statistics, and — behind a typed confirmation — maintenance, device-control, and config-write actions) and, strictly read-only, to the Z-Wave JS driver WebSocket for the real background-noise floor and capability flags that HA does not expose. It persists a per-node evidence time-series, scores every node worst-health-first, detects mesh symptoms, and recommends fixes — across nine screens, over a telnet server and a browser console, and publishes what it concludes as Home Assistant entities.
One engine, three front doors — two for people, one for machines:
- Telnet on port
2324— a full-screen terminal on your LAN. - Browser console in the Home Assistant sidebar (HA Ingress) — works inside the HA mobile app, no extra ports exposed.
- Home Assistant entities +
GET /api/health— everything the engine concludes, in a form an automation or a monitor can act on. See The machine-readable boundary.
The Home Assistant add-on — including the Node/TypeScript server — lives in
./zwave_tui; the server source is under
./zwave_tui/server. Everything the image needs sits
inside zwave_tui/ — CI builds the multi-arch image from that one directory,
and installs pull it from GHCR.
Works with any Z-Wave JS network. Nothing about a specific controller or mesh is hard-coded: the
zwave_jsconfig-entry id is auto-discovered at startup and the node roster comes from the device/entity registries. Developed and tested against a Zooz ZST39 LR 800-series controller on a ~39-node mesh.
Beyond the live dashboard, the engine runs a pipeline that turns raw statistics into diagnoses and recommendations — advisory, everything grounded in measured evidence:
- Evidence store — a persistent per-node time-series on
/data(a fine ring plus a downsampled multi-day coarse tier), with fabrication guards so a counter reset or a quiet window never invents a reading. Survives restarts. - Baselines — each node's "normal" is learned per time-of-day band across several distinct days before its detectors may fire; symptomatic windows are quarantined so a fault can't teach the baseline to accept itself.
- Symptom detectors — degraded return path, dead-flapping, node down (a node the driver has marked Dead, past a short dwell), a quiet node (a mains node that still reads Alive but has gone unheard for at least three liveness-sweep cadences, never less than 6 h), rate fallback, high RTT, weak signal, route churn (the mesh cannot settle on a stable path — usually one marginal repeater), a chatty flooder, a suspected ghost, controller serial-link strain, S2 nonce-resync storms (a marginal secure link, which no statistics counter can see — it is read from the driver's own log stream), and correlation across nodes: an edge-cluster (a small group sharing one repeater) and a mesh-wide interference event, which subsume the per-node symptoms beneath them so you see one cause, not N faults. A detector that counts events (flaps, timeouts, S2 resyncs, re-routes) only fires while the condition is still happening — a burst that ends de-asserts rather than maturing into a "persistent" symptom. Rate fallback is the exception: a slow reading within 30 s of the last counted one is the same exchange, so one retried exchange never fires it, but once two separate slow acknowledged transmissions follow a 100 kbps one on the same route, the newest reading decides — it holds until a 100 kbps reading, or a reading on a different route, clears it, which on a node that only the liveness sweep addresses can be hours. While the driver reports no route, or a different route that has carried no reading yet, the symptom is withdrawn but its evidence is kept: it returns after the 5-minute dwell if the same route reappears, with no new reading needed.
- Planner — each symptom becomes a ranked set of recommendations: physical guidance first (most Z-Wave fixes are physical — move a repeater, power-cycle, relocate the stick) plus any safe executable probe. Safety gates fail closed; a route rebuild is only ever shown to say not to.
- Outcome learning — when a symptom resolves, the engine records whether the action beat the mesh's own spontaneous-recovery rate, scored per symptom kind by the signal its fix actually moves. It only claims an action "helped" once it clears that control arm by a real margin.
- Interference watch — the real 900 MHz noise floor (recovered from the driver WebSocket, since HA strips it), controller serial-link health shown apart, a diurnal timeout heatmap, and a persisted multi-day noise-floor trend.
Read-only by default. Every operator action is human-gated behind a typed
CONFIRM; the engine's recommendations are never self-executed. The one
autonomous mesh write — the opt-in auto-ping probe — is documented in
Write actions & safety.
The Overview node list is home; every other screen is an overlay that
dismisses with q / Esc.
| # | Screen | What it shows |
|---|---|---|
| 1 | Overview | Live node table, worst-health-first, summary bar + per-node flags. |
| 2 | Detail | Scrollable per-node dossier: identity, live link, live entity state (is the light on? sensor values, lock state, climate mode…), the device's Z-Wave configuration parameters, LWR/NLWR routes, TX/RX reliability, battery, firmware. |
| 3 | Controller | Node-1 radio health, background-RSSI noise floor, controller counters, rebuild progress. |
| 4 | Topology | Hop-grouped route tree + repeater load + Long-Range star. |
| 5 | Heatmap | Nodes by HA area, cells graded by SNR-margin bucket. |
| 6 | Log | Driver/value/notification events + command outcomes; scroll, filter; error events latch bold-red until acknowledged (M releases the latch, for every session). |
| 7 | Remedy | The engine's diagnoses + ranked recommendations, with learned "helped X%" efficacy. |
| 8 | Interference | Noise floor + recent/multi-day trend, serial-link health, diurnal timeout heatmap. |
| 9 | Engine | The engine's own runtime: auto-ping state and per-node ladder, the episodes it is measuring right now, and every learned rate with the n behind it. |
Keys. 1–9 jump to a screen (c Controller, e Log, y Remedy, f
Interference are shortcuts too). On Overview: j/k move, Enter detail, /
filter, s sort (health · id · name · rssi · seen · symptom), t margin↔dBm. a opens the Actions Menu for the selected
node (on the Controller screen it opens the mesh-wide actions instead);
p pings the selected node (gated); q quits.
Detail is the per-node dossier — it scrolls, and answers both "what is this device doing right now?" and "how is it configured?":
Six more screens — Controller, Topology, Heatmap, Log, Remedy, Interference (Engine, screen 9, has no screenshot yet)
Screenshots are generated from a synthetic demo mesh by
zwave_tui/server/scripts/gen-screenshots.mts— regenerate them withcd zwave_tui/server && npx tsx scripts/gen-screenshots.mts.
Full keybinding and screen documentation is in
zwave_tui/DOCS.md — the complete System & Engine
Reference (also attached to each release
as .docx + .pdf).
A composite 0–100 score + letter grade + discrete state, blending weighted
lanes — reachability, signal margin over the live noise floor + SNR, route
quality, TX reliability, interview — with hard gates: dead → 0, unknown
capped low, a node asleep within its wake interval is not penalized, and
battery is a separate advisory lane that never drags down the RF score.
Long-Range nodes (id ≥ 256) redistribute route weight into signal + reliability.
The TX-reliability signal is the reply-timeout rate (timeoutResponse / commandsTX),
not commandsDroppedTX — which does not count RF ACK failures.
Grade bands: A ≥ 90, B ≥ 80, C ≥ 70, D ≥ 55, F < 55.
Flags: D dead · S stale · W weak signal · F response timeouts · R route
problem · L high latency · I incomplete interview · B battery low ·
U firmware update available (advisory — never affects the score). A trailing
! marks a node the engine holds an open critical finding on, and · a
lesser one — a separate judgement from the score, and it sorts.
The score says how much of itself is assumption. Signal (25%) and Route
(20%) fall back to a neutral value when there is nothing to measure — a routed
node has no usable RSSI of its own — so up to 45% of a grade can stand on
defaults. Detail states the share and which lanes, directly under the gauge: a
C built half out of defaults and a C built out of measurements are different
claims about a device.
Read-only by default. Enable Write Actions is off, so the add-on sends
nothing to the mesh or its devices; it still publishes its own conclusions as
Home Assistant states (see The machine-readable boundary).
Turn the switch on to unlock actions on the selected node. Press a to open
the Actions Menu. It is scoped to what you are looking at — on the Overview or
Detail it offers only actions bounded by the selected node, and groups:
- Maintenance — ping, refresh values, re-interview, rebuild this node's routes, remove-failed.
- Device controls — turn a light / switch / fan on · off · toggle, open / close a cover or garage door, lock / unlock a lock.
- Configuration — edit a writeable Z-Wave parameter through a bounded value picker (enum options or a min/max-checked number).
Mesh-wide actions live on the Controller screen, not in a device's menu.
Press a there for NETWORK ACTIONS — rebuild all routes, or stop a rebuild
in progress. Keeping them apart means a menu headed "target #8 Kitchen Lamp"
can never offer you an action that touches all 39 nodes.
Every row is badged SAFE / CAUTION / DESTRUCTIVE (unlocking a lock or opening a
garage is DESTRUCTIVE), and selecting any of them opens a modal that requires you
to type the literal word CONFIRM before it runs (only a bare p ping stays
immediate). Every outcome is logged. The engine never executes its
recommendations; device control and config writes are operator actions, and
are never fed to the learning ledger.
Auto-ping — the one autonomous mesh write (v0.30, opt-in). With
auto_ping_enabled on (and only under the master write_actions_enabled gate),
the engine probes a mains node that has been Dead past a dwell (default
10 min, then up to 3 attempts with 10/30/60 min waits between them — at the
default 3 only the 10 and 30 min waits are reached, so the engine hands the node
to you about 50 min after death, or about 40 min if it died with a liveness
probe to it unanswered, since that skips the dwell), and issues a liveness
probe to a mains node silent past a threshold (default 120 min — Z-Wave JS
marks Dead only reactively, so an unplugged device can read "Alive" for hours until
something talks to it). It is restricted to ping because ping is idempotent and
has nothing to undo; battery/sleeping devices are never probed; a boot window,
a rebuild suppressor, and a mesh-storm guard (≥25 % dead ⇒ stand down) bound it.
Off by default; every decision is traced to the log and every outcome feeds the
learning ledger.
Swapping the controller — you are asked, nothing is discarded (v0.64.0). The
learned stores (efficacy ledger, per-node baselines, history) are tagged with the
controller that taught them. Attach a different stick — or restore an NVM backup,
which gives a new home id on the same physical mesh — and the add-on stops
using that learning immediately (it describes other hardware) but writes
nothing and deletes nothing. It asks: keep the existing learning under the new
identity, resume this controller's own learning if it has been here before, or
start fresh. "Start fresh" renames the old files aside in /data as
<name>.home-<id>.json, so they can always be inspected or put back by hand. The
decision is answerable even in read-only mode, since it touches nothing on the
mesh, and it raises binary_sensor.zwave_tui_degraded so you hear about it
without having the console open. If you expose the LAN telnet port on an
untrusted network, enable the optional login gate (plaintext or scrypt:
passwords, with a per-peer backoff). The sidebar console is restricted to Home
Assistant administrators — the same position the official Z-Wave JS add-on
takes, and appropriate for a panel that can remove a failed node or unlock a lock.
Measured runtime footprint, render cost, verification cost and what the engine has actually learned on a live mesh: PERFORMANCE.md.
Headlines, each stated as narrowly as it was measured: the slowest screen
redraw is 412 µs against a 1 000 ms frame budget; the container holds
93 MB (that is npm + the tsx loader + the server, not the server's own
RSS) and sampled 0.02 % CPU once, not as an average; at the v0.69.0 release
run, 1 212 tests in 13.0 s and 715 mutants in ~31 min gated the release.
A TUI session costs
4.96 KB/s at 80×24 and 17.22 KB/s at 200×60 — which is not a sampled
rate but one whole-frame redraw per second.
That document also carries what is not measured, and why most of it does not need to be — and, for the two items that did earn measuring, what the numbers overturned. The mutation harness is not startup-bound, as had been guessed: sys is 5 % and the measured spawn floor 6 %, against 85 % spent in the 682 targeted test runs themselves — a phase the harness times whole, so how it splits between transpiling, module loading and running the tests is not measured.
Everything the engine concludes used to be reachable only from a terminal behind
a login gate — which nothing can poll and nobody watches at 3am. /api/health
answered one question, "can this add-on see Home Assistant?", so a mesh with
every node dead still returned 200 OK.
The engine's conclusions are published as ordinary Home Assistant entities, re-asserted every 30 seconds:
| entity | state | notable attributes |
|---|---|---|
binary_sensor.zwave_tui_degraded |
on / off |
reason, published_at |
sensor.zwave_tui_engine |
awaiting-identity-decision / disabled / no-auto-ping / running / suppressed:<why> |
detectors_ready, detectors_unmeasured, detectors_total |
sensor.zwave_tui_summons |
count of nodes needing a person | node_ids |
sensor.zwave_tui_symptoms |
live symptom count | critical, warning, kinds |
sensor.zwave_tui_route_failures |
route failures in the last 7 days (unknown while the feed is blind) |
links (ranked), node_ids, last_failure_at, lower_bound |
The engine sensor's states are listed in the order the code checks them:
awaiting-identity-decision (a mesh identity decision is pending; it outranks
every other state), disabled (no learned-baselines store), no-auto-ping (the
engine is learning but auto-ping is not running, because auto_ping_enabled or
write_actions_enabled is off — the default install), and running /
suppressed:<why>, which appear only with both switches on.
GET /api/health carries the same values — built from the same function, so a
monitor polling HTTP and an automation triggering on state cannot disagree about
the mesh. ok there stays a pure transport verdict and the HTTP status code
still tracks it alone: a degraded mesh is not a broken add-on, and conflating
them would make an existing uptime check flap on a single symptom.
degraded is deliberately not "any symptom exists." A warning-level symptom
on one node is the resting state of a real mesh, and an alert that is always on
is not an alert. It fires on a summons (the remediation ladder has spent its
budget and is asking for a person), a critical symptom, or the engine being
structurally unable to do its job.
There is no built-in notifier, on purpose. The add-on could call
notify.mobile_app_* directly — it has the permission — but that hardcodes a
policy (who is told, when, how loudly, whether it bypasses Do Not Disturb)
into a diagnostic console. As state, your existing notification setup,
automations, dashboards and history all work on it unchanged:
automation:
- alias: Z-Wave needs a human
trigger:
- platform: state
entity_id: binary_sensor.zwave_tui_degraded
to: "on"
for: "00:05:00" # ride out a transient
action:
- service: notify.mobile_app_iphone
data:
title: Z-Wave mesh degraded
message: >-
{{ state_attr('binary_sensor.zwave_tui_degraded', 'reason') }}
(nodes: {{ state_attr('sensor.zwave_tui_summons', 'node_ids') }})published_at is how you know the add-on is still there (v0.66.0). Home
Assistant ignores a write whose state and attributes both match what it already
holds, so on a healthy mesh — publishing off / none every 30 s — the entity
never changed, and a stopped add-on looked exactly the same. published_at is
a heartbeat: the time of the last publish, rounded to the minute so it costs
one recorder row a minute rather than two. It makes last_updated advance on
every publish; last_changed still moves only when the state flips, so the
for: above still measures how long it has been on. Watch for silence:
- alias: Z-Wave TUI has stopped reporting
trigger:
- platform: template
value_template: >-
{{ states.binary_sensor.zwave_tui_degraded is none
or now() - states.binary_sensor.zwave_tui_degraded.last_updated
> timedelta(minutes=10) }}
for: "00:02:00" # a Core restart republishes within 30 s
action:
- service: notify.mobile_app_iphone
data:
title: Z-Wave TUI is not reporting
message: The mesh verdict has not been refreshed for 10 minutes.These are unmanaged states — created over the REST API, with no device and no
unique_id — so they do not survive a Home Assistant Core restart.
Re-publishing on the interval self-heals that within 30 seconds rather than
adding an MQTT dependency. The entity ids are published API: renaming one breaks
every automation built on it.
Requires Home Assistant OS or Supervised, with the Z-Wave JS integration already set up.
- In Home Assistant: Settings → Add-ons → Add-on Store → ⋮ → Repositories,
and add this repository:
https://github.com/tesseractAZ/zwave - Install Z-Wave TUI from the store. Supervisor pulls a prebuilt multi-arch
image from GHCR (
ghcr.io/tesseractaz/{arch}-zwave-tui), so install and updates take seconds — no on-device build. - Start it. No configuration is required: the add-on auto-discovers your
zwave_jsconfig entry and builds the node roster from the device/entity registries. - Open Z-Wave TUI in the HA sidebar, or connect over LAN telnet:
nc <homeassistant-ip> 2324
Optional: turn on Enable Write Actions to unlock the gated actions (see Write actions & safety), and the login gate if you expose the telnet port on a network you don't fully trust.
Developing against a clone? You can also run it as a local add-on: copy the add-on files to
/addons/zwave_tuion the HA host, reload the store, and installlocal_zwave_tui. That's the workflow the maintainer uses for fast iteration.
(Maintainer notes.) Releases are fully automated by a three-workflow relay:
- Bump the version in
zwave_tui/config.yaml(version:),zwave_tui/server/package.jsonandzwave_tui/server/package-lock.json(both its top-levelversionandpackages[""].version; runningnpm version X.Y.Z --no-git-tag-versioninzwave_tui/serversets both server files) — CI enforces the lock-step (configContract.test.ts) andtag-release.ymlrefuses to tag whenconfig.yamlandpackage.jsondisagree — add a## X.Y.Z — DATEsection tozwave_tui/CHANGELOG.md, then squash-merge with a subject that starts withRelease vX.Y.Z— that prefix is the trigger; CI gates the PR as usual. (release.ymlis the one-click alternative: aworkflow_dispatchthat performs the same bumps, writes the CHANGELOG section, and opens that release PR for you.) tag-release.ymlsees the merge subject, pushes thevX.Y.Ztag, and then dispatchespublish-release.ymlfor that version (workflow_dispatch) — a tag pushed with the defaultGITHUB_TOKENcannot trigger another workflow by itself. No manual tagging.publish-release.ymlchecks out that tag, runs the server tests, builds and pushes the multi-arch GHCR images (aarch64+amd64), builds the printable manual (.docx+.pdf), and cuts a GitHub Release with the CHANGELOG notes and the manual attached.
A merge whose subject does not start with Release v changes main without
releasing anything — docs and tooling changes ride along until the next release.
ci.yml (typecheck + tests + docs build + amd64/aarch64 docker smoke builds)
and codeql.yml (CodeQL security-extended; results are uploaded to the
Security tab, and check-sarif.mjs fails the job on actionable findings) are
both required status checks on every PR to main.
zwave_tui/— the add-on:config.yaml,Dockerfile,build.yaml,rootfs/andserver/. Everything Supervisor needs to build lives inside this one directory, because an add-on is built from its own folder — before v0.25.0 the Dockerfile and source sat at the repository root, where a store install could never find them.zwave_tui/server/— TypeScript backend run directly withtsx(no build step).npm testruns the node:test suite (count and run time under Performance);npm run typecheckis the CI gate;npm startruns the server.node server/scripts/mutation-check.mjs(fromzwave_tui/) reverts each behavioural fix one at a time and requires the suite to go red. A green suite proves the tests run; this proves they would notice. It refuses to draw a conclusion it has not earned:SURVIVEDis a fix no test protects,MISSINGmeans the script has drifted from the code,AMBIGUOUSis an anchor that matches more than one site (so the mutation would land on a site nobody chose),INVALIDis a mutant that does not compile — a broken build makes every test fail to load, so counting it as a kill would prove nothing — andRELABELis a mutant still marked equivalent that the suite now kills, so its label is stale. All five fail the run; an anchor pre-flight rejectsMISSINGandAMBIGUOUSbefore any mutant runs. It also checks the suite is green before it starts (on an already-red tree every mutant would falsely reportkilled) and refuses to run twice at once.- The browser console (
/console) vendors xterm.js fromnode_modules— no CDN, so it works behind the Ingress token prefix.
MIT © 2026 Eric Paschal — see LICENSE.