Skip to content
tesseractAZPublic

About

Mesh-observability and remediation engine for Z-Wave JS on Home Assistant: measures every link against the live noise floor, learns each node's normal, runs 14 fault detectors (S2 resync storms, return-path loss, route churn…) and ranks fixes, each scored against the mesh's own recovery. SSH + sidebar console, HA entities. Read-only by default.

Topics

Resources

Security policy

Stars

0 stars

Watchers

0 watching

Forks

Repository files navigation

Z-Wave TUI

A mesh-observability and remediation engine for Z-Wave JS networks on Home Assistant.

It measures every link in the mesh continuously, learns what normal looks like for each node, detects faults the Z-Wave statistics alone cannot show, and turns them into ranked, evidence-backed fixes. Then it checks whether those fixes worked: each one is scored against the mesh's own spontaneous recovery rate. Operators work in an SSH control-room terminal or a browser console in the Home Assistant sidebar. Automations read the same conclusions as Home Assistant entities and a health endpoint.

What the engine does

  • Measures every link. Each node is measured on:

    • signal margin over the live 900 MHz noise floor, which the engine recovers from the Z-Wave JS driver because Home Assistant does not expose it;
    • round-trip time, reply-timeout rate and negotiated data rate;
    • hop count and the last working and next-to-last working routes, with failing hops recorded from the driver's route-failure reports.

    An opt-in liveness sweep sends one routed read per node on a cadence, so mains devices that never speak on their own are still measured.

  • Learns each node's normal. A persistent evidence time-series (a fine ring plus a multi-day coarse tier) feeds per-node baselines.

    • Baselines are learned per time-of-day band, across several distinct days.
    • Windows that contain a symptom are quarantined, so a fault cannot teach its own baseline to accept it.
    • Counter resets and quiet windows never produce invented readings.
  • Detects faults. Fourteen per-node detectors cover:

    • acute and chronic return-path loss;
    • dead-flapping, node down and quiet nodes;
    • rate fallback, RTT degradation and weak signal;
    • route churn, chatty devices and suspected ghosts;
    • missed reports;
    • S2 nonce-resync storms and repeated frames, both read from the driver's log stream, which no statistics counter covers.

    Cross-node correlation folds an edge cluster behind one repeater, or a mesh-wide interference event, into a single cause instead of N faults.

  • Plans remediation. Each symptom becomes a ranked plan, with physical guidance first because most Z-Wave faults are physical, followed by any safe executable probe. Safety gates fail closed, and a route rebuild is shown only to advise against it.

  • Learns from outcomes. Every episode is recorded with before-and-after evidence. An action is credited only when it beats the mesh's spontaneous recovery rate for that symptom by a real margin. A pre-registered refresh trial runs the same comparison as a two-arm experiment.

  • Watches interference. It shows:

    • a multi-day noise-floor trend and a diurnal timeout heatmap;
    • controller serial-link health kept apart from RF, so a USB fault is not mistaken for interference.
  • Protects the mesh. The engine is read-only by default.

    • Every operator write sits behind a typed CONFIRM.
    • The only autonomous writes are the opt-in probes. They are admitted risk-first and pause themselves during a storm.
    • The 0–100 health score states how much of itself rests on defaults instead of measurement.
Server about 32,000 lines of TypeScript; 9 screens; SSH, browser and Home Assistant entity interfaces
Detection 14 per-node detectors, 2 cross-node correlations, controller serial-link health
Verification 1,684 tests; a mutation-testing harness of 1,617 hand-written mutants (13 labelled equivalent), run on the code each release touches
Integration Home Assistant Core WebSocket (roster, statistics, gated actions) plus a read-only Z-Wave JS driver WebSocket (noise floor, capabilities, log stream)

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:

  • SSH on port 2324 — a full-screen terminal on your LAN (telnet until v0.76.1).
  • 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, which moved to a Home Assistant Connect ZWA-2 on 2026-10-16 (same home id, no re-pairing; the engine's learned state carried over).

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), repeated frames (a node that keeps re-sending one frame because it does not hear the controller's acknowledgement: 5 or more duplicate drops in a minute, twice in 24 h or once with 15 or more, read from the same log stream; it holds while those bursts stay inside the 24 h lookback), a missed report (a sleeping device silent past 1.5× — 2.5× for a warning — the Wake Up or heartbeat interval it declares; a device that declares none is not watched), 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, and EVIDENCE — what the engine can see of this node (feeds, samples, probe answers split into routed reads and pings, its learned normal).
3 Controller Node-1 radio health, background-RSSI noise floor, controller counters, rebuild progress.
4 Topology Hop-grouped route tree, route failures by link, measured route stability, 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 driver link (and whether the log lane behind the S2 and repeated-frame watches is live), SLEEPERS — how many sleeping devices the missed-report watch covers and why each other one is not watched — the episodes it is measuring right now, and every learned rate with the n behind it. Z pauses every automatic write.

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

The Detail screen scrolled to the end of the dossier: the live link with signal and latency sparklines, LIVE ENTITIES (a motion sensor reading detected, a light on at 70 percent, a power sensor at 38.4 W), five CONFIG PARAMETERS (two with decoded enum meanings, one read-only), the route, traffic counters, and EVIDENCE

Seven more screens — Controller, Topology, Heatmap, Log, Remedy, Interference, Engine

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

Engine — auto-ping, the refresh trial, and what the ledger has learned

The Engine screen: AUTO-PING running with its last pass, candidates and the nodes owed a sweep; REFRESH TRIAL running and waiting, with the eligible-node count and both arms' counts; the live ledger; and LEARNED, every rate with the n behind it, including the live-span count naming the nodes it came from

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, Detail, Log or Remedy it offers only actions bounded by the node under the cursor (on Log and Remedy, the highlighted event's or symptom's node), and groups:

  • Maintenance — ping, refresh values, re-interview, rebuild this node's routes, remove-failed.
  • Device controls — turn a light / switch / fan / siren on · off · toggle, open / close / toggle 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).

