Skip to main content

Z-Wave (hc-zwave)

The hc-zwave plugin bridges a zwave-js WebSocket server to HomeCore. It supports all Z-Wave devices that zwave-js can handle — locks, dimmers, switches, sensors, thermostats, and more.

Prerequisites

  • A Z-Wave controller (USB stick: Zooz ZST39, Aeotec Z-Stick, etc.)
  • zwave-js-server running and accessible via WebSocket
  • Alternatively, Z-Wave JS UI (includes zwave-js-server)

Setting up zwave-js-server

The easiest option is Z-Wave JS UI via Docker:

docker run -d \
--name zwave-js-ui \
--device /dev/ttyUSB0:/dev/ttyUSB0 \
-p 8091:8091 \
-p 3000:3000 \
-v /path/to/store:/usr/src/app/store \
zwavejs/zwave-js-ui:latest

Open http://localhost:8091 → Settings → Z-Wave → enable WebSocket server on port 3000.

Configuration

[homecore]
broker_host = "127.0.0.1"
broker_port = 1883
plugin_id = "plugin.zwave"
password = ""

[zwave]
ws_url = "ws://localhost:3000" # zwave-js WebSocket server URL

Running

cd /path/to/hc-zwave
./hc-zwave config/config.toml

Device IDs

Z-Wave device IDs follow the pattern zwave_{node_id}:

zwave_1     ← controller (typically no attributes)
zwave_23 ← a door lock
zwave_7 ← a light switch

Multi-endpoint devices (e.g. a multi-outlet plug with separately controllable outlets) use:

zwave_{node_id}_ep{endpoint}
zwave_15_ep1 ← endpoint 1 of node 15
zwave_15_ep2 ← endpoint 2 of node 15

Supported Command Classes

CCDevicesAttributes
Binary Switch (0x25)On/off switches, outletson
Multilevel Switch (0x26)Dimmers, fanson, level (0-99)
Binary Sensor (0x30)Motion, door sensorssensor_binary
Multilevel Sensor (0x31)Temperature, humidityair_temperature, humidity, etc.
Door Lock (0x62)Smart lockscurrent_mode ("unsecured" / "secured"), locked
Thermostat Mode (0x40)Thermostatsmode
Thermostat Setpoint (0x43)Thermostatsheating, cooling
Battery (0x80)Any battery devicelevel
Notification (0x71)Door/window, smoke, COVarious event values
Color Switch (0x33)RGB/RGBW lightsred, green, blue, warm_white, cold_white
Meter (0x32) v1+Power monitorsenergy_kwh, power_w, voltage, current_a
Meter (0x32) v3Smart meters with advanced fields+ apparent_energy_kvah, power_factor, reactive_power_kvar, reactive_energy_kvarh, pulse_count
Meter (0x32) — solar/PVBidirectional metersenergy_kwh_exported, power_w_exported
Unaliased values

Anything the alias table doesn't recognise still publishes under a deterministic synthetic name like cc50_value_pk67073 so it's visible. Watch for these in the device's attributes — if you find one that should have a clean canonical name, file an issue with the propertyKey and the value's meaning and we'll add an alias.

Commanding devices

Switches and dimmers

# Turn on
curl -s -X PATCH http://localhost:8080/api/v1/devices/zwave_7/state \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"on": true}'

# Set level (dimmer, 0-99)
curl -s -X PATCH http://localhost:8080/api/v1/devices/zwave_7/state \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"on": true, "level": 75}'

Door locks

# Lock
curl -s -X PATCH http://localhost:8080/api/v1/devices/zwave_23/state \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"targetMode": "secured"}'

# Unlock
curl -s -X PATCH http://localhost:8080/api/v1/devices/zwave_23/state \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"targetMode": "unsecured"}'
Lock command field

For Door Lock CC 98, the command target field is targetMode (not currentMode). Using currentMode results in the command being silently ignored by zwave-js.

Thermostats

# Set heat setpoint to 68°F
curl -s -X PATCH http://localhost:8080/api/v1/devices/zwave_12/state \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"heating": 68}'

Plugin actions

hc-zwave declares three capability actions the admin UI exposes as buttons on the plugin detail page (and hc-mcp surfaces via list_plugin_actions).

include_node (streaming, admin)

Put the controller into inclusion mode and add one or more Z-Wave devices.

  1. Click Include Z-Wave device. The drawer opens and tells you to press the include button on each device.
  2. Press the device's include button. The flow emits progress updates as zwave-js reports the inclusion lifecycle: waiting for controllerlisteningNode 14 included; interviewing…Node 14 interview complete. Each newly-added node shows up in the item list, color-coded by status (addedinterviewingready).
  3. Repeat for any additional devices.
  4. Click Done. For each node whose interview completed during the session, the action prompts for a name and area (both optional, with a Skip checkbox). On submit it sends node.set_name / node.set_location to zwave-js, then triggers a rescan that publishes the new identity to homeCore.

S2 security: requested classes are auto-granted. Devices that require DSK PIN entry are not supported in v1 — the flow emits a warning and the inclusion times out.

exclude_node (streaming, admin)

Mirror image. Put the controller into exclusion mode, press the exclude / reset button on each device, click Done. Removed nodes are unregistered from homeCore immediately.

rescan_nodes (sync, user)

Re-fetches every node's full state from zwave-js and republishes to homeCore. Useful when:

  • A freshly-included device hasn't appeared yet (interview is slow on battery / S2 nodes).
  • You renamed nodes in Z-Wave JS UI and want the homeCore device names refreshed without restarting the plugin.

include_node's complete step auto-pings rescan, so you usually don't need to click this manually after pairing.

Rule examples

Auto-lock after door closes

name = "Front door — auto-lock"
enabled = true

[trigger]
type = "device_state_changed"
device_id = "yolink_front_door"
attribute = "open"
to = false

[[actions]]
type = "delay"
duration_secs = 30

[[actions]]
type = "set_device_state"
device_id = "zwave_23"
state = { targetMode = "secured" }

Alert on manual unlock

name = "Front lock — unlocked alert"
enabled = true

[trigger]
type = "device_state_changed"
device_id = "zwave_23"
attribute = "current_mode"
to = "unsecured"

[[actions]]
type = "notify"
channel = "telegram"
message = "Front door unlocked"

Troubleshooting

ProblemSolution
WebSocket connection failedCheck zwave-js-server is running and the ws_url in config is correct
Devices not appearingCheck zwave-js UI — nodes must be included in the Z-Wave network
Lock not respondingVerify targetMode (not currentMode) is used in the command
State staleZ-Wave is a polling protocol for some CCs — values may be cached; force a poll in zwave-js UI
Node zwave_1 has no attributesThis is the controller node — it has no user-visible state

Node name sync

Node names set in Z-Wave JS UI are synced to HomeCore device names automatically when state is published. Renaming a node in the UI takes effect at the next state update. Restarting hc-zwave forces immediate re-registration of all node names.

Log rotation

hc-zwave writes logs to logs/hc-zwave.log. Rotation and compression are configured in config/config.toml:

[logging]
level = "info" # stderr log level; RUST_LOG overrides this
rotation = "daily" # daily | hourly | weekly | never
max_size_mb = 100 # rotate when file exceeds this MB (0 = time-only)
compress = true # gzip rotated files in a background thread
FileDescription
logs/hc-zwave.logActive log (always uncompressed)
logs/hc-zwave.2026-03-27.log.gzRotated daily file (compressed)
logs/hc-zwave.2026-03-27.1.log.gzSecond rotation in same period (size limit hit)