zigduck is the flake that brings version control to your smart home. A NixOS-based Zigbee full-stack home automation system that's reproducible and deployable. Nix for configuration, Rust for responsive async runtime. Under the hood: zigbee2mqtt, Mosquitto, tokio/serde_json and adb.
Define once, deploy forever.
zigduck uses smart defaults, after defining your rooms & devices -- most users don’t need to write any automations at all. Lights, dimmers, motion sensors - it should all work as expected out of the box. Everything is configurable via NixOS options.
An optional dashboard page is generated from the defined Nix configuration to display customized cards as well as scene activation and device control on-the-fly.
Nix
│
▼
zigduck
│
┌──────┴──────┐
▼ ▼
MQTT REST API
│ │
▼ ▼
zigbee2mqtt adb/media
│ │
└──────┬──────┘
▼
Devices❄️ Using flakes
inputs = {
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
zigduck.url = "github:quackhack-mcblindy/zigduck";
}; imports = [
zigduck.nixosModules.zigduck
];Note: the module also requires
selfandinputsas module arguments. Pass them to yournixosSystemviaspecialArgs:
nixosSystem {
specialArgs = { inherit self inputs; };
...
} services.zigduck = {
enable = true;
cli.enable = true;
# for security reasons, it's highly recommended to serve the dashboard over a reverse proxy (Nginx, Caddy, Traefik, etc).
dashboard = {
enable = true;
# set to false if using http (not recommended)
secure = true;
port = 13336;
openFirewall = true;
passwordFile = config.sops.secrets.dashboard.path;
};
# if using `yo` and want to execute scripts from the `zigduck` user
extraEnv.PATH =
"/run/current-system/sw/bin:"
+ "/optional/wrappers";
};
};If anything about the configuration is unclear or if you have questions,
a good starter point would be to study the options which has detailed descriptions for everything.
🛜 Zigbee configuration
Example configuration:
house = {
zigbee = {
# points to the zigbee network encryption key. zigbee devices join a network using this key.
# if you lose/change it, all devices must be re-paired.
# without this network key there is no reproducibility!
networkKeyFile = config.sops.secrets.z2m_network_key.path;
# define coordinator stick
coordinator = {
vendorId = "10c3"; # USB identifiers
productId = "ea61";
symlink = "zigbee"; # symlinks usb port as "/dev/zigbee"
};
mosquitto = {
host = "192.168.1.110";
username = "duckmqtt";
passwordFile = config.sops.secrets.mosquitto.path;
};
# [optional] Philips Hue hdmi sync box
hueSyncBox = {
enable = true;
syncBox = {
ip = "192.168.1.34";
passwordFile = config.sops.secrets.hueBridgeAPI.path;
tv = "shield";
};
# Philips Hue bridge required for sync box
# hue devices are still fully integrated
bridge = {
ip = "192.168.1.33";
# to fetch api token:
# curl -X POST http://192.168.1.33/api -d '{"devicetype":"house#nixos"}'
passwordFile = config.sops.secrets.hueBridgeAPI.path;
};
};
};🛁 Rooms
Example configuration:
house = {
rooms = {
bedroom.icon = "mdi:bed";
hallway.icon = "mdi:door";
kitchen.icon = "mdi:food-fork-drink";
livingroom.icon = "mdi:sofa";
wc.icon = "mdi:toilet";
tv-area.icon = "mdi:television";
};💡 Lights / Devices
zigduck always uses smart defaults. Define a dimmer, or motion sensor and those devices would default to control it's defined room, unless overridden.
Example configuration:
house = {
zigbee = {
devices = {
"0x0016830103ba7e95" = { # 64bit IEEE address (this is the unique device ID)
friendly_name = "Dimmer Switch Kitchen"; # simple human readable friendly name
room = "kitchen"; # bind to group
type = "dimmer"; # device type (light, hue_light, dimmer, motion, sensor, blinds, ...)
endpoint = 1; # zigbee endpoint
icon = "mdi:toggle-switch"; # icon used on dashboard
batteryType = "CR2450"; # optional - currently only used as a note to self
};
"0x0017880402750848a" = {
friendly_name = "Spotlight 1";
room = "kitchen";
type = "light";
icon = "mdi:spotlight";
supports_temperature = true;
endpoint = 11;
};
"00178801095f06300b" = {
friendly_name = "TV Play Strip";
room = "tv-area";
type = "hue_light";
icon = "mdi:light-strip";
endpoint = 1;
supports_color = true;
hue_id = 38;
};
"0x54ef4410003e58e2" = {
friendly_name = "Roller Shade";
room = "livingroom";
type = "blind";
icon = "mdi:blinds";
endpoint = 1;
};
"0x00178801021311c4" = {
friendly_name = "Motion Sensor Hall";
room = "hallway";
type = "motion";
icon = "mdi:motion-sensor";
endpoint = 1;
batteryType = "AAA";
};
"0x00158d00053ec9b1" = {
friendly_name = "Door Sensor Hall";
room = "hallway";
type = "sensor";
icon = "mdi:door";
endpoint = 1;
};
};
};🎚 Dimmers / Motion (optional)
Example configuraiton:
house.zigbee = {
dimmer = {
message = "action";
# double clicking on automatically cycles defined scenes in the dimmers room.
doubleClickTimeout = 500; # ms
# optional as these defaults match most dimmers
#actions = {
# onPress = "on_press_release";
# onHold = "on_hold_release";
# upPress = "op_press_release";
# upHold = "up_hold_release";
# downPress = "down_press_release";
# downHold = "down_hold_release";
# offPress = "off_press_release";
# offHold = "off_hold_release";
#};
};
motion = {
when.dark.enable = true; # enabled by default
trigger.lights = {
# time window in which motion trigger lights on
after = 14;
before = 9;
duration = 900; # turn off lights again after x seconds of no motion
transition = false; # enable to fade off the lights
};
};
no.motion = {
trigger.all.lights.off = {
enable = true; # disabled by default
after = 60; # minutes
exclude = [ "Spotlight 1" "Spotlight 2" ]; # exclude list of devices by friendly name. (wont turn off)
};
};| Action | Behavior |
|---|---|
| On — press | Turn on all room lights |
| On — multiple presses | Cycle through room scenes |
| On — hold | Turn on all lights |
| Up — press | Increase room brightness |
| Down — press | Decrease room brightness |
| Off — press | Turn off room lights |
| Off — hold | Turn off all lights |
To customize these actions, configure the automation type house.zigbee.automations.dimmer_actions.
🎨 Scenes (optional)
Example configuraiton:
house.zigbee = {
scenes = {
"Scene name" = {
# device friendly_name
"Spotlight 1" = {
state = "ON";
brightness = 200;
color = { hex = "#00FF00"; };
};
"Spotlight 2" = {
state = "OFF";
transition = 100;
};
# ... more lights🤖 Automations (optional)
Example configuraiton:
house = {
zigbee = {
# there are 6 different automation types
automations = {
# + a greeting automation
greeting = {
enable = true;
awayDuration = 7200; # only trigger if nobody home for x seconds
door = "Door Sensor Hallway"; # when this door opens
delay = 10; # wait x seconds before action is performed
actions = [
{
type = "shell";
command = ''
tts command here for example
'';
}
];
};
# 1. time based automations
time_based = {
morning_wakeup = {
enable = true;
description = "set morning wakeup alarm";
schedule = {
start = "01:00";
days = ["mon" "tue" "wed" "thu" "fri"];
};
actions = [ "zigduck-cli alarm add --hours 11 --minutes 00" ];
};
};
# 2. MQTT triggered automations
mqtt_triggered = {
alarm_wakeup = {
enable = true;
description = "Time to wake up!";
topic = "zigbee2mqtt/alarm/triggered";
actions = [
# there are 7 different automation action types
# 1. shell
{ type = "shell"; command = "tv --typ youtube --search 'nisse snus'"; }
# 3. snapshot
{ type = "snapshot"; snapshot_name = "before_alarm"; }
# 4. scene
{ type = "scene"; scene = "max"; }
# 5. mqtt
{ type = "mqtt"; topic = "zigbee2mqtt/Robot Arm 3/set"; message = ''{"state":"OFF"}''; }
{ type = "mqtt"; topic = "zigbee2mqtt/Robot Arm 4/set"; message = ''{"state":"OFF"}''; }
# 6. wait
{ type = "wait"; duration = 10; }
{ type = "scene"; scene = "dark-fast"; }
{ type = "wait"; duration = 2; }
{ type = "shell"; command = "curl http://192.168.1.13/api/settings/speaker/play/ding"; }
# 7. simple string (shell shortcut)
# (if using `yo`) this can be used for simple automations using natural language
"yo do 'turn on all lights'"
{ type = "wait"; duration = 2; }
{ type = "shell"; command = "curl http://192.168.1.15/api/settings/speaker/play/ding"; }
{ type = "wait"; duration = 10; }
{ type = "mqtt"; topic = "zigbee2mqtt/Roller Shade/set"; message = ''{"state":"ON"}''; }
# 8. restore (snapshot)
{ type = "restore"; snapshot_name = "before_alarm"; }
];
};
timer_finish = {
enable = true;
description = "a timer is ringing";
topic = "zigbee2mqtt/timer/finished";
actions = [
{ type = "scene"; scene = "max"; }
{ type = "shell"; command = "curl http://192.168.1.15/api/settings/speaker/play/ding"; }
{ type = "wait"; duration = 7; }
{ type = "scene"; scene = "dark-fast"; }
{ type = "wait"; duration = 2; }
{ type = "scene"; scene = "max"; }
];
};
};
# 3. room action automations
room_actions = {
hallway = {
# simple string can be used as "shell" automation action
door_opened = [ "curl http://192.168.1.15/api/settings/speaker/play/ding" ];
door_closed = [];
};
kitchen = {
motion_not_detected = [
{
type = "shell";
command = ''
power=$(jq -r '."Fläkt".power' /var/lib/zigduck/state.json)
# if kitchen fan is consuming energy turn it off after 2 minutes
if (( power > 20 )); then
zigduck-cli --publish --topic "zigduck/Fläkt/set" --payload '{"countdown": 120}'
fi
'';
}
# slowly turn off kitchen lights
{ type = "scene"; scene = "kitchenFadeOff"; }
];
motion_detected = [
# instant lights
{ type = "scene"; scene = "kitchenInstant"; }
{
type = "shell";
command = ''
# cancel any pending countdown
zigduck-cli --publish --topic "zigduck/Fläkt/set" --payload '{"countdown": 0}'
# if fan is off - start it
STATE=$(jq -r '."Fläkt".state' /var/lib/zigduck/state.json)
if [ "$STATE" = "OFF" ]; then
zigduck-cli --device "Fläkt" --state on
fi
'';
}
];
};
};
# 4. global actions automations
global_actions = {
leak_detected = [ "notify '🚨 WATER LEAK DETECTED!'" ];
smoke_detected = [ "notify '🔥 SMOKE DETECTED!'" ];
};
# 5. dimmer actions automations (default configured per room)
dimmer_actions = {
bedroom = {
off_hold_release = {
enable = true;
description = "Turn off all configured light devices + turn off kitchen fan";
extra_actions = [];
override_actions = [
{
type = "scene";
scene = "dark";
}
{
type = "mqtt";
topic = "zigbee2mqtt/Fläkt/set";
message = ''{"state":"OFF"}'';
}
];
};
};
};
# 6. presence based automations
presence_based = {};
};📺 Media (optional)
Example configuraiton:
house = {
# Android TV requires a `https` domain (TLS) to be able to play external .m3u files
https.media.url = "https://my-media-domain.org";
# or if you want to keep your url outside of Git
# example file contents: ```https://my-media-domain.org```
# https.media.urlFile = config.sops.secrets.webserver.path;
# root directory for the media library.
# the URL above should point to this directory as a file server.
# no external port needs to be exposed on router as long as the TLS certificate remains valid.
media.root = "/Pool";
# YouTube API token
media.youtubePasswordFile = config.sops.secrets.youtubeAPI.path;
# media type directories
media = {
movies = "/Pool/Movies";
tv = "/Pool/TV";
music = "/Pool/Music";
musicVideos = "/Pool/Music_Videos";
otherVideos = "/Pool/Other_Videos";
podcasts = "/Pool/Podcasts";
};
# tv devices
tv = {
"my-tv" = {
ip = "192.168.1.123";
room = "bedroom";
# applications (activity name)
apps = {
telenor = "se.telenor.stream/.MainActivity";
tv4 = "se.tv4.tv4playtab/se.tv4.tv4play.ui.mobile.main.BottomNavigationActivity";
};
channels = {
"1" = {
name = "SVT1";
id = 1;
# or use a direct stream url
# stream_url = "https://url.com/";
# command to start a application based channel
cmd = "open_telenor && wait 5 && start_channel_1";
};
# ...
};
};
"my-other-tv" = {
ip = "192.168.1.124";
room = "livingroom";
isDefault = true;
};
};
};🌐 Dashboard (optional)
Example optional configuraiton:
house = {
zigbee.automations = {
# first let's create a file that the status card below can read
mqtt_triggered = {
temperature_update = {
enable = true;
description = "Update living room temperature on the dashboard";
topic = "zigbee2mqtt/Living Room Sensor";
actions = [
{
type = "shell";
command = ''
# read the MQTT payload, extract the "temperature" field,
# and write it to /var/lib/zigduck/temperature.json with a history array.
VALUE=$(echo "$MQTT_PAYLOAD" | jq '.temperature')
FILE="/var/lib/zigduck/temperature.json"
mkdir -p "$(dirname "$FILE")"
if [ ! -s "$FILE" ]; then
jq -n --argjson v "$VALUE" '{ temperature: $v, history: [$v] }' > "$FILE"
else
jq --argjson v "$VALUE" '
.temperature = $v |
.history += [$v] |
.history = (.history[-200:])
' "$FILE" > "$FILE.tmp" && mv "$FILE.tmp" "$FILE"
fi
'';
}
];
};
};
};
# now we can create a customized card that reads and displays the temperature (with history chart)
dashboard = {
statusCards = {
temperature = {
enable = true;
title = "TEMPERATURE C";
group = "sensors";
icon = "fas fa-thermometer-half";
color = "#e74c3c";
theme = "glass";
filePath = "/var/lib/zigduck/temperature.json";
jsonField = "temperature";
format = "{value} °C";
detailsFormat = "Temperature in Hallway";
chart = true;
historyField = "history";
# perform a automation action when clicking the dashboard card
on_click_action = [
{
type = "shell";
command = "example shell command.'";
}
];
};
};
# if user wants to have extra dashboard tabs
pages = {
"3" = {
icon = "fas fa-television";
title = "remote";
# symlink optional extra files/directories to webserver
files = { tv = "/var/lib/zigduck/tv"; };
css = # css code
code = # html code
};
};
🎙️ Voice (optional)
The companion flake yo is handling everything voice/natural language related, please see it's repo for installation instructions.
Once setup, copy the ./modules/voice directory into your NixOS configuration to be able to control your devices/rooms/media/timers/alarms etc.
Note: for
ESP32-S3basedyoclients - see the yo-esp library.
To write additional custom voice commands, please see yo for instructions.
Commandline
The zigduck-cli tool provides complete control over your smart home from the command line. Below is its full help output – use
--helpat any time to see the same information.
Usage: zigduck-cli [OPTIONS] [COMMAND]
Commands:
timer
alarm
snapshot
help Print this message or the help of the given subcommand(s)
Options:
-b, --broker <BROKER>
MQTT broker host
[env: MQTT_BROKER=]
[default: 127.0.0.1]
-u, --user <USER>
MQTT username
[env: MQTT_USER=]
[default: mqtt]
--password-file <PASSWORD_FILE>
MQTT password file
[env: MQTT_PASSWORD_FILE=]
--password <PASSWORD>
MQTT password
[env: MQTT_PASSWORD=]
-v, --verbose...
Verbosity level
--devices-config <DEVICES_CONFIG>
Path to devices configuration
[env: DEVICES_CONFIG=]
--scenes-config <SCENES_CONFIG>
Path to scenes configuration
[env: SCENES_CONFIG=]
--hue-bridge-ip <HUE_BRIDGE_IP>
Hue Bridge IP
[env: HUE_BRIDGE_IP=]
--hue-api-key <HUE_API_KEY>
Hue Bridge API key
[env: HUE_API_KEY=]
--hue-key-file <HUE_KEY_FILE>
Hue Bridge API key file
[env: HUE_KEY_FILE=]
--device <DEVICE>
Device name (friendly name)
--room <ROOM>
Room name
--scene <SCENE>
Scene name
--list [<LIST>]
List devices, rooms, scenes, lights, or sensors
[possible values: devices, rooms, scenes, lights, sensors]
--status
Show a formatted device status table including state, battery, temperature
--get-temp [<GET_TEMP>]
Get temperature readings from devices in a room (requires --room)
--get-bat [<GET_BAT>]
Get battery level for a device (requires --device)
--state-file <STATE_FILE>
Path to local state.json (overrides API fetch)
[env: ZIGDUCK_STATE_FILE=]
--pair [<PAIR>]
Pairing duration in seconds (default: 120)
--all-lights [<ALL_LIGHTS>]
Control all lights (optional true/false)
--blinds <BLINDS>
Control all blinds globally (up or down)
--cheap-mode <CHEAP_MODE>
Room name for cheap mode
--publish
Publish a raw MQTT message
--topic <TOPIC>
MQTT topic (used with --publish)
--json-cmd
Send raw JSON to a device
--state <STATE>
Device state: on/off/toggle/max/dark
--brightness <BRIGHTNESS>
Brightness percentage (1-100)
--color <COLOR>
Color name or hex code
--temperature <TEMPERATURE>
Color temperature (153-500)
--transition <TRANSITION>
Transition time in seconds
--payload <PAYLOAD>
Raw JSON payload (used with --json-cmd or --publish)
--backend <BACKEND>
Backend type (auto/zigbee/hue)
[default: auto]
[possible values: auto, zigbee, hue]
--json-output
Output list as JSON
--watch
Watch for new devices during pairing
--random
Pick a random scene
--scene-room <SCENE_ROOM>
Restrict scene to a specific room
--delay <DELAY>
Delay in seconds for cheap mode
[default: 300]
--api-url <API_URL>
zigduck API URL
[env: API_URL=]
--api-password-file <API_PASSWORD_FILE>
File containing API password
[env: API_PASSWORD_FILE=]
--api-password <API_PASSWORD>
API password directly
[env: API_PASSWORD=]
-h, --help
Print help (see a summary with '-h')
-V, --version
Print version
Example usage:
zigduck-cli --scene myScene
zigduck-cli --scene myScene --room kitchen
zigduck-cli --blinds up
zigduck-cli --device myLight --state on --brightness 90 --color red --transition 5
zigduck-cli timer set --minutes 15 --seconds 30
zigduck-cli alarm add --hours 07 --minutes 0
zigduck-cli --publish --topic "zigduck/Fläkt/set" --payload '{"countdown": 0}'
zigduck-cli snapshot create my_test
zigduck-cli snapshot restore my_test
The tv CLI tool provides a simple way of communicating with your TV over
ADB. It uses fuzzy search to find media and creates a playlist and tell the TV to play it. Below is its full help output – use--helpat any time to see the same information.
Note: Please see the media configuration step before usage.
Cast media to an Android TV device via ADB
Usage: tv [OPTIONS] --typ <TYP>
Options:
-t, --typ <TYP> [possible values: on, off, up, down, next, prev, previous, pause, play, call, youtube, tv, movie, podcast, music, musicvideo, audiobook, jukebox, song, othervideo, livetv, play_playlist, nav_up, nav_down, nav_left, nav_right, nav_select, nav_menu, nav_back, channel_up, channel_down, nav_home, nav_recents]
-s, --search <SEARCH>
--season <SEASON>
--room <ROOM>
--ip <IP>
--no-shuffle
--shuffle
--max-items <MAX_ITEMS>
--config <CONFIG> [default: /etc/zigduck/tv-defaults.json]
-h, --help Print help
Example usage:
tv --typ on
tv --typ music --search "iron maiden" --ip 192.168.1.123
tv --typ tv --search "big bang theory"
tv --typ song --search "the duck song" --room bedroom
tv --typ youtube --search "play a youtube video"
API
Endpoints:
| Endpoint | Method | Description | Parameters |
|---|---|---|---|
/ |
GET | Service info and list of all endpoints | None |
/browse/browsev2/api/browse/api/browsev2 |
GET | Browse media directory (legacy ls or improved find). browsev2 returns full path. |
path (relative to media root) |
| Timers | |||
/timers |
GET | List all timers | None |
/timers/set |
POST | Create a new timer | hours, minutes, seconds (at least one >0), topic, payload, optional name |
/timers/pause |
POST | Pause a running timer | id (timer ID) |
/timers/resume |
POST | Resume a paused timer | id |
/timers/cancel |
POST | Cancel (delete) a timer | id |
| Alarms | |||
/alarms/api/alarms |
GET | List all alarms | None |
/alarms/add/api/alarms/add |
POST | Add a new alarm | hours (0-23), minutes (0-59), name, optional days (comma-separated 0=Sun..6=Sat) |
/alarms/remove/api/alarms/remove |
POST | Remove an alarm | id |
/alarms/toggle/api/alarms/toggle |
POST | Toggle alarm on/off | id |
| Media (ADB) | |||
/media/power/on/api/media/power/on |
POST | Wake up media device | device (IP, default 192.168.1.224) |
/media/power/off/api/media/power/off |
POST | Sleep media device | device |
/media/next |
POST | Next track | device |
/media/previous |
POST | Previous track | device |
/media/play/media/pause |
POST | Toggle play/pause | device |
/media/volume/up |
POST | Volume up | device |
/media/volume/down |
POST | Volume down | device |
/media/playlist |
POST | Launch playlist on device (ADB intent) | device, optional url (defaults to webserver /playlist.m3u) |
| Playlist (m3u file) | |||
/playlist/list |
GET | List current m3u playlist | None |
/playlist/add |
POST | Add entry to playlist | entry (path) |
/playlist/remove |
POST | Remove entry by index (0-based) | index |
/playlist/shuffle |
POST | Shuffle playlist | None |
/playlist/clear |
POST | Clear entire playlist | None |
| State | |||
/state/api/state |
GET | Full state of all Zigbee devices | None |
/state/{device}/api/state/{device} |
GET | State of a specific device | {device} (friendly name) |
/state/room/{room}/api/state/room/{room} |
GET | State of all devices in a room | {room} |
| Devices & Scenes | |||
/device/list/api/device/list |
GET | List all devices (from devices.json) |
None |
/device/{device}/{command}/{value}... |
POST | Control a device (multiple commands can be chained) | {device} name, then pairs like state/on, brightness/200, color/%23FF5733, temperature/300 |
/device/rooms/api/device/rooms |
GET | List devices grouped by room | None |
/device/types/api/device/types |
GET | List devices grouped by type | None |
/scene/{scene}/api/scene/{scene} |
POST | Activate a scene | {scene} (scene name) |
If you’d like
zigduckto support devices using other protocols, such asMatter,Z-Wave, or similar, please consider submitting a helpful PR with a suggested persistent device configuration. Please include enough information to provide a clear implementation strategy and keepzigduckfully reproducible. Thanks!
This project is licensed under the terms of the MIT license.
See the LICENSE file in the repository for full details.
Contributions are welcomed.