Topology, Heatmap, Interference and Engine have no actions menu.

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), pause or resume automatic writes, and, while a controller swap awaits an answer, the keep / resume / start-fresh identity decision. 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, and Pause automatic writes, badged SAFE

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 — and Z on ENGINE, which pauses automatic writes and sends nothing). Every outcome is logged, and since v0.72.0 it reports what the driver actually said: a route rebuild that returns false is a failure, a config write that is only queued on a mains device is not confirmed, and a rebuild Home Assistant stopped waiting for is outcome unknown, not failed. The engine never executes its recommendations; device control and config writes are operator actions, and are never fed to the learning ledger.

Automatic remediation was evaluated, and nothing qualifies (v0.72.0). Every verb was checked against a five-property rule — no effect a Home Assistant automation reacts to, an in-band result, a bounded frame count, nothing to undo, and an effect the ledger can measure — and none passes, so the automatic set is empty (DESIGN §3.5). Rebuild, re-interview, remove-failed, device control and config writes can never run automatically. What needs a person is published as sensor.zwave_tui_recommendation — at once when auto-ping has given up on a device, and otherwise once a problem has lasted an hour — so your own notification automation can tell you.

The refresh trial (v0.75.0, opt-in). The one route by which a verb could ever be admitted is a pre-registered randomized trial (DESIGN §3.5), and refresh_trial_enabled runs it for the one candidate the evidence supports: when a mains node's rtt-degraded episode has been live for 10 minutes and nothing has acted on it, a fair coin decides between one refresh values and doing nothing. Both halves are scored the same way — a verification burst at +20 minutes and the round trips of the next ten against the episode's own before-window — apart from everything the engine learns, until 30 decisions in each give one verdict (or 120 days pass). Only a node whose refresh asks more than the probe's one Get can take part, so on a mesh of plain switches and dimmers the trial sends nothing at all, and ENGINE says so. It needs write actions and auto-ping running, decides at most once per node per day, twice an hour and six times a day, only 09:00–20:00 and never within 10 minutes of your own action, and a node that goes Dead within 5 minutes of a refresh is excluded for a week (a second such death stops the trial until you restart with it off). Running it admits nothing; a pass only makes an automatic refresh reviewable. ENGINE shows its counts, and sensor.zwave_tui_engine publishes them (DOCS §11.14).

One pause for everything automatic (v0.72.0). Z on the ENGINE screen, or an input_boolean.zwave_tui_pause_autonomy helper you create in Home Assistant, stops auto-ping's sweep, verification and dead-node ladder, and the refresh trial, at once; it survives restarts, fails closed, and once it is older than 24 hours raises the degraded alarm (while auto-ping is running for it to stop). A pause set from the TUI is resumed behind the typed CONFIRM (Controller 3 → A); one set by the toggle lifts when you turn the toggle off.

