Browse Source

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 <noreply@anthropic.com>
pull/11/head
Nicolas Massé 1 month ago
parent
commit
f32a2f9c15
  1. 17
      cookbooks/zwave-js-ui/Makefile
  2. 244
      cookbooks/zwave-js-ui/README.md
  3. 19
      cookbooks/zwave-js-ui/config/examples/external-settings.json
  4. 13
      cookbooks/zwave-js-ui/config/examples/zwave-js-ui.env
  5. 34
      cookbooks/zwave-js-ui/config/init.sh
  6. 8
      cookbooks/zwave-js-ui/config/initial-settings.json
  7. 9
      cookbooks/zwave-js-ui/overlay.bu
  8. 4
      cookbooks/zwave-js-ui/tmpfiles.d/zwave-js-ui.conf
  9. 24
      cookbooks/zwave-js-ui/zwave-js-ui-init.service
  10. 97
      cookbooks/zwave-js-ui/zwave-js-ui.container
  11. 12
      cookbooks/zwave-js-ui/zwave-js-ui.image
  12. 11
      cookbooks/zwave-js-ui/zwave-js-ui.target

17
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) $< $@

244
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=<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.
- **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
```

19
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
}

13
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

34
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"

8
cookbooks/zwave-js-ui/config/initial-settings.json

@ -0,0 +1,8 @@
{
"gateway": {
"authEnabled": true
},
"mqtt": {
"disabled": true
}
}

9
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

4
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 -

24
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

97
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

12
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

11
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
Loading…
Cancel
Save