Z-Wave Controllers using Dell Wyse Thin Clients

About two years ago my family and I moved into our (hopefully) forever home. This house has an unfortunately large number of exterior doors and is situated on a main road, so we wanted to make sure that most of the doors are locked without having to patrol the doors before bedtime every night.

To that end, I put z-wave locks on every door and built out a z-wave network so they could be reached from my Home Assistant instance that shows their status on a nice tidy dashboard.

The Beginning

Originally I had a z-wave USB stick just stuck into the server running Home Assistant. This is a problem for a couple reasons. First, the z-wave network was rebooting and taking forever to start up every time I restarted Home Assistant (which, at the beginning, was extremely frequently).

Second, this house is very long (about 140ft) and has a number of thick cinderblock walls between where I put the server and where the furthest locks are. Z-wave doesn't travel too well between cinderblock so I ended up having to use a couple repeaters, which worked ok but added so much latency that status updates just were not very reliable.

A New Plan

We suffered with the over-extended mesh for quite a long time before I cooked up a plan:

  1. Each zone (defined as an area surrounded by cinderblock) will have a zwave gateway consisting of a raspberry pi and a z-wave USB stick
  2. Each zone will have an independent z-wave network, rather than trying to bridge them with repeaters.
  3. All the zones will integrate into one Home Assistant instance

This plan worked well, except that raspberry pi's are not very reliable for this application. Two of the zones are in unconditioned spaces and for whatever reason their connection would drop when it got cold. Also, those same two zones were using raspberry pi zero w's which are slow as heck trying to do anything.

Plan B: Replace It All

After reading a lot and trolling eBay for hardware I came across the Dell Wyse 3040 thin client. Ostensibly these are for running RDP sessions to remove Windows servers but they're quite overpowered. These little machines have four Intel Atom cores, 2GB of memory, and 8GB of onboard storage, plus a bunch of USB ports, two DisplayPorts and an ethernet jack. They can run whatever Linux you want, provided you can get into the BIOS settings and change the boot order. They're perfect for what I want to do, I just have to be careful with the built-in storage because it's exactly equivalent to an 8GB SD card soldered to the motherboard and can wear out after too many writes.

Dell Wyse 3040 thin client sitting on a desk with a red Swingline stapler for scale

Software stack

I settled on this software stack for each gateway:

  • Alpine Linux as the base OS
  • zwavejs2mqtt running in a Docker container with the USB stick passed through
  • Managed with Portainer as an Edge Agent
  • zwavejs2mqtt ports only open to Tailscale

Alpine is a minimal linux that really tries hard to be small while also fully featured. It's widely used as a base for Docker containers but for this application I wanted to install it directly on the hardware. Alpine has a sys installation type that works well for this. I spent too much time trying to make a custom image that golfed down the installed size and just ended up using the stock ISO installer.

zwavejs2mqtt dashboard

ZwaveJS2MQTT is a great UI on top of a pure-JS z-wave stack (zwave-js), which in turn has a first-party integration with Home Assistant. In my setup I actually have HA talking to the gateways via their websocket rather than using MQTT as an intermediary. I'm running their official Docker container and passing the USB z-wave stick through.

Portainer bills itself as "a centralized service delivery platform" which I'd say is fairly accurate. I'm using it to deploy a docker-compose file across my little fleet of thin clients without having to manually ssh into each machine and run a docker command. Each node is set up as an edge agent, which tells them to check in with the central server every few seconds, ask for work, and then go back to sleep. If the server needs their attention it'll tell them to open a tunnel on their next check-in so it can access their docker socket. All of this happens transparently, all I ever have to interact with is the central server's UI.

Tailscale is a managed mesh overlay network based on Wireguard. Every node in the mesh tries really hard to make direct Wireguard-encrypted connections to every other node. The Tailscale control plane handles public key distribution to all of your nodes as well as serving as an introduction point when nodes can't connect directly. It also has a tag-based ACL firewall system that lets me lock down the z-wave gateways so only Home Assistant can talk to them. This is important because zwavejs2mqtt doesn't ship with authentication built-in for the web UI, anyone can connect to the websocket if it's turned on and issue commands. I put them on tailscale to mitigate a situation where someone cracks my wifi password and starts scanning my network.

Setting it all up

Setting up the nodes was pretty standard once I got going. I created a generic USB bootable drive out of an external drive I had laying around using Ventoy. Once Ventoy is set up you just have to copy ISOs onto the drive and Ventoy presents a nice menu on boot to select one to boot. The way it works under the hood is fascinating: it uses iPXE to present the menu, then chainloads into the ISO you select. It can even do cloud-init/kickstart for a couple different Linux distributions (sadly not Alpine).

After getting Alpine installed I curl | bash'd a setup script. This doesn't do much of anything interesting, except for that last line:

docker network create -d bridge -o com.docker.network.bridge.host_binding_ipv4=$(tailscale ip | head -n1) tailnet

This is a docker network that bridges into tailscale, which means my docker-compose.yml file for the zwave stack can be nice and generic:

version: '3.7'
services:
  zwavejs2mqtt:
    container_name: zwavejs2mqtt
    image: zwavejs/zwavejs2mqtt:6.5.2
    restart: always
    tty: true
    stop_signal: SIGINT
    privileged: true
    networks:
      - tailnet
    ports:
      - '8091:8091'
      - '3000:3000'
    environment:
      - ZWAVEJS_EXTERNAL_CONFIG=/usr/src/app/store/.config-db
    devices:
      - ${ZWAVE_DEVICE_PATH:-/dev/serial/by-id/usb-0658_0200-if00}:/dev/zwave:rmw
    volumes:
      - zwave-config:/usr/src/app/store
volumes:
  zwave-config:
    name: zwave-config
networks:
  tailnet:
    external: true

Before creating that network I was trying to pass the tailscale IP in through an environment variable and the compose file was super ugly.


I started rolling out this system a couple months ago and finally put the last node in production earlier this week. It's been working very well, much better than the raspberry pi-based nodes it replaced. I think it also means I'm going to avoid raspberry pis unless I absolutely need the GPIO. They're just not very good machines.