@bobfrankston/wzlan
v0.1.12
Published
Wiz LAN protocol library for device control via UDP
Maintainers
Readme
@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 configProtocol 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
