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.
-
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.
- Every operator write sits behind a typed
| 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) |
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_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, 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).
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), 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.
- 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, 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?":
Seven more screens — Controller, Topology, Heatmap, Log, Remedy, Interference, Engine
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,
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.
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.
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.
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.
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.
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).
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. - 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_enabledand add one first):The host key's fingerprint is on the add-on's startup log line, to compare with whatssh -p 2324 <user>@<homeassistant-ip>
sshshows 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_tuion the HA host, delete theimage:line from the copiedconfig.yaml, reload the store, and installlocal_zwave_tui. Withimage:present, Supervisor pulls the published GHCR image forversion:instead of building the copied source, so local changes never run and an unpublished version fails to install. Without it, Supervisor builds theDockerfileon the device, which takes minutes rather than seconds.
(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.Zsection (plain, no date) at the top ofzwave_tui/CHANGELOG.md, runnode .github/scripts/rotate-changelog.mjs, 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 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.tsenforces both. -
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. Every job, CodeQL included,
runs on a pinned ubuntu-26.04 image (native ubuntu-26.04-arm for aarch64
builds).
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, and the same pre-flight rejectsUNMAPPED— a test name a mutant orSOURCE_TESTSwrites 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 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.