Tutorial: Automating Z-Wave Light Switches with Node.js

So, at least one of my Twitter friends requested a tutorial after I recorded the original demo. It's a bit late, but here it is! In this tutorial, I'll show you how to create a Node.js application that can control Z-Wave light switches in your home, complete with a real-time web interface.

Z-Wave – a brief introduction

Z-Wave is a wireless communication protocol designed specifically for home automation. It creates a mesh network where devices can communicate with each other, extending the range of the network as you add more devices. Z-Wave operates on a different frequency than WiFi - 868.4MHz in the UK. This separation from WiFi frequencies helps avoid interference with your home network.

The protocol has been widely adopted by many smart home device manufacturers, making it an excellent choice for home automation projects. Unlike some proprietary systems, Z-Wave offers an open API that developers can use to build custom applications.

What we'll build

By the end of this tutorial, you'll have a Node.js application that can:

  1. Detect and communicate with Z-Wave devices in your home
  2. Turn lights on and off via API calls
  3. Dim lights to specific levels
  4. Provide a real-time web interface using Socket.IO
  5. Get instant feedback when light states change

Here's a quick look at what our final application structure will look like:

z-wave-lights/
├── public/
│   ├── index.html
│   ├── style.css
│   └── client.js
├── server.js
└── package.json

What you'll need

Before we get started, make sure you have the following:

  • A Z-Wave controller/USB stick (like the Aeotec Z-Stick)
  • At least one Z-Wave compatible light switch or dimmer
  • Node.js installed on your computer
  • Basic understanding of JavaScript
  • Administrative/sudo access to your machine (required for USB device access)

Setting up the environment

First, let's create a new directory for our project and initialize a Node.js application:

mkdir z-wave-lights && cd z-wave-lights
npm init -y

Next, we'll install the required dependencies:

npm install --save openzwave-shared express socket.io

Let's break down what each package does:

  • openzwave-shared: A Node.js wrapper for the OpenZWave library, which provides the core functionality for communicating with Z-Wave devices
  • express: A minimal web framework for serving our frontend application
  • socket.io: A library that enables real-time, bidirectional communication between web clients and the server

Note on OpenZWave libraries:

There are two main libraries for using Z-Wave with Node.js: openzwave and openzwave-shared. This tutorial uses openzwave-shared, which dynamically links to the OpenZWave C++ library installed on your system. Unlike the original openzwave package (which statically bundles the entire OpenZWave library), openzwave-shared requires manual installation of OpenZWave first but offers better maintainability, more frequent updates, and additional security and management features. With openzwave-shared, you can update the underlying OpenZWave implementation without rebuilding the entire Node.js addon.

Connecting to the Z-Wave network

Let's create our server file. Create a new file called server.js in your project directory and add the following code:

// server.js
var OpenZWave = require('openzwave-shared');
var express = require('express');
var app = express();
var http = require('http').Server(app);
var io = require('socket.io')(http);

// Serve the static files
app.use(express.static('public'));

// Store information about all discovered Z-Wave nodes
var nodes = [];

// Store dimmer values for quick access
var dimValues = [];

// Configure Z-Wave options
var zwave = new OpenZWave({
    logging: true,           // enable logging to OZW_Log.txt
    consoleoutput: false,    // don't copy logging to the console
    saveconfig: true,        // write an XML network layout
    driverattempts: 3,       // try this many times before giving up
    pollinterval: 500,       // interval between polls in milliseconds
    suppressrefresh: true    // don't send updates if nothing changed
});

// Find the correct port for your Z-Wave stick
// On Linux, this is typically /dev/ttyACM0 or /dev/ttyUSB0
// On Mac, it might be /dev/cu.SLAB_USBtoUART or /dev/cu.usbmodem*
// On Windows, it would be COM3, COM4, etc.
var zstickPort = '/dev/ttyUSB0'; // Adjust this to match your system

This initializes the necessary libraries and sets up configuration variables. You'll need to adjust the zstickPort variable to match the port where your Z-Wave USB stick is connected.

Setting up event listeners

Z-Wave communication is event-driven. Let's add event listeners to handle these events:

// Add after the previous code in server.js

