commit 98e74503d6d863a72b4085d9f180736b93dcbb01
parent a77b9b6cdb00ea76dfa299d3ad74c945db85da2f
Author: Anders Damsgaard <anders@adamsgaard.dk>
Date: Sun, 26 Apr 2026 20:26:38 +0200
content: add post about Go Lights
Diffstat:
3 files changed, 260 insertions(+), 0 deletions(-)
diff --git a/pages/019-golights.cfg b/pages/019-golights.cfg
@@ -0,0 +1,8 @@
+filename=golights.html
+title=golights: a small Go service for Zigbee2MQTT lights
+description=Replacing Zigbee2MQTT direct bindings with a Go service that follows daylight, respects manual overrides, and skips turn-ons in bright rooms
+id=golights
+tags=go, mqtt, zigbee2mqtt, home automation
+created=2026-04-26
+updated=2026-04-26
+#index=0
diff --git a/pages/019-golights.html b/pages/019-golights.html
@@ -0,0 +1,102 @@
+<blockquote>
+<p>Source: <a href="https://src.adamsgaard.dk/golights">src.adamsgaard.dk/golights</a>.</p>
+</blockquote>
+
+<p>golights is a small <a href="https://go.dev/">Go</a> service that handles motion sensors, wall switches, and lights via <a href="https://www.zigbee2mqtt.io/">Zigbee2MQTT</a>. It runs as a single container alongside the broker and the Zigbee2MQTT bridge.</p>
+
+<h2 id="motivation">Motivation</h2>
+
+<p>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:</p>
+
+<ul>
+ <li>Brightness and color temperature are fixed to whatever was last set on the group.</li>
+ <li>There is no built-in way to skip a turn-on when the room is already bright.</li>
+ <li>If someone changes a light by hand, the binding does not know, and the next motion event overrides it.</li>
+</ul>
+
+<p>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.</p>
+
+<h2 id="what-it-does">What it does</h2>
+
+<p>The service subscribes to one Zigbee2MQTT base topic, reacts to configured motion sensors and switches, and publishes commands for configured groups or individual lights:</p>
+
+<ul>
+ <li><strong>Daylight schedule</strong>: brightness and color temperature follow time-of-day bands.</li>
+ <li><strong>Illuminance cutoff</strong>: motion does not trigger a turn-on if the room is already above a configured lux threshold.</li>
+ <li><strong>Service-owned off timers</strong>: lights turned on by the service are turned off after a per-sensor timeout. Lights turned on by hand are left alone.</li>
+ <li><strong>Manual override cancellation</strong>: a state change that does not match what the service last published cancels ownership of that target.</li>
+ <li><strong>Per-target command encoding</strong>: some lights handle state, brightness, and color temperature in one payload, others need them split with a small delay.</li>
+</ul>
+
+<h2 id="configuration">Configuration</h2>
+
+<p>Settings live in a single settings.json file. The daylight schedule is a list of bands keyed by local clock time:</p>
+
+<pre><code>"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 }
+]
+</code></pre>
+
+<p>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.</p>
+
+<p>Motion sensors are configured with their target groups, an off timer, and an optional illuminance cutoff:</p>
+
+<pre><code>"motion_sensors": {
+ "fm_koekken_sensor": {
+ "targets": ["fm_house_lights"],
+ "timeout_seconds": 180,
+ "follow_daylight": true,
+ "illuminance_cutoff": 25
+ }
+}
+</code></pre>
+
+<p>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.</p>
+
+<h2 id="ownership">Ownership and manual overrides</h2>
+
+<p>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.</p>
+
+<p>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.</p>
+
+<p>This is the part that direct Zigbee2MQTT bindings cannot do, and it is the reason the service exists.</p>
+
+<h2 id="layout">Layout</h2>
+
+<p>The code is split into four small packages:</p>
+
+<ul>
+ <li><strong>config</strong>: loads and validates settings.json.</li>
+ <li><strong>daylight</strong>: matches a time against the schedule bands.</li>
+ <li><strong>mqtt</strong>: a thin wrapper around <a href="https://github.com/eclipse/paho.mqtt.golang">paho.mqtt.golang</a> with reconnect handling.</li>
+ <li><strong>automation</strong>: the service itself: message dispatch, ownership, timers, command encoding.</li>
+</ul>
+
+<p>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.</p>
+
+<h2 id="deployment">Deployment</h2>
+
+<p>The repository ships a Dockerfile and a docker-compose.yml. To run it next to an existing Zigbee2MQTT setup:</p>
+
+<pre><code>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
+</code></pre>
+
+<p>To run without Docker, build and start the binary directly. Go 1.24 or newer is required:</p>
+
+<pre><code>go build -o golights ./cmd/golights
+./golights
+</code></pre>
+
+<p>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.</p>
+
+<p>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.</p>
+
+<p>Patches and bug reports by <a href="mailto:anders@adamsgaard.dk">email</a>.</p>
diff --git a/pages/019-golights.txt b/pages/019-golights.txt
@@ -0,0 +1,150 @@
+ Source: [1]src.adamsgaard.dk/golights.
+
+golights is a small [2]Go service that handles motion sensors,
+wall switches, and lights via [3]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 [4]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 [5]email.
+
+References:
+
+[1] https://src.adamsgaard.dk/golights
+[2] https://go.dev/
+[3] https://www.zigbee2mqtt.io/
+[4] https://github.com/eclipse/paho.mqtt.golang
+[5] mailto:anders@adamsgaard.dk