Skip to content

Repository files navigation

Z-Wave TUI

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.

The Overview screen: a live node table sorted worst-health-first, with per-node health scores, SNR margin, RTT, timeout rate, hop count, data rate and triage flags

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_js config-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.

The advisory engine

Beyond the live dashboard, the engine runs a pipeline that turns raw statistics into diagnoses and recommendations — advisory, everything grounded in measured evidence:

  1. 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.
  2. 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.
  3. 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.
  4. 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.
  5. 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.
  6. 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.

Screens & keys

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. 19 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?":

The Detail screen scrolled to its LIVE ENTITIES and CONFIG PARAMETERS sections: a motion sensor reading detected, a light on at 70 percent, a power sensor at 38.4 W, and five Z-Wave configuration parameters with their decoded enum meanings

Six more screens — Controller, Topology, Heatmap, Log, Remedy, Interference (Engine, screen 9, has no screenshot yet)

Controller — radio health, noise floor, counters

Controller screen

Topology — hop-grouped route tree

Topology screen

Heatmap — nodes by area, graded by SNR margin

Heatmap screen

Log — driver events, value changes, command outcomes

Log screen

Remedy — diagnoses and ranked recommendations

Remedy screen

Interference — noise floor, serial health, diurnal heatmap

Interference screen

Screenshots are generated from a synthetic demo mesh by zwave_tui/server/scripts/gen-screenshots.mts — regenerate them with cd 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).

Health score

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.

Write actions & safety

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).

The DEVICE ACTIONS menu, headed with the target node, showing three groups: MAINTENANCE with ping, refresh values, re-interview, rebuild node routes and remove failed node; DEVICE CONTROLS with on/off/toggle rows each showing the device's current state; and CONFIGURATION rows for editing writeable Z-Wave parameters. Unlocking a lock and opening a garage door are badged DESTRUCTIVE

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.

The NETWORK ACTIONS menu, headed "whole mesh" with no device target, listing the mesh-wide operations under a MESH-WIDE group heading: Rebuild ALL routes, badged DESTRUCTIVE

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.

Performance

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.

The machine-readable boundary

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.

Install

Requires Home Assistant OS or Supervised, with the Z-Wave JS integration already set up.

  1. In Home Assistant: Settings → Add-ons → Add-on Store → ⋮ → Repositories, and add this repository:
    https://github.com/tesseractAZ/zwave
    
  2. 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.
  3. Start it. No configuration is required: the add-on auto-discovers your zwave_js config entry and builds the node roster from the device/entity registries.
  4. 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_tui on the HA host, reload the store, and install local_zwave_tui. That's the workflow the maintainer uses for fast iteration.

Releasing a new version

(Maintainer notes.) Releases are fully automated by a three-workflow relay:

  1. Bump the version in zwave_tui/config.yaml (version:), zwave_tui/server/package.json and zwave_tui/server/package-lock.json (both its top-level version and packages[""].version; running npm version X.Y.Z --no-git-tag-version in zwave_tui/server sets both server files) — CI enforces the lock-step (configContract.test.ts) and tag-release.yml refuses to tag when config.yaml and package.json disagree — add a ## X.Y.Z — DATE section to zwave_tui/CHANGELOG.md, then squash-merge with a subject that starts with Release vX.Y.Z — that prefix is the trigger; CI gates the PR as usual. (release.yml is the one-click alternative: a workflow_dispatch that performs the same bumps, writes the CHANGELOG section, and opens that release PR for you.)
  2. tag-release.yml sees the merge subject, pushes the vX.Y.Z tag, and then dispatches publish-release.yml for that version (workflow_dispatch) — a tag pushed with the default GITHUB_TOKEN cannot trigger another workflow by itself. No manual tagging.
  3. publish-release.yml checks 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.

Local development

  • zwave_tui/ — the add-on: config.yaml, Dockerfile, build.yaml, rootfs/ and server/. 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 with tsx (no build step). npm test runs the node:test suite (count and run time under Performance); npm run typecheck is the CI gate; npm start runs the server.
  • node server/scripts/mutation-check.mjs (from zwave_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: SURVIVED is a fix no test protects, MISSING means the script has drifted from the code, AMBIGUOUS is an anchor that matches more than one site (so the mutation would land on a site nobody chose), INVALID is a mutant that does not compile — a broken build makes every test fail to load, so counting it as a kill would prove nothing — and RELABEL is a mutant still marked equivalent that the suite now kills, so its label is stale. All five fail the run; an anchor pre-flight rejects MISSING and AMBIGUOUS before any mutant runs. It also checks the suite is green before it starts (on an already-red tree every mutant would falsely report killed) and refuses to run twice at once.
  • The browser console (/console) vendors xterm.js from node_modules — no CDN, so it works behind the Ingress token prefix.

License

MIT © 2026 Eric Paschal — see LICENSE.

About

Home Assistant add-on: a telnet + browser control-room TUI for a Z-Wave JS mesh — node health scored worst-first, per-device state + Z-Wave config, write-gated control, and a learned advisory engine that detects mesh symptoms and recommends grounded fixes. Publishes its conclusions as HA entities for your own automations. Read-only by default.

Topics

Resources

Security policy

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Used by

Contributors

Languages