// Handler when Z-Wave driver is ready
zwave.on('driver ready', function(homeid) {
    console.log('Scanning Z-Wave network with home ID: 0x%s...', homeid.toString(16));
});

// Handler if driver fails to start
zwave.on('driver failed', function() {
    console.log('Failed to start Z-Wave driver');
    zwave.disconnect();
    process.exit();
});

// Handler when a new node (device) is discovered
zwave.on('node added', function(nodeid) {
    nodes[nodeid] = {
        manufacturer: '',
        manufacturerid: '',
        product: '',
        producttype: '',
        productid: '',
        type: '',
        name: '',
        loc: '',
        classes: {},
        ready: false,
    };
    console.log('Node added: %d', nodeid);
});

// Handler when a value is added to a node
zwave.on('value added', function(nodeid, comclass, value) {
    if (!nodes[nodeid]['classes'][comclass])
        nodes[nodeid]['classes'][comclass] = {};
    
    nodes[nodeid]['classes'][comclass][value.index] = value;
});

// Handler when a value changes
zwave.on('value changed', function(nodeid, comclass, value) {
    if (nodes[nodeid]['ready']) {
        console.log('Node %d: value changed: %s -> %s', 
            nodeid, 
            nodes[nodeid]['classes'][comclass][value.index]['value'],
            value['value']);
        
        // Store dimmer values for quick access
        dimValues[nodeid] = value['value'];
        
        // Send updates to connected clients
        io.emit('updateClient', { node: nodeid, value: value['value'] });
    }
    
    // Update our stored value
    nodes[nodeid]['classes'][comclass][value.index] = value;
});

// Handler when a node is ready with complete information
zwave.on('node ready', function(nodeid, nodeinfo) {
    nodes[nodeid]['manufacturer'] = nodeinfo.manufacturer;
    nodes[nodeid]['manufacturerid'] = nodeinfo.manufacturerid;
    nodes[nodeid]['product'] = nodeinfo.product;
    nodes[nodeid]['producttype'] = nodeinfo.producttype;
    nodes[nodeid]['productid'] = nodeinfo.productid;
    nodes[nodeid]['type'] = nodeinfo.type;
    nodes[nodeid]['name'] = nodeinfo.name;
    nodes[nodeid]['loc'] = nodeinfo.loc;
    nodes[nodeid]['ready'] = true;
    
    console.log('Node %d ready: %s, %s', nodeid,
        nodeinfo.manufacturer ? nodeinfo.manufacturer : 'id=' + nodeinfo.manufacturerid,
        nodeinfo.product ? nodeinfo.product : 'product=' + nodeinfo.productid + ', type=' + nodeinfo.producttype
    );
    
    // Enable polling for dimmer switches and binary switches
    for (comclass in nodes[nodeid]['classes']) {
        switch (comclass) {
            case 0x25: // COMMAND_CLASS_SWITCH_BINARY
            case 0x26: // COMMAND_CLASS_SWITCH_MULTILEVEL
                zwave.enablePoll(nodeid, comclass);
                break;
        }
        
        var values = nodes[nodeid]['classes'][comclass];
        for (idx in values) {
            if (values[idx]['label'] == 'Level') {
                dimValues[nodeid] = values[idx]['value'];
                console.log('Node %d: Level = %d', nodeid, values[idx]['value']);
            }
        }
    }
    
    // Notify clients of updated node
    io.emit('nodeReady', { 
        node: nodeid,
        name: nodeinfo.name,
        type: nodeinfo.type,
        value: dimValues[nodeid]
    });
});

// Handler when Z-Wave network scan is complete
zwave.on('scan complete', function() {
    console.log('Z-Wave network scan complete');
    console.log('Found %d nodes', Object.keys(nodes).length);
    
    // At this point, you have a complete view of your Z-Wave network
    io.emit('scanComplete', Object.keys(nodes).map(function(nodeid) {
        return {
            node: nodeid,
            name: nodes[nodeid].name,
            type: nodes[nodeid].type,
            value: dimValues[nodeid]
        };
    }));
});

// Connect to the Z-Wave controller
zwave.connect(zstickPort);

// Clean disconnection on exit
process.on('SIGINT', function() {
    console.log('Disconnecting from Z-Wave network...');
    zwave.disconnect(zstickPort);
    process.exit();
});

