@bobfrankston/wzlan
Platform-agnostic Wiz LAN protocol library for controlling Wiz smart bulbs via UDP.
Architecture
wzlan (protocol core, platform-agnostic)
├── wzlan-node (Node.js adapter — NodeUdpTransport / dgram)
└── wzlan-browser (Browser adapter — WebSocket via httpudp-client)
Wiz uses JSON-over-UDP on two ports:
- 38899 — Send commands, receive responses
- 38900 — Receive heartbeat/syncPilot status (after registration)
Quick Start (Node.js)
import { createClient } from '@bobfrankston/wzlan-node';
const client = createClient();
client.on('device', (device) => {
console.log(`Found: ${device.mac} ${device.ip}`);
});
client.on('state', (device) => {
console.log(`${device.mac} power=${device.power} dim=${device.dimming} temp=${device.temp}`);
});
await client.start(); // Binds sockets, starts registration loop
// Heartbeats arrive automatically (~1/sec per bulb for ~20s, re-registered every 15s)
API
WzClient
Created via createClient() (wzlan-node) or createWzlanClient() (wzlan-browser).
| Method | Description |
|---|---|
start() |
Bind transport, start registration loop |
stop() |
Close sockets, stop timers |
register(heartbeats?) |
Broadcast registration (true=heartbeats, false=discovery only) |
discover() |
Shorthand for register(false) |
getDevice(mac) |
Look up device by MAC |
addDevice(mac, ip?, port?) |
Add device manually |
devices |
Map<string, WzDevice> of all known devices |
Events
| Event | Args | Description |
|---|---|---|
device |
(device) |
New device discovered |
state |
(device) |
State updated (syncPilot heartbeat or setPilot response) |
pilot |
(device, msg) |
getPilot response received |
systemConfig |
(device, msg) |
getSystemConfig response received |
message |
(device, msg) |
Any message received |
error |
(err) |
Transport or protocol error |
WzDevice
Extends DeviceBase from @bobfrankston/devdefs — shared state (mac, ip, port, power, label, online, lastSeen, markSeen()) and transport plumbing. See devdefs README for base class details and MAC utilities (mac12, macmac).
Properties updated automatically from heartbeats:
| Property | Type | Description |
|---|---|---|
mac |
string |
MAC address (lowercase, colon-separated) |
ip |
string |
Current IP address |
power |
boolean |
On/off state |
dimming |
number |
Brightness 0-100 |
color |
WizColor |
{ r, g, b, c, w } channels (0-255) |
temp |
number |
Color temperature in Kelvin |
sceneId |
number |
Active scene (0 = none) |
speed |
number |
Animation speed |
rssi |
number |
WiFi signal strength (dBm) |
fwVersion |
string |
Firmware version |
online |
boolean |
Responding to messages |
lastSeen |
number |
Timestamp of last message |
Control Methods (fire-and-forget)
device.setPower(true); // Turn on
device.setPower(false); // Turn off
device.setBrightness(75); // Set brightness 0-100
device.setColor(255, 0, 0); // Set RGB
device.setColor(0, 255, 0, 50); // Set RGB with dimming
device.setWhite(4200); // Set color temperature
device.setWhite(2700, 80); // Set temp with dimming
device.setScene(4); // Set scene by ID
device.setScene(5, 100, 80); // Scene with speed and dimming
device.pulse(-30, 900); // Pulse brightness
device.getPilot(); // Query current state
device.getSystemConfig(); // Query system config
Protocol Functions
For building custom messages:
import { encodeMessage, decodeMessage, createSetPilot, createRegistration } from '@bobfrankston/wzlan';
const msg = createSetPilot({ state: true, dimming: 75 });
const bytes = encodeMessage(msg);
// Send bytes via UDP to port 38899
const response = decodeMessage(receivedBytes);
Scenes
| ID | Name | ID | Name |
|---|---|---|---|
| 1 | Ocean | 17 | True Colors |
| 2 | Romance | 18 | TV Time |
| 3 | Sunset | 19 | Plant Growth |
| 4 | Party | 20 | Spring |
| 5 | Fireplace | 21 | Summer |
| 6 | Cozy | 22 | Fall |
| 7 | Forest | 23 | Deep Dive |
| 8 | Pastel Colors | 24 | Jungle |
| 9 | Wake Up | 25 | Mojito |
| 10 | Bedtime | 26 | Club |
| 11 | Warm White | 27 | Christmas |
| 12 | Daylight | 28 | Halloween |
| 13 | Cool White | 29 | Candlelight |
| 14 | Night Light | 30 | Golden White |
| 15 | Focus | 31 | Pulse |
| 16 | Relax | 32 | Steampunk |
| 1000 | Rhythm |
Constants
import { WIZ_PORT, WIZ_LISTEN_PORT, WizMethod, WizScenes } from '@bobfrankston/wzlan';
WIZ_PORT // 38899 — command port
WIZ_LISTEN_PORT // 38900 — heartbeat port
WizMethod.SetPilot // 'setPilot'
WizMethod.GetPilot // 'getPilot'
WizMethod.SyncPilot // 'syncPilot'
WizMethod.Registration // 'registration'
WizScenes[12] // 'Daylight'
wzlan-node
Passes NodeUdpTransport as the transport factory. WzClient itself creates both sockets: ephemeral port for commands (→38899), port 38900 for heartbeats, merged into a single message handler.
import { createClient } from '@bobfrankston/wzlan-node';
const client = createClient({
debug: true, // Log messages to console
discoveryInterval: 15000, // Re-register every 15s (default)
homeId: 704603, // Optional home ID
});
wzlan-browser
WebSocket transport via httpudp proxy. The httpudp server handles dual-port proxying server-side.
import { createWzlanClient } from '@bobfrankston/wzlan-browser';
const client = await createWzlanClient({
httpudpUrl: 'ws://localhost:9321',
registrationInterval: 15000,
});
wztest CLI
Simple test/control tool:
wztest Interactive mode
wztest list List discovered bulbs
wztest <mac> -on [dim] Turn on
wztest <mac> -off Turn off
wztest <mac> -dim 75 Set dimming
wztest <mac> -color 255 0 0 Set RGB
wztest <mac> -white 4200 Set white temp
wztest <mac> -scene 4 Set scene (Party)
wztest -v Verbose (show heartbeats)
Device can be partial MAC, partial IP, or #N from list.
Wiz Protocol Reference
Local vs Cloud Architecture
Wiz bulbs expose a local UDP API on the LAN and maintain a separate cloud connection via MQTT over TLS. The local API is intentionally limited — advanced configuration is gated behind the cloud path.
| Feature | Local UDP | Cloud (MQTT/App) |
|---|---|---|
| On/off, brightness, color, scenes | Yes (setPilot) |
Yes |
| State queries | Yes (getPilot) |
Yes |
| Discovery (broadcast) | Yes (registration) |
No |
| Read user config (fade, default dim) | Yes (getUserConfig) |
Yes |
| Write user config (fade, default dim) | No (returns error -32600) | Yes (setUserConfig via MQTT) |
| Fade speed / transitions | No | Yes |
| SpaceSense (Wi-Fi motion detection) | No | Yes (app → cloud → MQTT → bulb) |
| PIR motion sensor config | No | Yes |
| PIR motion events (passive listen) | Yes (syncPilot with src: "pir") |
Yes |
| Schedules | Partial | Yes |
| Firmware updates | No | Yes (signed, via firmware.wiz.world) |
UDP Methods (port 38899)
All methods use JSON. Some are read-only or disabled on UDP at the firmware level (a per-method flag byte controls UDP availability).
| Method | UDP | Description |
|---|---|---|
registration |
Yes | Discovery / heartbeat registration |
getPilot |
Yes | Query current light state |
setPilot |
Yes | Control light (on/off, color, brightness, scene) |
syncPilot |
Push | Heartbeat/state push from bulb (port 38900) |
getUserConfig |
Read-only | Returns fadeIn, fadeOut, dftDim, opMode, po, minDimming, tapSensor |
setUserConfig |
Blocked | Returns {"error":{"code":-32600,"message":"Invalid Request"}} |
getSystemConfig |
Yes | Internal device config (MAC, module, etc.) |
setSystemConfig |
Yes | Write internal config |
getModelConfig |
Yes | Device model/capabilities |
getDevInfo |
Yes | Device info |
setDevInfo |
Yes | Write device info |
getWifiConfig |
Yes | WiFi settings |
setWifiConfig |
Yes | Write WiFi settings |
pulse |
Yes | Pulse brightness |
reboot |
Yes | Reboot device |
reset |
Yes | Factory reset |
setState |
Yes | Set state |
setSchd |
Yes | Set schedule |
setSchdPset |
Yes | Set schedule preset |
setFavs |
Yes | Set favorites |
firstBeat |
Push | Initial config announcement from bulb |
getUserConfig Response Example
{
"method": "getUserConfig",
"env": "pro",
"result": {
"fadeIn": 700,
"fadeOut": 700,
"dftDim": 100,
"opMode": 0,
"po": false,
"minDimming": 0,
"tapSensor": 1
}
}
SpaceSense (Wi-Fi Motion Detection)
SpaceSense uses Wi-Fi CSI (Channel State Information) between 2+ bulbs to detect movement — no PIR hardware needed. Configuration is entirely cloud-side (app → Wiz cloud → MQTT → bulbs). There is no known local API to enable, disable, or configure SpaceSense.
The separate Wiz PIR motion sensor accessory communicates via BLE and triggers syncPilot pushes with "src": "pir" that can be passively observed on the local UDP API.
Network Details
- Port 38899: Bulb receives commands
- Port 38900: App/client receives heartbeats and push notifications
- Port 80: Open on bulb but no endpoints discovered
- MQTT:
mqtt.wiz.world/us.mqtt.wiz.worldport 8883 (TLS) — cloud connection with device-specific credentials - Protocol: Unencrypted JSON over UDP (local), MQTT over TLS (cloud)
- Chip: ESP8266 (older) / ESP32 (newer, for BLE + CSI support)
Cloud API (WiZ Pro)
The WiZ Pro API uses GraphQL over HTTPS with OAuth 2.0 + PKCE. It requires a clientId obtained from Wiz (no self-service signup). Available mutations are limited to light state control (changeLightState, changeRoomState, etc.) — no setUserConfig or SpaceSense control is exposed.
References
- pywizlight — Python connector, most complete reverse-engineering of the UDP protocol
- OpenWiz — C# library with protocol documentation
- pywizlight issue #155 — Reverse-engineering of UDP method flags and
setUserConfigblocking - wiz_light discussion #85 — Cloud vs local architecture details
- Home Assistant WiZ integration — HA integration (passive PIR/occupancy detection)
- Espressif ESP-CSI — Wi-Fi CSI motion detection (underlying tech for SpaceSense)
- Dammitly.net — Early hardware/protocol teardown
- WiZ SpaceSense — Official SpaceSense product page