golights: a small Go service for Zigbee2MQTT lights
Last modification on
Source: src.adamsgaard.dk/golights.
golights is a small Go service that handles motion sensors, wall switches, and lights via Zigbee2MQTT. It runs as a single container alongside the broker and the Zigbee2MQTT bridge.
Motivation
Zigbee2MQTT supports direct device-to-group bindings, where a motion sensor turns on a light group without any external service. That works, but it has limits:
- Brightness and color temperature are fixed to whatever was last set on the group.
- There is no built-in way to skip a turn-on when the room is already bright.
- If someone changes a light by hand, the binding does not know, and the next motion event overrides it.
I wanted a setup that follows daylight through the day, suppresses turn-ons above an illuminance threshold, and steps out of the way when a wall switch or app is used. Existing tools like Home Assistant or Node-RED can do this, but at a much larger footprint than the job warrants. golights is the minimal piece that sits between Zigbee2MQTT and the lights.
What it does
The service subscribes to one Zigbee2MQTT base topic, reacts to configured motion sensors and switches, and publishes commands for configured groups or individual lights:
- Daylight schedule: brightness and color temperature follow time-of-day bands.
- Illuminance cutoff: motion does not trigger a turn-on if the room is already above a configured lux threshold.
- Service-owned off timers: lights turned on by the service are turned off after a per-sensor timeout. Lights turned on by hand are left alone.
- Manual override cancellation: a state change that does not match what the service last published cancels ownership of that target.
- Per-target command encoding: some lights handle state, brightness, and color temperature in one payload, others need them split with a small delay.
Configuration
Settings live in a single settings.json file. The daylight schedule is a list of bands keyed by local clock time:
"daylight_schedule": [
{ "from": "06:00", "to": "09:00", "brightness": 190, "color_temp": 330, "transition_seconds": 1 },
{ "from": "09:00", "to": "17:00", "brightness": 230, "color_temp": 250, "transition_seconds": 1 },
{ "from": "17:00", "to": "22:30", "brightness": 130, "color_temp": 420, "transition_seconds": 1 },
{ "from": "22:30", "to": "06:00", "brightness": 40, "color_temp": 480, "transition_seconds": 2 }
]
Bands wrap across midnight when "from" is greater than "to". A motion event on a sensor with "follow_daylight: true" picks up the brightness, color temperature, and transition from the current band.
Motion sensors are configured with their target groups, an off timer, and an optional illuminance cutoff:
"motion_sensors": {
"fm_koekken_sensor": {
"targets": ["fm_house_lights"],
"timeout_seconds": 180,
"follow_daylight": true,
"illuminance_cutoff": 25
}
}
The cutoff is checked only when no service-owned light in the target set is currently on. Once the service has turned a light on, raising the room illuminance above the cutoff does not turn it off again. The off timer does that.
Ownership and manual overrides
Each target keeps a set of owners: sensors or switches that are currently responsible for keeping it on. A motion event adds the sensor as an owner and resets the sensor's off timer. The off timer fires per sensor, removes that sensor as an owner, and turns the target off only if no other owners remain.
State messages from Zigbee2MQTT are matched against the last command the service published to that target. If the brightness, color temperature, or state differs, the service treats the change as a manual override, drops all owners for that target, and stops its off timers. The next motion event starts ownership again from scratch.
This is the part that direct Zigbee2MQTT bindings cannot do, and it is the reason the service exists.
Layout
The code is split into four small packages:
- config: loads and validates settings.json.
- daylight: matches a time against the schedule bands.
- mqtt: a thin wrapper around paho.mqtt.golang with reconnect handling.
- automation: the service itself: message dispatch, ownership, timers, command encoding.
The daylight and config packages have no MQTT dependencies and are unit-tested directly. The automation tests inject a fake clock and a fake AfterFunc, so timer-driven behaviour can be verified deterministically.
Deployment
The repository ships a Dockerfile and a docker-compose.yml. To run it next to an existing Zigbee2MQTT setup:
git clone git://src.adamsgaard.dk/golights
cd golights
cp .env.example .env
cp settings.example.json settings.json
$EDITOR .env settings.json
docker compose up --build -d
To run without Docker, build and start the binary directly. Go 1.24 or newer is required:
go build -o golights ./cmd/golights
./golights
The binary reads .env and settings.json from the current working directory. Set SETTINGS_PATH to point at a settings file in another location. MQTT credentials can be supplied either through .env or through the process environment; existing environment variables take precedence.
Before relying on the service, disable any direct Zigbee2MQTT sensor-to-group bindings for the same devices. Otherwise the binding races the service and turns lights on before the illuminance cutoff and ownership rules apply.
Patches and bug reports by email.