These event handlers manage the life cycle of Z-Wave device discovery, value updates, and network scanning.

Creating the lights API

Now let's create a simple API that we can use to control our lights:

// Add after the previous code in server.js

// Lights control API
var lights = {
    // Turn a light on (set to level 99)
    on: function(id) {
        if (nodes[id]) {
            console.log('Turning node %d on', id);
            zwave.setLevel(id, 99);
            return true;
        }
        return false;
    },
    
    // Turn a light off (set to level 0)
    off: function(id) {
        if (nodes[id]) {
            console.log('Turning node %d off', id);
            zwave.setLevel(id, 0);
            return true;
        }
        return false;
    },
    
    // Get current value for a specific node or all nodes
    get: function(id) {
        if (id) return dimValues[id];
        return dimValues;
    },
    
    // Set dimming level (0-99)
    set: function(id, level) {
        if (nodes[id]) {
            level = parseInt(level);
            if (level < 0) level = 0;
            if (level > 99) level = 99;
            
            console.log('Setting node %d level to %d', id, level);
            zwave.setLevel(id, level);
            return true;
        }
        return false;
    },
    
    // Get information about all nodes
    getNodes: function() {
        return Object.keys(nodes).map(function(nodeid) {
            return {
                id: nodeid,
                name: nodes[nodeid].name || 'Node ' + nodeid,
                type: nodes[nodeid].type,
                manufacturer: nodes[nodeid].manufacturer,
                product: nodes[nodeid].product,
                ready: nodes[nodeid].ready,
                value: dimValues[nodeid]
            };
        });
    }
};

This API provides methods to turn lights on and off, set dimming levels, and retrieve information about the Z-Wave nodes.

Setting up Socket.IO for real-time communication

Next, let's configure Socket.IO to handle real-time communication with the browser:

// Add after the previous code in server.js

// Handle Socket.IO connections
io.on('connection', function(socket) {
    console.log('Client connected');
    
    // Send current values when a client connects
    socket.emit('initialValues', lights.getNodes());
    
    // Handle toggle (on/off) requests
    socket.on('toggle', function(data) {
        if (data.state === 'on') {
            lights.on(data.id);
        } else {
            lights.off(data.id);
        }
    });
    
    // Handle dimming requests
    socket.on('dim', function(data) {
        lights.set(data.id, data.value);
    });
    
    // Handle disconnection
    socket.on('disconnect', function() {
        console.log('Client disconnected');
    });
});

// Start the server
var PORT = process.env.PORT || 3000;
http.listen(PORT, function() {
    console.log('Server listening on port %d', PORT);
});

This sets up a Socket.IO server that listens for connections from web clients and defines handlers for various events.

Creating the web interface

Now let's create a simple web interface to control our lights. First, create a public directory in your project folder:

mkdir public

Create an index.html file in the public directory:

<!-- public/index.html -->
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Z-Wave Light Control</title>
    <link rel="stylesheet" href="style.css">
</head>
<body>
    <div class="container">
        <h1>Z-Wave Light Control</h1>
        <p class="status">Connecting to server...</p>
        
        <div id="lights-container">
            <!-- Lights will be added here dynamically -->
        </div>
    </div>
    
    <script src="/socket.io/socket.io.js"></script>
    <script src="client.js"></script>
</body>
</html>

Next, create a style.css file in the public directory:

/* public/style.css */
* {
    box-sizing: border-box;
    margin: 0;
    padding: 0;
}

body {
    font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
    line-height: 1.6;
    color: #333;
    background-color: #f5f5f5;
    padding: 20px;
}

.container {
    max-width: 800px;
    margin: 0 auto;
    background-color: white;
    padding: 20px;
    border-radius: 5px;
    box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
}

h1 {
    margin-bottom: 20px;
    color: #2c3e50;
}

.status {
    margin-bottom: 20px;
    padding: 10px;
    background-color: #f8f9fa;
    border-radius: 4px;
}

.light-card {
    border: 1px solid #ddd;
    border-radius: 4px;
    padding: 15px;
    margin-bottom: 15px;
    display: flex;
    flex-direction: column;
}

