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:
- Detect and communicate with Z-Wave devices in your home
- Turn lights on and off via API calls
- Dim lights to specific levels
- Provide a real-time web interface using Socket.IO
- 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 devicesexpress
: A minimal web framework for serving our frontend applicationsocket.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!