12 changed files with 623 additions and 0 deletions
@ -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) $< $@ |
|||
@ -0,0 +1,313 @@ |
|||
# 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). 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 |
|||
|
|||
- **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=<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 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. |
|||
- **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 |
|||
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. |
|||
|
|||
```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 |
|||
``` |
|||
@ -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 |
|||
} |
|||
@ -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 |
|||
@ -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" |
|||
@ -0,0 +1,8 @@ |
|||
{ |
|||
"gateway": { |
|||
"authEnabled": true |
|||
}, |
|||
"mqtt": { |
|||
"disabled": true |
|||
} |
|||
} |
|||
@ -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 |
|||
@ -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 - |
|||
@ -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 |
|||
@ -0,0 +1,137 @@ |
|||
[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 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. |
|||
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 |
|||
# 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. |
|||
# |
|||
# 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 |
|||
|
|||
# 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): 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 |
|||
# 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 |
|||
@ -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 |
|||
@ -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 |
|||
Loading…
Reference in new issue