.light-header {
    display: flex;
    justify-content: space-between;
    align-items: center;
    margin-bottom: 15px;
}

.light-name {
    font-size: 18px;
    font-weight: bold;
}

.light-id {
    color: #6c757d;
    font-size: 14px;
}

.controls {
    display: flex;
    flex-direction: column;
    gap: 10px;
}

.button-group {
    display: flex;
    gap: 10px;
}

button {
    padding: 8px 12px;
    border: none;
    border-radius: 4px;
    cursor: pointer;
    font-weight: bold;
}

.on-btn {
    background-color: #28a745;
    color: white;
}

.off-btn {
    background-color: #dc3545;
    color: white;
}

.slider-container {
    display: flex;
    align-items: center;
    gap: 10px;
}

.slider {
    flex-grow: 1;
    height: 8px;
    -webkit-appearance: none;
    background: #ddd;
    outline: none;
    border-radius: 4px;
}

.slider::-webkit-slider-thumb {
    -webkit-appearance: none;
    width: 20px;
    height: 20px;
    background: #007bff;
    border-radius: 50%;
    cursor: pointer;
}

.slider-value {
    min-width: 40px;
    text-align: center;
}

Finally, create a client.js file in the public directory:

// public/client.js
(function() {
    // Connect to the WebSocket server
    var socket = io();
    
    // Get DOM elements
    var status = document.querySelector('.status');
    var lightsContainer = document.getElementById('lights-container');
    
    // Update status when connected
    socket.on('connect', function() {
        status.textContent = 'Connected to server';
        status.style.color = '#28a745';
    });
    
    // Update status when disconnected
    socket.on('disconnect', function() {
        status.textContent = 'Disconnected from server';
        status.style.color = '#dc3545';
    });
    
    // Process initial values when first connecting
    socket.on('initialValues', function(nodes) {
        console.log('Initial values received:', nodes);
        status.textContent = 'Connected - Found ' + nodes.length + ' devices';
        
        // Clear the container
        lightsContainer.innerHTML = '';
        
        // Create UI for each light
        nodes.forEach(function(node) {
            if (node.ready) {
                createLightUI(node);
            }
        });
    });
    
    // Handle updating values from server
    socket.on('updateClient', function(data) {
        console.log('Value update received:', data);
        
        // Find the slider and value display for this node
        var slider = document.querySelector('.light-' + data.node + ' .slider');
        var valueDisplay = document.querySelector('.light-' + data.node + ' .slider-value');
        
        if (slider && valueDisplay) {
            slider.value = data.value;
            valueDisplay.textContent = data.value + '%';
        }
    });
    
    // Handle new nodes becoming ready
    socket.on('nodeReady', function(data) {
        console.log('Node ready:', data);
        
        // Check if this node already has UI
        var existingNode = document.querySelector('.light-' + data.node);
        
        if (!existingNode) {
            createLightUI(data);
        }
    });
    
    // Create UI for a light
    function createLightUI(node) {
        var card = document.createElement('div');
        card.className = 'light-card light-' + node.id;
        
        var header = document.createElement('div');
        header.className = 'light-header';
        
        var name = document.createElement('div');
        name.className = 'light-name';
        name.textContent = node.name || 'Light ' + node.id;
        
        var id = document.createElement('div');
        id.className = 'light-id';
        id.textContent = 'ID: ' + node.id;
        
        header.appendChild(name);
        header.appendChild(id);
        
        var controls = document.createElement('div');
        controls.className = 'controls';
        
        var buttonGroup = document.createElement('div');
        buttonGroup.className = 'button-group';
        
        var onBtn = document.createElement('button');
        onBtn.className = 'on-btn';
        onBtn.textContent = 'ON';
        onBtn.addEventListener('click', function() {
            socket.emit('toggle', { id: node.id, state: 'on' });
        });
        
        var offBtn = document.createElement('button');
        offBtn.className = 'off-btn';
        offBtn.textContent = 'OFF';
        offBtn.addEventListener('click', function() {
            socket.emit('toggle', { id: node.id, state: 'off' });
        });
        
        buttonGroup.appendChild(onBtn);
        buttonGroup.appendChild(offBtn);
        
        var sliderContainer = document.createElement('div');
        sliderContainer.className = 'slider-container';
        
        var slider = document.createElement('input');
        slider.type = 'range';
        slider.min = '0';
        slider.max = '99';
        slider.value = node.value || '0';
        slider.className = 'slider';
        
        var sliderValue = document.createElement('span');
        sliderValue.className = 'slider-value';
        sliderValue.textContent = (node.value || '0') + '%';
        
        // Add change event to slider
        slider.addEventListener('input', function() {
            sliderValue.textContent = this.value + '%';
        });
        
        slider.addEventListener('change', function() {
            socket.emit('dim', { id: node.id, value: parseInt(this.value) });
        });
        
        sliderContainer.appendChild(slider);
        sliderContainer.appendChild(sliderValue);
        
        controls.appendChild(buttonGroup);
        controls.appendChild(sliderContainer);
        
        card.appendChild(header);
        card.appendChild(controls);
        
        lightsContainer.appendChild(card);
    }
})();

