From f32a2f9c15b2528a56b0545a916d220b7b8eacb1 Mon Sep 17 00:00:00 2001 From: Nicolas MASSE Date: Sat, 26 Sep 2026 15:49:24 +0200 Subject: [PATCH 1/4] zwave-js-ui: drive a Z-Wave controller and serve Home Assistant Z-Wave JS UI exposes a Z-Wave controller twice: a web control panel on 8091 and the Z-Wave JS Server websocket on 3000, which is what the Home Assistant zwave_js integration connects to. MQTT stays off. The controller is taken as /dev/zwave, a name a udev rule of the site gives it, so no site value is baked into the cookbook. The container path is spelled out in AddDevice: a udev SYMLINK is a symbolic link, and a device passed without one arrives under its resolved name, which is not the name ZWAVE_PORT asks for. The security keys, the Z-Wave JS Server settings and the log level come from an operator-provided file through ZWAVE_EXTERNAL_SETTINGS, so Z-Wave JS UI marks them managed externally and refuses to edit them. The two settings that file cannot carry -- authEnabled and mqtt.disabled -- are seeded into the store once, following the home-assistant cookbook pattern. The health probe sends Accept: text/plain. Without it the single-page-application fallback answers 200 for any request that accepts HTML, driver or no driver. Co-Authored-By: Claude Opus 5 --- cookbooks/zwave-js-ui/Makefile | 17 ++ cookbooks/zwave-js-ui/README.md | 244 ++++++++++++++++++ .../config/examples/external-settings.json | 19 ++ .../config/examples/zwave-js-ui.env | 13 + cookbooks/zwave-js-ui/config/init.sh | 34 +++ .../zwave-js-ui/config/initial-settings.json | 8 + cookbooks/zwave-js-ui/overlay.bu | 9 + .../zwave-js-ui/tmpfiles.d/zwave-js-ui.conf | 4 + .../zwave-js-ui/zwave-js-ui-init.service | 24 ++ cookbooks/zwave-js-ui/zwave-js-ui.container | 97 +++++++ cookbooks/zwave-js-ui/zwave-js-ui.image | 12 + cookbooks/zwave-js-ui/zwave-js-ui.target | 11 + 12 files changed, 492 insertions(+) create mode 100644 cookbooks/zwave-js-ui/Makefile create mode 100644 cookbooks/zwave-js-ui/README.md create mode 100644 cookbooks/zwave-js-ui/config/examples/external-settings.json create mode 100644 cookbooks/zwave-js-ui/config/examples/zwave-js-ui.env create mode 100755 cookbooks/zwave-js-ui/config/init.sh create mode 100644 cookbooks/zwave-js-ui/config/initial-settings.json create mode 100644 cookbooks/zwave-js-ui/overlay.bu create mode 100644 cookbooks/zwave-js-ui/tmpfiles.d/zwave-js-ui.conf create mode 100644 cookbooks/zwave-js-ui/zwave-js-ui-init.service create mode 100644 cookbooks/zwave-js-ui/zwave-js-ui.container create mode 100644 cookbooks/zwave-js-ui/zwave-js-ui.image create mode 100644 cookbooks/zwave-js-ui/zwave-js-ui.target diff --git a/cookbooks/zwave-js-ui/Makefile b/cookbooks/zwave-js-ui/Makefile new file mode 100644 index 0000000..e1a8bbd --- /dev/null +++ b/cookbooks/zwave-js-ui/Makefile @@ -0,0 +1,17 @@ +## +## Makefile for Z-Wave JS UI quadlet +## + +# Z-Wave JS UI runs as UID 10035 (zwave-js-ui) / GID 10000 (itix-svc) on the host +PROJECT_UID = 10035 +PROJECT_GID = 10000 + +# Include common Makefile +include ../../scripts/common.mk + +# The example security keys stand in for the operator-provided ones. Install them +# with the same 0400 mode and 10035:10000 ownership that the operator must use, +# overriding the default 0644 config-install rule for this file. +TARGET_ZWAVE_JS_UI_KEYS_EXAMPLE = $(TARGET_CHROOT)/etc/quadlets/zwave-js-ui/external-settings.json +$(TARGET_ZWAVE_JS_UI_KEYS_EXAMPLE): $(TARGET_CHROOT)/etc/quadlets/zwave-js-ui/%: config/examples/% $(TARGET_CHROOT)/etc/quadlets/zwave-js-ui + install -m 0400 -o $(PROJECT_UID) -g $(PROJECT_GID) $< $@ diff --git a/cookbooks/zwave-js-ui/README.md b/cookbooks/zwave-js-ui/README.md new file mode 100644 index 0000000..d6fb03e --- /dev/null +++ b/cookbooks/zwave-js-ui/README.md @@ -0,0 +1,244 @@ +# Podman Quadlet: Z-Wave JS UI + +## Overview + +[Z-Wave JS UI](https://zwave-js.github.io/zwave-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 `journalctl` is 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/data` must be present (provided by the + `fcos-with-virtiofs` template / the `base` cookbook). 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 + `/var` survives 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 (`ConditionPathExists` guards on + `/etc/quadlets/zwave-js-ui/external-settings.json` and + `/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 on `ATTRS{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 is `root:dialout 0660`, which it cannot read. + Setting the group on the device node is preferred over adding the host's `dialout` GID 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: + +```sh +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: + +```sh +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= +DEFAULT_USERNAME=admin +DEFAULT_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://: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 owns + `store/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 the `home-assistant` cookbook. 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.json` in `/etc/quadlets` is + read-only and authoritative for the keys and the websocket server; + `store/settings.json` is 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, + into `store/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. + +```sh +sudo make tail-logs +``` + +Install the Podman Quadlets and start Z-Wave JS UI. + +```sh +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: + +```sh +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](#health-check)): + +```sh +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. + +```sh +sudo make I_KNOW_WHAT_I_AM_DOING=yes uninstall clean +``` diff --git a/cookbooks/zwave-js-ui/config/examples/external-settings.json b/cookbooks/zwave-js-ui/config/examples/external-settings.json new file mode 100644 index 0000000..6b36bf9 --- /dev/null +++ b/cookbooks/zwave-js-ui/config/examples/external-settings.json @@ -0,0 +1,19 @@ +{ + "securityKeys": { + "S0_Legacy": "01010101010101010101010101010101", + "S2_Unauthenticated": "02020202020202020202020202020202", + "S2_Authenticated": "03030303030303030303030303030303", + "S2_AccessControl": "04040404040404040404040404040404" + }, + "securityKeysLongRange": { + "S2_Authenticated": "05050505050505050505050505050505", + "S2_AccessControl": "06060606060606060606060606060606" + }, + "serverEnabled": true, + "serverPort": 3000, + "serverServiceDiscoveryDisabled": false, + "enableSoftReset": true, + "logEnabled": true, + "logLevel": "info", + "logToFile": false +} diff --git a/cookbooks/zwave-js-ui/config/examples/zwave-js-ui.env b/cookbooks/zwave-js-ui/config/examples/zwave-js-ui.env new file mode 100644 index 0000000..16cab72 --- /dev/null +++ b/cookbooks/zwave-js-ui/config/examples/zwave-js-ui.env @@ -0,0 +1,13 @@ +# Development credentials and session secret for Z-Wave JS UI. +# +# NOT FOR PRODUCTION: an operator provides this file with its own values, out of +# a vault. See the README. +# +# SESSION_SECRET signs the session cookies and the API tokens. Provided here so +# that sessions survive a store that is wiped between two iterations; absent, one +# is generated and persisted in the store. +SESSION_SECRET=development-session-secret-not-for-production +# Credentials of the administrator account, created on the FIRST start only (once +# store/users.json exists, changing these values does nothing). +DEFAULT_USERNAME=admin +DEFAULT_PASSWORD=zwave diff --git a/cookbooks/zwave-js-ui/config/init.sh b/cookbooks/zwave-js-ui/config/init.sh new file mode 100755 index 0000000..7f6120c --- /dev/null +++ b/cookbooks/zwave-js-ui/config/init.sh @@ -0,0 +1,34 @@ +#!/bin/sh +# +# Seed the Z-Wave JS UI store on first boot. +# +# Z-Wave JS UI owns store/settings.json: every change made in the web interface +# is written back to it. So this script only ever creates it when it is absent, +# and never touches it again. What it seeds are the two settings the cookbook +# decides and that the external settings file cannot carry (Z-Wave JS UI only +# accepts `zwave.*` keys there): +# +# - gateway.authEnabled, so the web interface asks for a password from the +# very first boot rather than after an operator remembers to turn it on; +# - mqtt.disabled, because a Z-Wave JS UI with no MQTT section at all builds no +# MQTT client, and /health then reports 500 forever (the probe asks for both +# halves of the gateway, and an absent client is indistinguishable from a +# broken one). +# +# The keys of settings.json are merged with Z-Wave JS UI's own defaults at each +# start, so seeding a partial file loses nothing. +# +set -eu + +SRC=/etc/quadlets/zwave-js-ui/initial-settings.json +DST=/var/lib/virtiofs/data/zwave-js-ui/settings.json +ZUI_UID=10035 +ZUI_GID=10000 + +if [ -e "$DST" ]; then + echo "Keeping existing $DST (Z-Wave JS UI owns it)" + exit 0 +fi + +echo "Seeding $DST from $SRC" +install -m 0600 -o "$ZUI_UID" -g "$ZUI_GID" "$SRC" "$DST" diff --git a/cookbooks/zwave-js-ui/config/initial-settings.json b/cookbooks/zwave-js-ui/config/initial-settings.json new file mode 100644 index 0000000..f7bbeab --- /dev/null +++ b/cookbooks/zwave-js-ui/config/initial-settings.json @@ -0,0 +1,8 @@ +{ + "gateway": { + "authEnabled": true + }, + "mqtt": { + "disabled": true + } +} diff --git a/cookbooks/zwave-js-ui/overlay.bu b/cookbooks/zwave-js-ui/overlay.bu new file mode 100644 index 0000000..2e455a4 --- /dev/null +++ b/cookbooks/zwave-js-ui/overlay.bu @@ -0,0 +1,9 @@ +variant: fcos +version: 1.4.0 +passwd: + users: + - name: zwave-js-ui + uid: 10035 + gecos: Z-Wave JS UI + home_dir: /var/lib/quadlets/zwave-js-ui + primary_group: itix-svc diff --git a/cookbooks/zwave-js-ui/tmpfiles.d/zwave-js-ui.conf b/cookbooks/zwave-js-ui/tmpfiles.d/zwave-js-ui.conf new file mode 100644 index 0000000..85ec8f2 --- /dev/null +++ b/cookbooks/zwave-js-ui/tmpfiles.d/zwave-js-ui.conf @@ -0,0 +1,4 @@ +# Precious data: the Z-Wave JS UI store (node names, associations, NVM backups, +# web interface users). The "$" marks it for removal on +# `systemd-tmpfiles --purge` (i.e. `make uninstall`). +d$ /var/lib/virtiofs/data/zwave-js-ui 0700 10035 10000 - diff --git a/cookbooks/zwave-js-ui/zwave-js-ui-init.service b/cookbooks/zwave-js-ui/zwave-js-ui-init.service new file mode 100644 index 0000000..cf9af46 --- /dev/null +++ b/cookbooks/zwave-js-ui/zwave-js-ui-init.service @@ -0,0 +1,24 @@ +[Unit] +Description=Z-Wave JS UI - Bootstrap the store from the packaged initial settings +Documentation=https://zwave-js.github.io/zwave-js-ui/guide/#configuration +After=var-lib-virtiofs-data.mount +Requires=var-lib-virtiofs-data.mount + +# Only run if the initial settings are there to be copied +ConditionPathExists=/etc/quadlets/zwave-js-ui/initial-settings.json + +# Start/stop this unit when the target is started/stopped, before the service +PartOf=zwave-js-ui.target +Before=zwave-js-ui.service + +[Service] +Type=oneshot +RemainAfterExit=yes + +# Seed the store's settings.json on first boot only (see init.sh). Z-Wave JS UI +# owns that file afterwards: every change made in the web interface is written +# back to it, so it is never overwritten once it exists. +ExecStart=/etc/quadlets/zwave-js-ui/init.sh + +[Install] +WantedBy=zwave-js-ui.target diff --git a/cookbooks/zwave-js-ui/zwave-js-ui.container b/cookbooks/zwave-js-ui/zwave-js-ui.container new file mode 100644 index 0000000..6c28993 --- /dev/null +++ b/cookbooks/zwave-js-ui/zwave-js-ui.container @@ -0,0 +1,97 @@ +[Unit] +Description=Z-Wave JS UI - Z-Wave control panel and Z-Wave JS Server +Documentation=https://zwave-js.github.io/zwave-js-ui/ +After=network.target var-lib-virtiofs-data.mount zwave-js-ui-init.service +Requires=var-lib-virtiofs-data.mount zwave-js-ui-init.service + +# The Z-Wave controller, under the stable name a udev rule gives it. Without it +# there is nothing to talk to: the driver would retry forever and every entity +# of the household would sit unavailable, which is noise, not a service. +ConditionPathExists=/dev/zwave + +# The security keys and the session secret are operator-provided: they are the +# identity of the Z-Wave network, not a setting. Starting without them would +# form a network whose keys nobody kept, and re-pairing every device by hand is +# the only way back from that. +ConditionPathExists=/etc/quadlets/zwave-js-ui/external-settings.json +ConditionPathExists=/etc/quadlets/zwave-js-ui/zwave-js-ui.env + +# Start/stop this unit when the target is started/stopped +PartOf=zwave-js-ui.target + +[Container] +ContainerName=zwave-js-ui +Image=zwave-js-ui.image +AutoUpdate=registry + +# No need for root privileges: the image has no entrypoint script, it execs node +# straight away, and node binds 8091 and 3000 (both > 1024) as this user. It +# writes nothing outside STORE_DIR. +User=10035 +Group=10000 + +# Network configuration (host networking is the convention across this repository) +Network=host + +# The Z-Wave controller. /dev/zwave is a stable name a udev rule of the site +# gives to its controller, and the device node it points at has to be writable +# by GID 10000 -- see the README. Naming the device here rather than a +# /dev/serial/by-id/... path is what keeps this cookbook free of site values. +# +# The container path is spelled out, and it has to be: a udev SYMLINK is a +# symbolic link, and `--device /dev/zwave` alone hands the container the node +# under its RESOLVED name (/dev/ttyACM0), which is not the name ZWAVE_PORT +# below asks for. The symptom is a driver that never opens its port: +# "Failed to open the serial port: No such file or directory (ZW0100)". +AddDevice=/dev/zwave:/dev/zwave + +# Everything the driver needs comes from the environment, so the settings this +# operator decided stay out of reach of the web interface (Z-Wave JS UI marks +# them "managed externally" and refuses to edit them): +# - ZWAVE_PORT forces the serial port AND enables the driver; +# - ZWAVE_EXTERNAL_SETTINGS holds the security keys and the Z-Wave JS Server +# settings Home Assistant connects to; +# - STORE_DIR moves the store off the image and onto the precious-data mount. +Environment=ZWAVE_PORT=/dev/zwave +Environment=ZWAVE_EXTERNAL_SETTINGS=/etc/quadlets/zwave-js-ui/external-settings.json +Environment=STORE_DIR=/var/lib/virtiofs/data/zwave-js-ui + +# SESSION_SECRET and the initial administrator credentials (site secrets). +EnvironmentFile=/etc/quadlets/zwave-js-ui/zwave-js-ui.env + +# Configuration (read-only), at the same path inside and outside the container so +# that ZWAVE_EXTERNAL_SETTINGS names one path only. +Volume=/etc/quadlets/zwave-js-ui:/etc/quadlets/zwave-js-ui:ro,Z + +# Precious data: the store holds the node names, the associations, the NVM +# backups of the controller and the users of the web interface. It lives on the +# virtiofs mount so the host's ZFS snapshots and backups pick it up. +Volume=/var/lib/virtiofs/data/zwave-js-ui:/var/lib/virtiofs/data/zwave-js-ui:z + +# Health check: /health is 200 only once the driver is connected to the +# controller AND the MQTT gateway is either connected or explicitly disabled +# (which is what the packaged initial-settings.json does). A radio head whose +# driver is down serves nothing, so that is the right thing to probe. The image +# has no curl; busybox wget exits non-zero on 500, which is all that is needed. +# +# The Accept header is what makes the probe a probe. A request that accepts HTML +# -- which includes one with no Accept header at all -- is taken by the +# single-page-application fallback and answered with a 301 to /health/ and then +# the web interface itself, 200, driver or no driver. The probe would pass +# forever. Asking for text/plain is what reaches the endpoint. +HealthCmd=wget -q -O /dev/null --header="Accept: text/plain" http://127.0.0.1:8091/health +HealthInterval=30s +HealthTimeout=10s +# First start downloads the device configuration database into the store, which +# takes a minute or so before the driver even opens the port. +HealthStartPeriod=180s +HealthRetries=5 + +[Service] +Restart=always +RestartSec=10 +TimeoutStartSec=300 +TimeoutStopSec=60 + +[Install] +WantedBy=zwave-js-ui.target diff --git a/cookbooks/zwave-js-ui/zwave-js-ui.image b/cookbooks/zwave-js-ui/zwave-js-ui.image new file mode 100644 index 0000000..0e57b7c --- /dev/null +++ b/cookbooks/zwave-js-ui/zwave-js-ui.image @@ -0,0 +1,12 @@ +[Unit] +Description=podman pull ghcr.io/zwave-js/zwave-js-ui +Documentation=https://zwave-js.github.io/zwave-js-ui/ + +[Image] +# Multi-architecture manifest (amd64 + arm64, verified 2026-09-26 on tag 11 -> +# 11.24.1), so the cookbook installs on an aarch64 host as well as on the x86_64 +# radio head. The tag is the major version: Z-Wave JS UI ships the zwave-js +# driver and its Z-Wave JS Server, whose schema version is what Home Assistant +# negotiates. A major bump is the one that can break that handshake, so it is +# the one an operator has to opt into. +Image=ghcr.io/zwave-js/zwave-js-ui:11 diff --git a/cookbooks/zwave-js-ui/zwave-js-ui.target b/cookbooks/zwave-js-ui/zwave-js-ui.target new file mode 100644 index 0000000..d2ca3df --- /dev/null +++ b/cookbooks/zwave-js-ui/zwave-js-ui.target @@ -0,0 +1,11 @@ +[Unit] +Description=Z-Wave JS UI Service Target +Documentation=man:systemd.target(5) +Requires=zwave-js-ui-init.service zwave-js-ui.service +After=zwave-js-ui-init.service zwave-js-ui.service + +# Allow isolation - can stop/start this target independently +AllowIsolate=yes + +[Install] +WantedBy=multi-user.target From 4ffd6d0f497c8706a85aff25624cf195e64ee9f9 Mon Sep 17 00:00:00 2001 From: Nicolas MASSE Date: Sat, 26 Sep 2026 16:18:13 +0200 Subject: [PATCH 2/4] zwave-js-ui: mount only the settings file, not the config directory ":Z" relabels what it mounts to container_file_t, recursively and on disk, and init.sh lives in /etc/quadlets/zwave-js-ui. Mounting that directory into the container therefore left a script systemd could no longer execute: zwave-js-ui-init.service failed with 203/EXEC at every boot after the first start of the container, and the container's Requires= on it kept the container down too. A machine that survived its installation stopped surviving a reboot. Found on the target machine, not in review: the first installation works, because Ignition writes /etc with the right labels and the init service runs before the container. The fault only appears at the second boot. The README also gains a "Clearing the store" section. The seed is a first-boot mechanism, so an operator who deletes settings.json -- after rotating the security keys, for instance, since Z-Wave JS UI keeps its own copy of the old ones -- has to restart the init service explicitly. "systemctl restart zwave-js-ui.target" does not re-run it: it is RemainAfterExit=yes and already active. Getting that wrong leaves a container unhealthy forever with a happy driver, and authentication silently off. Co-Authored-By: Claude Opus 5 --- cookbooks/zwave-js-ui/README.md | 32 +++++++++++++++++++++ cookbooks/zwave-js-ui/zwave-js-ui.container | 16 +++++++++-- 2 files changed, 45 insertions(+), 3 deletions(-) diff --git a/cookbooks/zwave-js-ui/README.md b/cookbooks/zwave-js-ui/README.md index d6fb03e..2cc9904 100644 --- a/cookbooks/zwave-js-ui/README.md +++ b/cookbooks/zwave-js-ui/README.md @@ -198,8 +198,40 @@ a restart loop fixes. 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. +- **Only `external-settings.json` is mounted into the container, never the configuration + directory.** `:Z` relabels what it mounts to `container_file_t`, recursively and on disk, and + `init.sh` lives in that directory. Mounting the directory therefore leaves a script systemd + can no longer execute: `zwave-js-ui-init.service` fails with `203/EXEC` at every boot *after + the first start of the container*, and the container's `Requires=` on it keeps the container + down too. A machine that survived its installation stops surviving a reboot, which is the + worst shape a fault can take. The `mosquitto` cookbook mounts its whole directory and is fine + because it keeps no script there; this one does. - **No Traefik and no TLS.** See "What this cookbook does NOT do". +## Clearing the store + +The seed is a **first-boot** mechanism: `init.sh` writes `store/settings.json` only when it is +absent, because Z-Wave JS UI owns that file afterwards. Two consequences for an operator who +wipes the store, or who deletes `settings.json` to force the settings of +`external-settings.json` back in — after rotating the security keys, for instance, since +Z-Wave JS UI keeps its own copy of the old ones: + +- **Delete the file with the container stopped, then restart the init service explicitly**, in + this order. `systemctl restart zwave-js-ui.target` is not enough: the init service is + `RemainAfterExit=yes` and already active, so it does not run again, and Z-Wave JS UI then + writes a `settings.json` that holds the external settings and **not** the seeded ones. +- The symptom of getting it wrong is a container that is `unhealthy` forever with a driver that + is perfectly happy: no `mqtt` section means no MQTT client, and `/health` answers 500. And, + more quietly, `gateway.authEnabled` is gone with it, so the web interface stops asking for a + password. + +```sh +systemctl stop zwave-js-ui.service +rm /var/lib/virtiofs/data/zwave-js-ui/settings.json +systemctl restart zwave-js-ui-init.service # seeds it again +systemctl start zwave-js-ui.service +``` + ## Usage In a separate terminal, follow the logs. diff --git a/cookbooks/zwave-js-ui/zwave-js-ui.container b/cookbooks/zwave-js-ui/zwave-js-ui.container index 6c28993..38e5e26 100644 --- a/cookbooks/zwave-js-ui/zwave-js-ui.container +++ b/cookbooks/zwave-js-ui/zwave-js-ui.container @@ -59,9 +59,19 @@ Environment=STORE_DIR=/var/lib/virtiofs/data/zwave-js-ui # SESSION_SECRET and the initial administrator credentials (site secrets). EnvironmentFile=/etc/quadlets/zwave-js-ui/zwave-js-ui.env -# Configuration (read-only), at the same path inside and outside the container so -# that ZWAVE_EXTERNAL_SETTINGS names one path only. -Volume=/etc/quadlets/zwave-js-ui:/etc/quadlets/zwave-js-ui:ro,Z +# Configuration (read-only): the ONE file the container reads, at the same path +# inside and outside so that ZWAVE_EXTERNAL_SETTINGS names one path only. +# +# Never mount the whole /etc/quadlets/zwave-js-ui directory here. ":Z" relabels +# what it mounts to container_file_t, recursively and on disk, and init.sh lives +# in that directory: systemd can no longer execute it, so +# zwave-js-ui-init.service fails 203/EXEC at every boot after the first start of +# this container, and the Requires= above then keeps the container down with it. +# A machine that survived its install stops surviving a reboot. +# +# ConditionPathExists on this file above is also what keeps podman from creating +# a DIRECTORY at this path when the operator has not provided the file. +Volume=/etc/quadlets/zwave-js-ui/external-settings.json:/etc/quadlets/zwave-js-ui/external-settings.json:ro,Z # Precious data: the store holds the node names, the associations, the NVM # backups of the controller and the users of the web interface. It lives on the From dce163b263caab0989552d1ff680f0844cfad0ac Mon Sep 17 00:00:00 2001 From: Nicolas MASSE Date: Sat, 26 Sep 2026 17:08:41 +0200 Subject: [PATCH 3/4] zwave-js-ui: name the image in the container, drop the .image unit A .image unit runs `podman image pull`, which contacts the registry even when the image is already in local storage. A machine that boots before its network does fails that pull with "Temporary failure in name resolution", and the Requires= quadlet generates on the .image unit takes the container down with it. A failed dependency is never retried -- Restart=always only applies to a unit that started at least once -- so the service stays down until someone notices. Observed on the target: the radio head was moved to a basement, powered on, and cabled some minutes later. It booted, NetworkManager-wait-online declared the network online 26 s in, the pull failed on DNS, and zwave-js-ui never started even though the image was sitting in local storage and the controller was plugged in. Naming the image in the container lets podman resolve it at start with its default --pull=missing: the local image starts with no registry involved, and a genuinely missing image fails the container, which Restart= then retries until the network is there. Auto-update is unaffected, it reads the container's label. This is the one deviation from the convention of this repository, and the README says so and why. Co-Authored-By: Claude Opus 5 --- cookbooks/zwave-js-ui/README.md | 14 +++++++++++++- cookbooks/zwave-js-ui/zwave-js-ui.container | 16 +++++++++++++++- cookbooks/zwave-js-ui/zwave-js-ui.image | 12 ------------ 3 files changed, 28 insertions(+), 14 deletions(-) delete mode 100644 cookbooks/zwave-js-ui/zwave-js-ui.image diff --git a/cookbooks/zwave-js-ui/README.md b/cookbooks/zwave-js-ui/README.md index 2cc9904..763c72a 100644 --- a/cookbooks/zwave-js-ui/README.md +++ b/cookbooks/zwave-js-ui/README.md @@ -27,7 +27,8 @@ This cookbook: - 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). +arm64, verified 2026-09-26, version 11.24.1). It is named in the container unit rather than in +a `.image` unit — see the deviations below, and the reason is boot resilience, not taste. ## What this cookbook does NOT do @@ -198,6 +199,17 @@ a restart loop fixes. 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 `.image` unit: the image is named in the container.** A `.image` unit runs + `podman image pull`, which contacts the registry even when the image is already in local + storage. A machine that boots before its network does — a power cut, a switch slower than the + machine, a cable plugged in after power-on — fails that pull with + `Temporary failure in name resolution`, and the `Requires=` that quadlet generates on the + `.image` unit takes the container down with it. **A failed dependency is never retried**: + `Restart=always` only ever applies to a unit that started at least once, so the service stays + down until someone notices. Naming the image in the container instead lets podman resolve it + at start with its default `--pull=missing`: the local image starts with no registry involved, + and a genuinely missing image fails the container, which `Restart=` then retries until the + network is there. Auto-update is unaffected — it reads the container's label. - **Only `external-settings.json` is mounted into the container, never the configuration directory.** `:Z` relabels what it mounts to `container_file_t`, recursively and on disk, and `init.sh` lives in that directory. Mounting the directory therefore leaves a script systemd diff --git a/cookbooks/zwave-js-ui/zwave-js-ui.container b/cookbooks/zwave-js-ui/zwave-js-ui.container index 38e5e26..5879a0e 100644 --- a/cookbooks/zwave-js-ui/zwave-js-ui.container +++ b/cookbooks/zwave-js-ui/zwave-js-ui.container @@ -21,7 +21,21 @@ PartOf=zwave-js-ui.target [Container] ContainerName=zwave-js-ui -Image=zwave-js-ui.image +# The image reference, and NOT a zwave-js-ui.image unit -- the one deviation from +# the convention of this repository, for a reason this cookbook has already paid +# for. A .image unit runs `podman image pull`, which contacts the registry even +# when the image is already in local storage. A radio head that boots before its +# network does -- a power cut, a switch that is slower than it is, a cable +# plugged in after the machine -- therefore fails that pull, and the generated +# `Requires=` on it takes the container down with it. A failed dependency is +# never retried: Restart=always only ever applies to a unit that started once. +# +# Named here instead, podman resolves the image at container start with its +# default --pull=missing: the local image starts with no registry involved, and a +# genuinely missing image fails the container, which Restart= below then retries +# until the network is there. Auto-update still works -- it reads the label of +# the container, not the .image unit. +Image=ghcr.io/zwave-js/zwave-js-ui:11 AutoUpdate=registry # No need for root privileges: the image has no entrypoint script, it execs node diff --git a/cookbooks/zwave-js-ui/zwave-js-ui.image b/cookbooks/zwave-js-ui/zwave-js-ui.image deleted file mode 100644 index 0e57b7c..0000000 --- a/cookbooks/zwave-js-ui/zwave-js-ui.image +++ /dev/null @@ -1,12 +0,0 @@ -[Unit] -Description=podman pull ghcr.io/zwave-js/zwave-js-ui -Documentation=https://zwave-js.github.io/zwave-js-ui/ - -[Image] -# Multi-architecture manifest (amd64 + arm64, verified 2026-09-26 on tag 11 -> -# 11.24.1), so the cookbook installs on an aarch64 host as well as on the x86_64 -# radio head. The tag is the major version: Z-Wave JS UI ships the zwave-js -# driver and its Z-Wave JS Server, whose schema version is what Home Assistant -# negotiates. A major bump is the one that can break that handshake, so it is -# the one an operator has to opt into. -Image=ghcr.io/zwave-js/zwave-js-ui:11 From 18b7ebaaf3ad474b4f4b0c2a4663e383cac98628 Mon Sep 17 00:00:00 2001 From: Nicolas MASSE Date: Sat, 26 Sep 2026 18:03:06 +0200 Subject: [PATCH 4/4] zwave-js-ui: keep the .image unit, want it instead of requiring it Reverts the shape of the previous commit without reverting its finding. The pull keeps its own unit -- it can be long, and its duration has no business inside the container's start time -- and the container keeps After= on it, so the pull still runs first on a boot where the network is there. What changes is the strength of the dependency. Measured on the radio head rather than assumed, because the obvious fix does not work: Restart=on-failure on the .image unit makes the PULL converge and leaves the container inactive for good. A failed dependency is never retried -- the dependent job dies at the first failure, and the later successful restart of the required unit does not re-trigger it. Upholds= on the pull unit does pick the container up, at the price of a container that can no longer be stopped on its own, and it still leaves the container down for as long as the registry is unreachable even though the image is right there. So: Image= names the reference, which is what keeps quadlet from generating the Requires=, and the container declares Wants= + After= on the pull unit itself. The pull unit gains Restart=on-failure with StartLimitIntervalSec=0, so a machine that boots without a network ends up with the image instead of a stale one, and PartOf=zwave-js-ui.target so stopping the target leaves nothing retrying. Verified on the target, booted with ghcr.io pointed at 127.0.0.1: the container starts from local storage and reports healthy while the pull unit retries, then the pull converges on its own after two restarts once resolution is restored, and nothing is left in systemctl --failed. The image reference now appears in both files, on purpose. The alternative -- a drop-in resetting the generated Requires= -- has to restate the container's other requirements and silently drops any a later edit adds. That trap is worse. Co-Authored-By: Claude Opus 5 --- cookbooks/zwave-js-ui/README.md | 51 +++++++++++++++------ cookbooks/zwave-js-ui/zwave-js-ui.container | 42 +++++++++++------ cookbooks/zwave-js-ui/zwave-js-ui.image | 34 ++++++++++++++ 3 files changed, 101 insertions(+), 26 deletions(-) create mode 100644 cookbooks/zwave-js-ui/zwave-js-ui.image diff --git a/cookbooks/zwave-js-ui/README.md b/cookbooks/zwave-js-ui/README.md index 763c72a..8781bbf 100644 --- a/cookbooks/zwave-js-ui/README.md +++ b/cookbooks/zwave-js-ui/README.md @@ -27,8 +27,9 @@ This cookbook: - 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). It is named in the container unit rather than in -a `.image` unit — see the deviations below, and the reason is boot resilience, not taste. +arm64, verified 2026-09-26, version 11.24.1). It is pulled by `zwave-js-ui.image`, which the +container **wants** rather than requires — see the deviations below, and the reason is boot +resilience, not taste. ## What this cookbook does NOT do @@ -199,17 +200,41 @@ a restart loop fixes. 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 `.image` unit: the image is named in the container.** A `.image` unit runs - `podman image pull`, which contacts the registry even when the image is already in local - storage. A machine that boots before its network does — a power cut, a switch slower than the - machine, a cable plugged in after power-on — fails that pull with - `Temporary failure in name resolution`, and the `Requires=` that quadlet generates on the - `.image` unit takes the container down with it. **A failed dependency is never retried**: - `Restart=always` only ever applies to a unit that started at least once, so the service stays - down until someone notices. Naming the image in the container instead lets podman resolve it - at start with its default `--pull=missing`: the local image starts with no registry involved, - and a genuinely missing image fails the container, which `Restart=` then retries until the - network is there. Auto-update is unaffected — it reads the container's label. +- **The container wants the `.image` unit, it does not require it — and it names the image + reference, not the unit.** This is the deviation, and it is the one this cookbook has already + paid for. + + A `.image` unit runs `podman image pull`, which contacts the registry even when the image is + already in local storage. A machine that boots before its network does — a PoE port whose + switch takes a minute to forward, an uplink that comes back after the machine — fails that + pull with `Temporary failure in name resolution`. Naming the unit in `Image=` is what makes + quadlet generate a `Requires=` on it, and **a failed dependency is never retried**: + `Restart=` only ever applies to a unit that started at least once, so the container stays down + until a human notices. On the radio head that was fifty-five minutes, with the image sitting + in local storage the whole time. + + What does *not* fix it, measured rather than assumed: `Restart=on-failure` on the `.image` + unit. The pull converges, and the container stays `inactive` for good, because its job died + at the first failure. (`Upholds=` on the pull unit does pick the container up, at the price of + a container that can no longer be stopped on its own — and it still leaves the container down + for as long as the registry is unreachable, even though the image is right there.) + + So the pull keeps its own unit — it can be long, and its duration has no business inside the + container's start time — the container keeps `After=` on it so the pull still runs first when + it can, and the dependency is `Wants=`. Podman then resolves the image at container start with + its default `--pull=missing`: the local image starts with no registry involved, and a genuinely + missing image fails the container, which `Restart=` retries. Auto-update is unaffected, it + reads the container's label. + + The pull unit also carries `Restart=on-failure` with `StartLimitIntervalSec=0`, so a machine + that boots without a network still ends up with the image rather than with a stale one, and + `PartOf=zwave-js-ui.target`, so stopping the target leaves nothing retrying behind. + + **The image reference appears in both files on purpose**, and the two must be changed together. + A divergence is not silent — the pull unit fetches one tag and the container runs the other, + which both journals say out loud — but it is a divergence. The alternative, a drop-in that + resets the generated `Requires=`, has to restate the other requirements of the container, and + silently drops any that a later edit adds. That trap is worse than this one. - **Only `external-settings.json` is mounted into the container, never the configuration directory.** `:Z` relabels what it mounts to `container_file_t`, recursively and on disk, and `init.sh` lives in that directory. Mounting the directory therefore leaves a script systemd diff --git a/cookbooks/zwave-js-ui/zwave-js-ui.container b/cookbooks/zwave-js-ui/zwave-js-ui.container index 5879a0e..cf3a063 100644 --- a/cookbooks/zwave-js-ui/zwave-js-ui.container +++ b/cookbooks/zwave-js-ui/zwave-js-ui.container @@ -4,6 +4,21 @@ Documentation=https://zwave-js.github.io/zwave-js-ui/ After=network.target var-lib-virtiofs-data.mount zwave-js-ui-init.service Requires=var-lib-virtiofs-data.mount zwave-js-ui-init.service +# The pull runs in its own unit -- it can be long, and its duration has no +# business inside this container's start time -- but it is Wants=, NOT Requires=. +# A pull needs the registry; starting this container does not, as long as the +# image is in local storage. Requires= is what took the radio head down for +# fifty-five minutes on 2026-09-26: no name resolution twenty-six seconds into +# the boot, pull failed, dependency failed, and a failed dependency is never +# retried -- Restart= only ever applies to a unit that started at least once. +# Measured, not assumed: a Restart=on-failure on the pull unit does make the pull +# converge, and leaves this container inactive for good. +# +# After= still orders the pull before this container, so nothing is lost on a +# boot where the network is there. +Wants=zwave-js-ui-image.service +After=zwave-js-ui-image.service + # The Z-Wave controller, under the stable name a udev rule gives it. Without it # there is nothing to talk to: the driver would retry forever and every entity # of the household would sit unavailable, which is noise, not a service. @@ -21,20 +36,21 @@ PartOf=zwave-js-ui.target [Container] ContainerName=zwave-js-ui -# The image reference, and NOT a zwave-js-ui.image unit -- the one deviation from -# the convention of this repository, for a reason this cookbook has already paid -# for. A .image unit runs `podman image pull`, which contacts the registry even -# when the image is already in local storage. A radio head that boots before its -# network does -- a power cut, a switch that is slower than it is, a cable -# plugged in after the machine -- therefore fails that pull, and the generated -# `Requires=` on it takes the container down with it. A failed dependency is -# never retried: Restart=always only ever applies to a unit that started once. +# The image REFERENCE, and not the name of the zwave-js-ui.image unit, although +# that unit exists and is what pulls this image. Naming the unit here is what +# makes quadlet generate a Requires= on it, and that Requires= is the fault +# described in [Unit] above. +# +# Named as a reference, podman resolves it at container start with its default +# --pull=missing: the local image starts with no registry involved, and a +# genuinely missing image fails the container, which Restart= below retries until +# the pull unit has done its job. Auto-update is unaffected, it reads the label +# of the container. # -# Named here instead, podman resolves the image at container start with its -# default --pull=missing: the local image starts with no registry involved, and a -# genuinely missing image fails the container, which Restart= below then retries -# until the network is there. Auto-update still works -- it reads the label of -# the container, not the .image unit. +# THIS REFERENCE IS ALSO IN zwave-js-ui.image, on purpose, and the two must be +# changed together. A divergence is not silent -- the pull unit fetches one tag +# and this container runs the other, which the journal of both says out loud -- +# but it is still a divergence. Image=ghcr.io/zwave-js/zwave-js-ui:11 AutoUpdate=registry diff --git a/cookbooks/zwave-js-ui/zwave-js-ui.image b/cookbooks/zwave-js-ui/zwave-js-ui.image new file mode 100644 index 0000000..0ed89ce --- /dev/null +++ b/cookbooks/zwave-js-ui/zwave-js-ui.image @@ -0,0 +1,34 @@ +[Unit] +Description=podman pull ghcr.io/zwave-js/zwave-js-ui +Documentation=https://zwave-js.github.io/zwave-js-ui/ + +# Keep trying. A machine whose network arrives after it does -- a PoE port whose +# switch takes a minute to forward, an uplink that comes back after the machine -- +# fails this pull once, twenty-six seconds into the boot, and a oneshot that +# failed once never runs again. Measured on the radio head: network-online.target +# was reached at boot+26s and the DHCP lease only landed at boot+72s. +# +# StartLimitIntervalSec=0 is what makes the retry unlimited: the default start +# limit (5 attempts in 10 s) turns the unit "failed" for good after 15 seconds of +# a network that needs a minute. +StartLimitIntervalSec=0 + +# Stopping the service target stops the pull with it, so `systemctl stop +# zwave-js-ui.target` leaves nothing retrying behind. +PartOf=zwave-js-ui.target + +[Image] +# Multi-architecture manifest (amd64 + arm64, verified 2026-09-26 on tag 11 -> +# 11.24.1), so the cookbook installs on an aarch64 host as well as on the x86_64 +# radio head. The tag is the major version: Z-Wave JS UI ships the zwave-js +# driver and its Z-Wave JS Server, whose schema version is what Home Assistant +# negotiates. A major bump is the one that can break that handshake, so it is +# the one an operator has to opt into. +# +# THIS REFERENCE IS ALSO IN zwave-js-ui.container, on purpose, and the two must +# be changed together. The reason is in the container file and in the README. +Image=ghcr.io/zwave-js/zwave-js-ui:11 + +[Service] +Restart=on-failure +RestartSec=60