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