|
|
1 month ago | |
|---|---|---|
| .. | ||
| config | 1 month ago | |
| tmpfiles.d | 1 month ago | |
| Makefile | 1 month ago | |
| README.md | 1 month ago | |
| overlay.bu | 1 month ago | |
| zwave-js-ui-init.service | 1 month ago | |
| zwave-js-ui.container | 1 month ago | |
| zwave-js-ui.image | 1 month ago | |
| zwave-js-ui.target | 1 month ago | |
README.md
Podman Quadlet: Z-Wave JS UI
Overview
Z-Wave JS UI drives a Z-Wave controller
plugged into the host and exposes it twice: a web control panel on port 8091, and the
Z-Wave JS Server websocket on port 3000, which is what the Home Assistant zwave_js
integration connects to.
This cookbook:
- Runs Z-Wave JS UI as a non-root container (UID 10035) with the controller as its only device.
- Takes the controller under the stable name
/dev/zwave, never a/dev/serial/by-id/...path: which USB stick is the Z-Wave one is a site value, and it belongs in a udev rule of the site, not in this cookbook. - Keeps the security keys, the Z-Wave JS Server settings and the log level out of the web interface, in an operator-provided file that the container mounts read-only. Z-Wave JS UI marks those settings "managed externally" and refuses to edit them.
- Enables authentication on the web interface from the first boot, and disables the MQTT gateway: Home Assistant is served by the websocket, not by MQTT.
- Stores the node names, the associations, the NVM backups of the controller and the users
of the web interface on the virtiofs precious-data mount
(
/var/lib/virtiofs/data/zwave-js-ui/), so the host's ZFS snapshots and backups pick them up. - Logs to stdout, so
journalctlis the single place to look. - Supports automatic container image updates via Podman auto-update.
The image is ghcr.io/zwave-js/zwave-js-ui:11, a multi-architecture manifest (amd64 +
arm64, verified 2026-09-26, version 11.24.1).
What this cookbook does NOT do
- It does not carry the Z-Wave network. The network lives in the NVM of the controller: its home ID, its node list and its routes are on the stick, not here. Wiping this cookbook's store loses the node names and the associations, not the network.
- It does not back the NVM up on its own. Z-Wave JS UI can dump the controller NVM into
its store (
backups/nvm/) from the web interface or on a schedule set there; nothing in this cookbook turns that on. - It does not publish anything. No Traefik dependency, no TLS: the two ports are served in clear text on the LAN. A radio head is a LAN appliance; if it must be reachable from outside, that is a reverse proxy in front, and a decision of the site.
- It does not include or exclude devices. Pairing a device is a physical act plus a click in the web interface.
Prerequisites
- The virtiofs data mount
/var/lib/virtiofs/datamust be present (provided by thefcos-with-virtiofstemplate / thebasecookbook). On a physical machine with no hypervisor the mount unit's condition simply does not hold, the unit is skipped, and the store is a plain directory of/var— which is what a radio head wants anyway, as long as/varsurvives a reinstallation. - A Z-Wave controller reachable as
/dev/zwave, whose device node is readable and writable by GID 10000. See below. - The operator must provide the security keys and the session secret. Until both files
exist, the container is intentionally not started (
ConditionPathExistsguards on/etc/quadlets/zwave-js-ui/external-settings.jsonand/etc/quadlets/zwave-js-ui/zwave-js-ui.env).
/dev/zwave, and why
AddDevice=/dev/zwave in the container, ZWAVE_PORT=/dev/zwave in its environment. The
site provides that name with a udev rule matching its controller, for instance:
# /etc/udev/rules.d/99-zwave.rules -- Home Assistant Connect ZWA-2
SUBSYSTEM=="tty", ATTRS{idVendor}=="303a", ATTRS{idProduct}=="4001", \
SYMLINK+="zwave", GROUP="itix-svc", MODE="0660"
Two things that rule has to do, and the reason for each:
SYMLINK+="zwave"— the container and its unit name one path, so a second radio stick on the same host (a Zigbee coordinator, say) cannot be handed to the Z-Wave driver by a renumbered/dev/ttyACM*. Matching onATTRS{serial}as well is what distinguishes two sticks that share a USB identifier, which is common: the CP210x bridge is the same chip in half the controllers on the market.GROUP="itix-svc", MODE="0660"— the container runs as 10035:10000 and opens the device as that user. The kernel default isroot:dialout 0660, which it cannot read. Setting the group on the device node is preferred over adding the host'sdialoutGID to the container, because that GID is a distribution constant that does not match the group file inside the image.
Reload the rules and replug the controller (or udevadm trigger) after writing the file,
then check that the symlink is there:
ls -l /dev/zwave
Files the operator must provide
Both are provided by the operator (e.g. an Ansible overlay out of a vault) and are not
part of the package. Working development examples ship under config/examples/.
| Path | Mode | Owner | Purpose |
|---|---|---|---|
/etc/quadlets/zwave-js-ui/external-settings.json |
0400 | 10035:10000 |
Security keys + Z-Wave JS Server settings |
/etc/quadlets/zwave-js-ui/zwave-js-ui.env |
0600 | root:root |
Session secret, initial admin credentials |
external-settings.json
Z-Wave JS UI reads it through ZWAVE_EXTERNAL_SETTINGS. Only zwave.* settings are
accepted there, and every key present becomes read-only in the web interface. The shipped
example is a complete, working file; the keys in it are obviously not secrets.
The four classic keys are the identity of the network. A device included in S0 or S2 holds a key derived from them: lose them and the device answers nobody, and the only way back is to exclude and re-include it by hand. They belong in a vault before the first inclusion, not after.
Generate six independent 16-byte keys:
for k in S0_Legacy S2_Unauthenticated S2_Authenticated S2_AccessControl \
LR_S2_Authenticated LR_S2_AccessControl; do
printf '%s = %s\n' "$k" "$(openssl rand -hex 16)"
done
securityKeysLongRange is only used by Z-Wave Long Range devices, and only on an 800-series
controller in an LR region. Providing the two keys costs nothing and spares a migration the
day such a device is included.
Settings worth knowing about:
| Key | Effect |
|---|---|
serverEnabled, serverPort |
The Z-Wave JS Server websocket Home Assistant connects to |
serverServiceDiscoveryDisabled |
false announces the server over mDNS, so Home Assistant finds it |
rf.region |
Writes the RF region to the controller. Absent, the controller keeps its own (0 = Europe, 9 = USA LR, 11 = Europe LR, 255 = default EU) |
enableSoftReset |
Lets the driver restart the controller in software rather than ask an operator to replug it |
logLevel, logEnabled |
info is readable; debug is what an inclusion that fails needs |
rf.region is deliberately absent from the shipped example: a controller is sold for a
band, an operator who copies an example must not have its region rewritten by surprise.
zwave-js-ui.env
SESSION_SECRET=<openssl rand -hex 32>
DEFAULT_USERNAME=admin
DEFAULT_PASSWORD=<a real password>
SESSION_SECRET signs the session cookies and the API tokens. Absent, Z-Wave JS UI
generates one and persists it in the store — which works, but then the secret is a piece of
state nobody chose and nobody can restore. DEFAULT_USERNAME and DEFAULT_PASSWORD create
the administrator account on the first start only: once store/users.json exists,
changing them does nothing and the password is changed from the web interface.
Ports
| Port | Bind | Purpose |
|---|---|---|
| 8091 | all | Web control panel (authenticated) |
| 3000 | all | Z-Wave JS Server websocket — ws://<host>:3000 for Home Assistant |
Both are above 1024, so the non-root container binds them without any capability. Host networking is what lets the mDNS announcement of the websocket server reach the LAN.
UID and GID
- User:
zwave-js-ui, UID 10035 - Group:
itix-svc, GID 10000
Health check
wget -q -O /dev/null --header="Accept: text/plain" http://127.0.0.1:8091/health. That
endpoint returns 200 only when the driver is connected to the controller and the MQTT
client is either connected or explicitly disabled — which is why the packaged
initial-settings.json carries mqtt.disabled: with no mqtt section at all, Z-Wave JS UI
builds no MQTT client and /health answers 500 forever.
The Accept header is load-bearing. A request that accepts HTML — which includes a
request with no Accept header at all, so curl and wget as they come — is taken by the
single-page-application fallback: 301 to /health/, then the web interface, 200, whatever
the driver is doing. A probe written without it passes forever and reports nothing.
A failing probe marks the container unhealthy and nothing more: no restart action is set. The driver reconnects on its own, and a controller that has been unplugged is not a problem a restart loop fixes.
Deviations from the repository conventions
- The store is seeded once, by
zwave-js-ui-init.service. Z-Wave JS UI ownsstore/settings.json— everything the web interface changes is written back to it — so the two settings this cookbook decides (gateway.authEnabled,mqtt.disabled) cannot be shipped as configuration. They are copied into the store on first boot only, following the same pattern as thehome-assistantcookbook. Z-Wave JS UI merges that file with its own defaults at each start, so seeding a partial file loses nothing. - Two settings files, on purpose.
external-settings.jsonin/etc/quadletsis read-only and authoritative for the keys and the websocket server;store/settings.jsonis the application's own, and the operator's copy of it is the seed. Z-Wave JS UI merges the two at each start and persists the result, security keys included, intostore/settings.json(0600, owned by this cookbook's user). So the store holds a copy of the keys whether or not they were seeded there, and a store backup is a secret-bearing artifact. What the external file buys is not secrecy: it is that the authoritative copy lives outside the application, is restorable from a vault, and cannot be edited from the web interface. - No Traefik and no TLS. See "What this cookbook does NOT do".
Usage
In a separate terminal, follow the logs.
sudo make tail-logs
Install the Podman Quadlets and start Z-Wave JS UI.
sudo make clean install
Without a Z-Wave controller there is nothing to drive, and the container unit is skipped
(ConditionPathExists=/dev/zwave). To exercise the rest of the cookbook on a machine that
has none, point /dev/zwave at any serial device — the driver fails to talk to it and says
so, which is exactly what it should do:
ln -sf /dev/ttyS0 /dev/zwave
sudo systemctl start zwave-js-ui.target
Note that AddDevice names the container path explicitly (/dev/zwave:/dev/zwave): a udev
SYMLINK is a symbolic link, and a device passed without a container path arrives under its
resolved name (/dev/ttyACM0), which is not the name ZWAVE_PORT asks for.
With a real controller, the driver comes up and the two ports answer (note the Accept
header — see Health check):
curl -sS -H 'Accept: text/plain' http://127.0.0.1:8091/health # -> Ok
ss -ltnp | grep -E ':(8091|3000)' # 8091 AND 3000
journalctl -u zwave-js-ui.service | grep -E 'Driver ready|server listening'
Finally, remove the quadlets, their configuration and their data.
sudo make I_KNOW_WHAT_I_AM_DOING=yes uninstall clean