This creates a simple web interface with controls for each light. It uses Socket.IO to communicate with the server in real-time.

Running the application

Now you can run your application with: node server.js

Note: sudo may be required to access the USB device on Linux/Mac systems. For a production setup, you might want to configure udev rules to give your user permission to access the device without sudo.

Once running, open a web browser and navigate to http://localhost:3000 to see your Z-Wave light control interface.

Adding additional features

Here are some ideas for extending your application:

1. Save and recall scenes

Add the ability to save combinations of light settings as "scenes" that can be recalled with a single click:

// Add to your lights API
var scenes = {
    "Movie Time": {
        1: 20,  // Node 1 at 20%
        2: 10   // Node 2 at 10%
    },
    "Bright": {
        1: 99,
        2: 99
    }
};

lights.activateScene = function(sceneName) {
    var scene = scenes[sceneName];
    if (scene) {
        for (var nodeId in scene) {
            this.set(nodeId, scene[nodeId]);
        }
        return true;
    }
    return false;
};

2. Scheduled events

Implement a scheduling system to automate lights based on time of day:

var schedule = require('node-schedule');

// Turn lights on at sunset (7:30 PM in this example)
var evening = schedule.scheduleJob('30 19 * * *', function() {
    lights.activateScene('Evening');
});

// Turn lights off at midnight
var midnight = schedule.scheduleJob('0 0 * * *', function() {
    Object.keys(nodes).forEach(function(nodeid) {
        lights.off(nodeid);
    });
});

3. Integration with other services

You could integrate your system with other home automation services like IFTTT, Amazon Alexa, or Google Home by creating appropriate API endpoints.

Troubleshooting

Z-Wave device not responding

  • Check that your Z-Wave controller is properly connected
  • Ensure the device is within range of the Z-Wave network
  • Try restarting the Z-Wave controller
  • Check if the device needs new batteries

Permission issues with USB device

On Linux, you might need to add a udev rule to give your user access to the Z-Wave controller without sudo:

nano /etc/udev/rules.d/99-usb-serial.rules

Add a line like:

SUBSYSTEM=="tty", ATTRS{idVendor}=="0658", ATTRS{idProduct}=="0200", SYMLINK+="zwave", GROUP="dialout", MODE="0660"

Adjust the vendor and product IDs to match your Z-Wave controller.

Identifying device information

To find information about your Z-Wave devices, you can modify the node ready event handler to log more details:

zwave.on('node ready', function(nodeid, nodeinfo) {
    // ... existing code ...
    
    console.log('Node details:');
    console.log(JSON.stringify(nodeinfo, null, 2));
    
    // ... rest of the code ...
});

Conclusion

You've now built a functional Z-Wave light control system using Node.js! The application allows you to control your Z-Wave lights from any device with a web browser on your local network. The real-time nature of Socket.IO means that any changes to light states, whether they come from your web interface or from physical switches, are immediately reflected across all connected clients.

This is just a starting point. From here, if you wanted to, you could:

  • Build more sophisticated scenes and automation
  • Add user authentication for remote access
  • Create a responsive mobile interface
  • Integrate with other smart home systems
  • Implement voice control

The beauty of rolling your own solution is that you can customize it to fit your specific needs, rather than being limited by the features of stuff you buy off Amazon ;) Have fun automating your home!