Auto-ping — the autonomous probes (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 on one of the engine's own probes, since that skips the dwell), and asks every listening mains node not marked Dead on a fixed per-node cadence (default every 120 min, however chatty the node — Z-Wave JS marks Dead only reactively, so an unplugged device can read "Alive" for hours until something talks to it). The dead-node ladder and a manual p send Home Assistant's ping; the liveness sweep, the verification bursts and (v0.78.0) the single rtt-degraded confirmation (one probe per lone slow round trip, to check it) send one routed read instead — zwave_js.refresh_value on the device's own switch/light value, a single Get that changes nothing on the device (its Report can correct a stale Home Assistant state) — because a ping is one ACK-only attempt on the stored routes, and an unanswered one marks a working device Dead: on the reference mesh 100 of 106 mains Dead episodes in 67 days began on one of the add-on's own pings (v0.71.0). A read can still go unanswered; the node is then retried at once and left alone by the sweep for 15 min. Battery/sleeping devices are never probed; a boot window, a rebuild suppressor, a controller RF-off hold (the nightly NVM backup turns the radio off for about ten seconds), and a mesh-storm guard (≥25 % dead ⇒ stand down) bound it. Off by default; every decision is traced to the log. Auto-ping's liveness probes and the ledger's verification probes are measurements, never recorded as a remediation (v0.38.1) — and because a read can re-route a node, an episode that re-route could clear by itself is credited to neither arm (v0.71.0), as is one it may have helped start (v0.78.0); only its dead-node pings count as an action in 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.

Access. The LAN port is SSH (v0.77.0), with password logins for the login gate's users (plaintext or scrypt: passwords, with a per-peer backoff); with the gate off, every SSH login is refused. 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.71.0 release run, 1 298 tests in 13.4 s and 879 mutants in ~43 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.

At verbose (a 6.35 h trial, 2026-10-15) the Z-Wave JS app ran at 0.13 % of one core, the archive grew about 190 KB a day gzipped, and the busiest minute (the nightly NVM backup) reached 44.5 % of the storm guard's limit; what rules verbose out as the permanent level is the host journal.

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 5 %, against 86 % spent in the 879 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, rtt_ready, paused_by, paused_since, auto_remediation, sleepers_watched, sleepers_total, refresh_trial and its six counts (refresh_trial_treated, …_treated_improved, …_treated_deaths, refresh_trial_held, …_held_improved, …_held_deaths), and since v0.76.1 the driver log lane: driver_log_level, s2_lane, silence_watchdog
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 or before the roster has loaded) links (ranked), node_ids, last_failure_at, lower_bound
sensor.zwave_tui_recommendation summons / action / physical / none node_id, node_name, symptom, since, headline, recommendation, action, automatic (always false), …

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, the engine being structurally unable to do its job, or a pause on automatic writes older than 24 hours while auto-ping is running for it to stop.

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.

Logs and backups

The host journal keeps only about a day and a half of every app's log, so the add-on keeps its own durable log archive (v0.75.4): its own log and the Z-Wave JS driver's log stream, redacted before they are written, one file a day under /addon_configs/<slug>/logs, gzipped when finished, 14 days and at most 200 MB by default (log_archive_days, log_archive_max_mb, log_archive_driver). Since v0.76.0 the finished days go into Home Assistant backups, with backup_pre/backup_post hooks that hold housekeeping while a backup copies the folder.

The driver stays at info, its permanent level. verbose adds per-frame transmit detail and is meant for bounded investigations, set at runtime with no restart; a 2026-10-15 trial showed that the host journal, not the archive, is what it costs (PERFORMANCE.md §4c, DOCS §6.11 and §12.9).

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. Turn on Show in sidebar on the add-on's Info page and open Z-Wave TUI there (or use Open Web UI), or connect over SSH from your LAN, as a user of the login gate (turn on auth_enabled and add one first):
    ssh -p 2324 <user>@<homeassistant-ip>
    The host key's fingerprint is on the add-on's startup log line, to compare with what ssh shows on the first connection.

Optional: turn on Enable Write Actions to unlock the gated actions (see Write actions & safety), and the login gate if you use the SSH port (it refuses every login while the gate is off).

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, delete the image: line from the copied config.yaml, reload the store, and install local_zwave_tui. With image: present, Supervisor pulls the published GHCR image for version: instead of building the copied source, so local changes never run and an unpublished version fails to install. Without it, Supervisor builds the Dockerfile on the device, which takes minutes rather than seconds.

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 section (plain, no date) at the top of zwave_tui/CHANGELOG.md, run node .github/scripts/rotate-changelog.mjs, 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 and rotates the CHANGELOG, and opens that release PR for you. It opens the PR with the workflow's own token, and GitHub starts no workflow for an event that token causes, so CI runs only once the PR is closed and reopened.)

    The shipped changelog keeps only the newest releases, under 32 KiB: Home Assistant renders the whole file in one dialog, and a long one took a long time to open. The rotation script moves older sections into docs/CHANGELOG-history.md, which is never shipped. The heading must be exactly ## X.Y.Z: Home Assistant's update dialog then shows the notes from the new release down to the installed one, and the whole file otherwise. test/changelogContract.test.ts enforces both.

  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. Every job, CodeQL included, runs on a pinned ubuntu-26.04 image (native ubuntu-26.04-arm for aarch64 builds).

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, and the same pre-flight rejects UNMAPPED — a test name a mutant or SOURCE_TESTS writes down that has no test file, which the targeted run would otherwise drop without a word. 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

Mesh-observability and remediation engine for Z-Wave JS on Home Assistant: measures every link against the live noise floor, learns each node's normal, runs 14 fault detectors (S2 resync storms, return-path loss, route churn…) and ranks fixes, each scored against the mesh's own recovery. SSH + sidebar console, HA entities. Read-only by default.

Topics

Resources

Security policy

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Used by

Contributors

Languages