From a5547761bba7dcc99736f59c95855a3003a88778 Mon Sep 17 00:00:00 2001 From: Fynn Datoo Date: Fri, 1 May 2026 00:07:22 -0700 Subject: [PATCH 01/16] doc: write plan for zigbee2mqtt driver --- docs/design/plans/2026-04-30-z2m-driver.md | 4187 ++++++++++++++++++++ 1 file changed, 4187 insertions(+) create mode 100644 docs/design/plans/2026-04-30-z2m-driver.md diff --git a/docs/design/plans/2026-04-30-z2m-driver.md b/docs/design/plans/2026-04-30-z2m-driver.md new file mode 100644 index 0000000..957706c --- /dev/null +++ b/docs/design/plans/2026-04-30-z2m-driver.md @@ -0,0 +1,4187 @@ +# Zigbee2MQTT Driver Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Build the `drivers/z2m/` Carport driver per `docs/design/specs/2026-04-30-z2m-driver-design.md`: MQTT-based Zigbee2MQTT integration that surfaces lights, numeric sensors, and binary sensors with hot add/remove via `bridge/devices` reconciliation. + +**Architecture:** Pure-function `internal/state` package translates Z2M devices ↔ gohome entities; `internal/z2m` decodes Z2M topic payloads; `internal/mqtt` wraps `paho.mqtt.golang`; `cmd/z2m-driver/main.go` wires them via the driverkit. Color math (CIE-xy ↔ RGB, HSV ↔ RGB) moves out of the Hue driver into a shared `gohome-driverkit/colorconv/` package consumed by both drivers. + +**Tech Stack:** Go 1.25.9; `github.com/eclipse/paho.mqtt.golang` for MQTT; `github.com/mochi-mqtt/server/v2` for in-process broker in integration tests; existing `gohome-driverkit` and `gohome/gen/gohome/entity/v1`. Depends on the **entity-binary-sensor** proto change (`docs/design/plans/2026-04-30-entity-binary-sensor.md`) landing first. + +**Branch:** Implement on the existing `feat/zigbee2mqtt` branch. + +--- + +## File Map + +``` +gohome-driverkit/ + colorconv/ + colorconv.go # new — XY, Gamut, RGB↔XY, RGB↔HSV, ClampToGamut, PackRGB + colorconv_test.go # new + +drivers/hue/ + internal/bridge/ + colormath.go # delete (functions move to colorconv) + colormath_test.go # delete (tests move to colorconv) + types.go # ColorXY/Gamut → colorconv.XY/Gamut + devices.go, events.go, client.go # update color field types + internal/state/ + color.go # unchanged signature; uint8 in / uint8 out + mapping.go # bridge.ColorXY → colorconv.XY at call sites + +drivers/z2m/ # all new + cmd/z2m-driver/ + main.go # wiring; ~250 lines target + main_test.go # integration tests w/ mochi broker + internal/mqtt/ + client.go # paho wrapper + client_test.go + internal/z2m/ + topics.go # topic constructors + topics_test.go + payload.go # Device / Expose / BridgeState / StatePayload + payload_test.go + testdata/ + bridge_devices.json # captured fixture + internal/state/ + ids.go # EntityID + ids_test.go + mapping.go # EntitiesFor + blocklist + mapping_test.go + command.go # CommandToPayload + command_test.go + merge.go # MergeState + merge_test.go + reconcile.go # Reconcile + Action types + reconcile_test.go + README.md + +docs/docs/drivers/first-party.md # update existing zigbee2mqtt entry to match impl + +go.mod, go.sum # add paho.mqtt.golang + mochi-mqtt/server/v2 +``` + +--- + +## Prerequisites + +- [ ] Confirm the **entity-binary-sensor** proto change is committed on this branch. Run: + ```bash + grep -E "type (NumericSensor|BinarySensor) struct" gen/gohome/entity/v1/attributes.pb.go + ``` + Expected: two lines, one for each. If not present, execute `docs/design/plans/2026-04-30-entity-binary-sensor.md` first and **stop**. + +--- + +## Task 1: Extract colorconv package into driverkit + +**Why first:** The Z2M driver depends on color math. Extracting it from `drivers/hue/internal/bridge/colormath.go` into `gohome-driverkit/colorconv/` makes it reusable by both drivers (and signals "this is how drivers handle color"). Hue migrates in Task 2 — Task 1 only adds the new package, leaving Hue untouched. + +**Files:** +- Create: `gohome-driverkit/colorconv/colorconv.go` +- Create: `gohome-driverkit/colorconv/colorconv_test.go` + +- [ ] **Step 1: Write `colorconv.go`** + +```go +// Package colorconv provides pure colour-space conversions for driver +// authors: CIE 1931 xy ↔ sRGB, HSV ↔ sRGB, gamut clamping, and packed +// RGB helpers. No I/O, no allocations beyond return values, no logging. +// +// Both first-party drivers (Hue, Z2M) consume this package; third-party +// drivers are encouraged to as well. +package colorconv + +import "math" + +// XY is a CIE 1931 chromaticity point. Both dimensions are 0..1. +// JSON-tagged so drivers can decode wire payloads directly. +type XY struct { + X float64 `json:"x"` + Y float64 `json:"y"` +} + +// Gamut is the triangle of representable colours for one bulb. Drivers +// without per-bulb gamut info pass the zero value, which disables +// clamping. +type Gamut struct { + Red XY `json:"red"` + Green XY `json:"green"` + Blue XY `json:"blue"` +} + +// PackRGB packs three bytes into a 0xRRGGBB uint32. +func PackRGB(r, g, b uint8) uint32 { + return uint32(r)<<16 | uint32(g)<<8 | uint32(b) +} + +// UnpackRGB unpacks a 0xRRGGBB uint32 into three bytes. +func UnpackRGB(packed uint32) (uint8, uint8, uint8) { + return uint8(packed >> 16), uint8(packed >> 8), uint8(packed) +} + +// RGBToXY converts an 8-bit-per-channel sRGB triple to a CIE 1931 xy +// chromaticity point. Standard sRGB → linear → CIE conversion with the +// D65 white reference. The returned point may fall outside any specific +// bulb's gamut; use ClampToGamut to project it back. +func RGBToXY(r, g, b uint8) XY { + rf := gammaInverse(float64(r) / 255.0) + gf := gammaInverse(float64(g) / 255.0) + bf := gammaInverse(float64(b) / 255.0) + + X := rf*0.4124564 + gf*0.3575761 + bf*0.1804375 + Y := rf*0.2126729 + gf*0.7151522 + bf*0.0721750 + Z := rf*0.0193339 + gf*0.1191920 + bf*0.9503041 + + sum := X + Y + Z + if sum == 0 { + return XY{0, 0} + } + return XY{X: X / sum, Y: Y / sum} +} + +// XYToRGB converts a CIE 1931 xy point back to 8-bit sRGB, clamped to +// [0, 255]. Brightness is normalised so the brightest channel reaches +// 255 — callers control intensity separately via dimming. +func XYToRGB(xy XY) (uint8, uint8, uint8) { + if xy.Y < 1e-9 { + return 0, 0, 0 + } + X := xy.X / xy.Y + Y := 1.0 + Z := (1.0 - xy.X - xy.Y) / xy.Y + + rl := X*3.2404542 + Y*-1.5371385 + Z*-0.4985314 + gl := X*-0.9692660 + Y*1.8760108 + Z*0.0415560 + bl := X*0.0556434 + Y*-0.2040259 + Z*1.0572252 + + maxC := math.Max(rl, math.Max(gl, bl)) + if maxC > 1.0 { + rl, gl, bl = rl/maxC, gl/maxC, bl/maxC + } + return floatToByte(gammaForward(rl)), floatToByte(gammaForward(gl)), floatToByte(gammaForward(bl)) +} + +// ClampToGamut projects xy onto the gamut triangle if outside. +// Inside-or-on returns xy unchanged. A zero Gamut (all corners at 0,0) +// disables clamping — returns xy unchanged. +func ClampToGamut(xy XY, g Gamut) XY { + if g.Red == (XY{}) && g.Green == (XY{}) && g.Blue == (XY{}) { + return xy + } + if pointInTriangle(xy, g.Red, g.Green, g.Blue) { + return xy + } + a := closestOnSegment(xy, g.Red, g.Green) + b := closestOnSegment(xy, g.Green, g.Blue) + c := closestOnSegment(xy, g.Blue, g.Red) + best, bestD := a, distSq(xy, a) + if d := distSq(xy, b); d < bestD { + best, bestD = b, d + } + if d := distSq(xy, c); d < bestD { + best = c + } + return best +} + +// RGBToHSV converts 8-bit sRGB to HSV with hue in [0, 360), saturation +// and value in [0, 1]. Used by drivers whose target accepts {hue, sat} +// natively (some Z2M devices). +func RGBToHSV(r, g, b uint8) (h, s, v float64) { + rf, gf, bf := float64(r)/255.0, float64(g)/255.0, float64(b)/255.0 + maxC := math.Max(rf, math.Max(gf, bf)) + minC := math.Min(rf, math.Min(gf, bf)) + v = maxC + d := maxC - minC + if maxC == 0 { + return 0, 0, 0 + } + s = d / maxC + if d == 0 { + return 0, s, v + } + switch maxC { + case rf: + h = (gf - bf) / d + if gf < bf { + h += 6 + } + case gf: + h = (bf-rf)/d + 2 + case bf: + h = (rf-gf)/d + 4 + } + h *= 60 + return h, s, v +} + +// HSVToRGB converts HSV (hue [0,360), saturation/value [0,1]) to 8-bit +// sRGB. Inputs outside their domains are clamped. +func HSVToRGB(h, s, v float64) (uint8, uint8, uint8) { + if s < 0 { + s = 0 + } + if s > 1 { + s = 1 + } + if v < 0 { + v = 0 + } + if v > 1 { + v = 1 + } + h = math.Mod(h, 360) + if h < 0 { + h += 360 + } + c := v * s + x := c * (1 - math.Abs(math.Mod(h/60, 2)-1)) + m := v - c + var rf, gf, bf float64 + switch { + case h < 60: + rf, gf, bf = c, x, 0 + case h < 120: + rf, gf, bf = x, c, 0 + case h < 180: + rf, gf, bf = 0, c, x + case h < 240: + rf, gf, bf = 0, x, c + case h < 300: + rf, gf, bf = x, 0, c + default: + rf, gf, bf = c, 0, x + } + return floatToByte(rf + m), floatToByte(gf + m), floatToByte(bf + m) +} + +// --- internal helpers --- + +func gammaInverse(c float64) float64 { + if c > 0.04045 { + return math.Pow((c+0.055)/1.055, 2.4) + } + return c / 12.92 +} + +func gammaForward(c float64) float64 { + if c <= 0 { + return 0 + } + if c <= 0.0031308 { + return 12.92 * c + } + return 1.055*math.Pow(c, 1.0/2.4) - 0.055 +} + +func floatToByte(f float64) uint8 { + if f < 0 { + return 0 + } + if f > 1 { + return 255 + } + return uint8(math.Round(f * 255)) +} + +func crossSign(a, b, c XY) float64 { + return (a.X-c.X)*(b.Y-c.Y) - (b.X-c.X)*(a.Y-c.Y) +} + +func pointInTriangle(p, a, b, c XY) bool { + const eps = 1e-9 + d1 := crossSign(p, a, b) + d2 := crossSign(p, b, c) + d3 := crossSign(p, c, a) + hasNeg := d1 < -eps || d2 < -eps || d3 < -eps + hasPos := d1 > eps || d2 > eps || d3 > eps + return !hasNeg || !hasPos +} + +func closestOnSegment(p, a, b XY) XY { + dx := b.X - a.X + dy := b.Y - a.Y + denom := dx*dx + dy*dy + if denom == 0 { + return a + } + t := ((p.X-a.X)*dx + (p.Y-a.Y)*dy) / denom + switch { + case t < 0: + return a + case t > 1: + return b + } + return XY{a.X + t*dx, a.Y + t*dy} +} + +func distSq(a, b XY) float64 { + dx := a.X - b.X + dy := a.Y - b.Y + return dx*dx + dy*dy +} +``` + +- [ ] **Step 2: Write `colorconv_test.go`** + +```go +package colorconv + +import ( + "math" + "testing" +) + +func TestPackUnpackRGB(t *testing.T) { + for _, tc := range []struct { + r, g, b uint8 + packed uint32 + }{ + {0, 0, 0, 0x000000}, + {255, 255, 255, 0xFFFFFF}, + {0xFF, 0x88, 0x00, 0xFF8800}, + {0x12, 0x34, 0x56, 0x123456}, + } { + got := PackRGB(tc.r, tc.g, tc.b) + if got != tc.packed { + t.Errorf("PackRGB(%d,%d,%d) = %#x, want %#x", tc.r, tc.g, tc.b, got, tc.packed) + } + r, g, b := UnpackRGB(tc.packed) + if r != tc.r || g != tc.g || b != tc.b { + t.Errorf("UnpackRGB(%#x) = (%d,%d,%d), want (%d,%d,%d)", tc.packed, r, g, b, tc.r, tc.g, tc.b) + } + } +} + +func TestRGBXYRoundTrip(t *testing.T) { + // Round-tripping primary colours must stay close. The conversion is + // lossy because XY drops luminance, but pure primaries should + // reconstruct to within 8 LSB after re-normalisation. + for _, tc := range []struct { + name string + r, g, b uint8 + }{ + {"red", 255, 0, 0}, + {"green", 0, 255, 0}, + {"blue", 0, 0, 255}, + {"orange", 0xFF, 0x88, 0}, + } { + t.Run(tc.name, func(t *testing.T) { + xy := RGBToXY(tc.r, tc.g, tc.b) + r2, g2, b2 := XYToRGB(xy) + if absDiff(r2, tc.r) > 8 || absDiff(g2, tc.g) > 8 || absDiff(b2, tc.b) > 16 { + t.Errorf("round-trip %s: got (%d,%d,%d), want (%d,%d,%d)", tc.name, r2, g2, b2, tc.r, tc.g, tc.b) + } + }) + } +} + +func TestRGBToXYBlack(t *testing.T) { + xy := RGBToXY(0, 0, 0) + if xy.X != 0 || xy.Y != 0 { + t.Errorf("black → %v, want zero", xy) + } +} + +func TestClampToGamutInside(t *testing.T) { + g := Gamut{ + Red: XY{0.7, 0.3}, + Green: XY{0.2, 0.7}, + Blue: XY{0.15, 0.05}, + } + p := XY{0.4, 0.4} + got := ClampToGamut(p, g) + if got != p { + t.Errorf("inside point modified: got %v, want %v", got, p) + } +} + +func TestClampToGamutOutside(t *testing.T) { + g := Gamut{ + Red: XY{0.7, 0.3}, + Green: XY{0.2, 0.7}, + Blue: XY{0.15, 0.05}, + } + // Far outside the triangle. + got := ClampToGamut(XY{0.9, 0.9}, g) + if !pointInTriangle(got, g.Red, g.Green, g.Blue) { + t.Errorf("outside point not clamped into triangle: got %v", got) + } +} + +func TestClampToGamutZeroGamut(t *testing.T) { + p := XY{0.9, 0.9} + got := ClampToGamut(p, Gamut{}) + if got != p { + t.Errorf("zero gamut should pass through: got %v, want %v", got, p) + } +} + +func TestHSVRGBRoundTrip(t *testing.T) { + for _, tc := range []struct { + name string + r, g, b uint8 + }{ + {"red", 255, 0, 0}, + {"green", 0, 255, 0}, + {"blue", 0, 0, 255}, + {"yellow", 255, 255, 0}, + {"grey", 128, 128, 128}, + } { + t.Run(tc.name, func(t *testing.T) { + h, s, v := RGBToHSV(tc.r, tc.g, tc.b) + r2, g2, b2 := HSVToRGB(h, s, v) + if absDiff(r2, tc.r) > 1 || absDiff(g2, tc.g) > 1 || absDiff(b2, tc.b) > 1 { + t.Errorf("round-trip %s via HSV: got (%d,%d,%d), want (%d,%d,%d)", tc.name, r2, g2, b2, tc.r, tc.g, tc.b) + } + }) + } +} + +func TestHSVToRGBKnownPoints(t *testing.T) { + cases := []struct { + h, s, v float64 + r, g, b uint8 + }{ + {0, 0, 0, 0, 0, 0}, // black + {0, 0, 1, 255, 255, 255}, // white + {0, 1, 1, 255, 0, 0}, // red + {120, 1, 1, 0, 255, 0}, // green + {240, 1, 1, 0, 0, 255}, // blue + } + for _, tc := range cases { + r, g, b := HSVToRGB(tc.h, tc.s, tc.v) + if r != tc.r || g != tc.g || b != tc.b { + t.Errorf("HSV(%g,%g,%g) → (%d,%d,%d), want (%d,%d,%d)", tc.h, tc.s, tc.v, r, g, b, tc.r, tc.g, tc.b) + } + } +} + +func TestHSVOutOfDomainClamped(t *testing.T) { + r, g, b := HSVToRGB(720, 2, 5) // hue wraps; sat/val clamp to 1 + r2, g2, b2 := HSVToRGB(0, 1, 1) + if r != r2 || g != g2 || b != b2 { + t.Errorf("clamped HSV mismatch: got (%d,%d,%d), want (%d,%d,%d)", r, g, b, r2, g2, b2) + } + // Negative inputs clamp to zero. + r, g, b = HSVToRGB(0, -1, -1) + if r != 0 || g != 0 || b != 0 { + t.Errorf("negative HSV → (%d,%d,%d), want (0,0,0)", r, g, b) + } +} + +func absDiff(a, b uint8) int { + d := int(a) - int(b) + return int(math.Abs(float64(d))) +} +``` + +- [ ] **Step 3: Run tests; expect pass** + +```bash +go test ./gohome-driverkit/colorconv/... -v +``` + +Expected: all tests pass. + +- [ ] **Step 4: Commit** + +```bash +git add gohome-driverkit/colorconv/ +git commit -m "$(cat <<'EOF' +feat(driverkit): add colorconv package + +New shared package gohome-driverkit/colorconv/ holds CIE-xy ↔ RGB, +HSV ↔ RGB, gamut clamping, and packed RGB helpers. Extracted from +drivers/hue/internal/bridge/colormath.go to be consumed by Hue and +Z2M drivers (and third-party drivers). + +Hue still owns its own copy in this commit; Task 2 migrates it. + +Co-Authored-By: Claude Opus 4.7 (1M context) +EOF +)" +``` + +--- + +## Task 2: Migrate Hue driver to use colorconv + +**Goal:** Replace `bridge.ColorXY`, `bridge.Gamut`, `bridge.RGBToXY`, `bridge.XYToRGB`, `bridge.ClampToGamut`, `bridge.PackRGB`, `bridge.UnpackRGB` with the colorconv equivalents. Delete the now-duplicate Hue file. + +**Files:** +- Modify: `drivers/hue/internal/bridge/types.go` (drop ColorXY, Gamut; embed colorconv types) +- Modify: `drivers/hue/internal/bridge/devices.go` (callers of the dropped types) +- Modify: `drivers/hue/internal/bridge/events.go` (callers) +- Modify: `drivers/hue/internal/bridge/client.go` (callers) +- Modify: `drivers/hue/internal/state/mapping.go` (`colorToRgb`) +- Modify: `drivers/hue/cmd/hue-driver/main.go` (`gamuts` map element type) +- Delete: `drivers/hue/internal/bridge/colormath.go` +- Delete: `drivers/hue/internal/bridge/colormath_test.go` + +- [ ] **Step 1: Inventory callers** + +Run: +```bash +grep -rn "bridge\.\(ColorXY\|Gamut\|RGBToXY\|XYToRGB\|ClampToGamut\|PackRGB\|UnpackRGB\)" drivers/hue --include='*.go' +``` + +Capture the list. Every hit is a call site that must change. + +- [ ] **Step 2: Update `drivers/hue/internal/bridge/types.go`** + +Replace the existing `ColorXY` and `Gamut` types and the `Color` / `ColorUpdate` definitions that reference them with type aliases that point to colorconv. Keep the file's other types intact. + +Replace lines defining `ColorXY` and `Gamut` (approximately lines 104-116 in the current file) with: + +```go +import ( + "github.com/fdatoo/gohome-driverkit/colorconv" +) + +// ColorXY is an alias to keep wire-format JSON tags working without +// touching every call site. New code should use colorconv.XY directly. +type ColorXY = colorconv.XY + +// Gamut is an alias to colorconv.Gamut for the same reason. +type Gamut = colorconv.Gamut +``` + +Place the import alongside the existing imports at the top of the file. The aliases preserve the JSON tags (already on `colorconv.XY`/`colorconv.Gamut`) so the bridge JSON decoders keep working. + +`Color` and `ColorUpdate` continue to use `ColorXY` (now an alias) — no change needed beyond the type definitions above. + +- [ ] **Step 3: Update `drivers/hue/internal/state/mapping.go` `colorToRgb`** + +Find: + +```go +func colorToRgb(xy bridge.ColorXY) uint32 { + r, g, b := bridge.XYToRGB(xy) + return bridge.PackRGB(r, g, b) +} +``` + +Replace with: + +```go +func colorToRgb(xy bridge.ColorXY) uint32 { + r, g, b := colorconv.XYToRGB(xy) + return colorconv.PackRGB(r, g, b) +} +``` + +Add `"github.com/fdatoo/gohome-driverkit/colorconv"` to the imports. + +- [ ] **Step 4: Update `drivers/hue/internal/state/mapping.go` `CommandToUpdate` `set_color`** + +Find the `case "set_color":` block. The existing code calls `bridge.RGBToXY` and `bridge.ClampToGamut`. Replace those two calls with `colorconv.RGBToXY` and `colorconv.ClampToGamut` respectively. Leave the surrounding logic unchanged. + +- [ ] **Step 5: Delete the duplicates in `drivers/hue/internal/bridge/`** + +```bash +rm drivers/hue/internal/bridge/colormath.go +rm drivers/hue/internal/bridge/colormath_test.go +``` + +- [ ] **Step 6: Build** + +```bash +go build ./... +``` + +Expected: silent success. + +If the build fails on a `bridge.RGBToXY` / `bridge.PackRGB` etc. reference inside `drivers/hue`, fix that file by switching to `colorconv.X` (matching the Step 4 pattern). + +- [ ] **Step 7: Run all tests** + +```bash +go test ./... +``` + +Expected: all tests pass. The Hue tests use `bridge.ColorXY{...}` literals which still work via the alias. + +- [ ] **Step 8: Commit** + +```bash +git add drivers/hue +git commit -m "$(cat <<'EOF' +refactor(hue): consume colorconv package + +Hue's private color math moves to gohome-driverkit/colorconv (added +in the previous commit). bridge.ColorXY and bridge.Gamut become +type aliases for backwards compatibility on JSON wire types; the +math functions are dropped from the bridge package. + +This unblocks the Z2M driver from sharing the same pure-math code +path without importing from another driver's internal/. + +Co-Authored-By: Claude Opus 4.7 (1M context) +EOF +)" +``` + +--- + +## Task 3: Add MQTT dependencies and Z2M directory scaffolding + +**Goal:** Bring in `paho.mqtt.golang` (production dep) and `mochi-mqtt/server/v2` (test dep) and create the empty `drivers/z2m/` package tree with `doc.go` files so subsequent tasks have somewhere to land code. + +**Files:** +- Modify: `go.mod`, `go.sum` +- Create: `drivers/z2m/cmd/z2m-driver/doc.go` +- Create: `drivers/z2m/internal/mqtt/doc.go` +- Create: `drivers/z2m/internal/z2m/doc.go` +- Create: `drivers/z2m/internal/state/doc.go` + +- [ ] **Step 1: Add the MQTT client dependency** + +```bash +go get github.com/eclipse/paho.mqtt.golang@latest +``` + +This pulls the latest stable release (v1.5.x as of writing). Verify the line landed in `go.mod` under `require`. + +- [ ] **Step 2: Add the test broker dependency** + +```bash +go get github.com/mochi-mqtt/server/v2@latest +``` + +This is only used by `drivers/z2m/cmd/z2m-driver/main_test.go`. Putting it in the main `go.mod` (rather than a separate `_test` module) is consistent with how other test-only deps are tracked in this repo. + +- [ ] **Step 3: Tidy** + +```bash +go mod tidy +``` + +Verify both modules show up in `go.mod` and that `go.sum` has matching checksums. + +- [ ] **Step 4: Create the package skeletons** + +`drivers/z2m/cmd/z2m-driver/doc.go`: + +```go +// Command z2m-driver is a Carport driver for Zigbee2MQTT. One driver +// instance mirrors a single Z2M deployment's devices into gohome: +// lights, numeric sensors, and binary sensors. +// +// Configuration is read from environment variables (Z2M_BROKER_URL, +// Z2M_USERNAME, ...). See the README for details. +package main +``` + +`drivers/z2m/internal/mqtt/doc.go`: + +```go +// Package mqtt is a thin wrapper around eclipse/paho.mqtt.golang that +// exposes the subset of operations the Z2M driver needs: Connect, +// Subscribe, Unsubscribe, Publish, Close. Auto-reconnect is delegated +// to paho; OnConnect / OnDisconnect callbacks let main re-assert +// subscriptions and emit driver events on broker churn. +package mqtt +``` + +`drivers/z2m/internal/z2m/doc.go`: + +```go +// Package z2m models Zigbee2MQTT topic namespaces and payload shapes. +// Pure types and topic constructors; no I/O. Decoders use +// encoding/json against captured fixtures. +package z2m +``` + +`drivers/z2m/internal/state/doc.go`: + +```go +// Package state translates between Zigbee2MQTT device descriptors and +// gohome entityv1.Attributes. Pure functions, no I/O. The exported +// surface is small: EntityID, EntitiesFor, MergeState, Reconcile, +// CommandToPayload. +package state +``` + +- [ ] **Step 5: Build** + +```bash +go build ./... +``` + +Expected: silent success. The doc.go files declare empty packages — no callers yet. + +- [ ] **Step 6: Commit** + +```bash +git add go.mod go.sum drivers/z2m/ +git commit -m "$(cat <<'EOF' +feat(z2m): scaffolding and MQTT dependencies + +Adds the drivers/z2m/ directory tree (cmd, internal/mqtt, +internal/z2m, internal/state) with doc.go package headers. +Pulls in paho.mqtt.golang for production use and +mochi-mqtt/server/v2 for in-process broker testing. + +No driver logic yet — subsequent commits fill in topics, payloads, +state translation, the MQTT wrapper, and main wiring. + +Co-Authored-By: Claude Opus 4.7 (1M context) +EOF +)" +``` + +--- + +## Task 4: Z2M topics + payload model + +**Goal:** Pure types and constructors for the Z2M topic namespace and JSON payload shapes. No I/O, no MQTT awareness — just decode/encode. + +**Files:** +- Create: `drivers/z2m/internal/z2m/topics.go` +- Create: `drivers/z2m/internal/z2m/topics_test.go` +- Create: `drivers/z2m/internal/z2m/payload.go` +- Create: `drivers/z2m/internal/z2m/payload_test.go` +- Create: `drivers/z2m/internal/z2m/testdata/bridge_devices.json` + +- [ ] **Step 1: Write `topics.go`** + +```go +package z2m + +import "fmt" + +// Topics holds the four topics belonging to a single Z2M device, used +// by the reconciler so the caller can subscribe/unsubscribe in one +// pass. +type Topics struct { + State string // / + Set string // //set + Availability string // //availability +} + +// BridgeDevices returns /bridge/devices — the retained topic +// listing every paired device. Subscribing replays the current list. +func BridgeDevices(base string) string { return base + "/bridge/devices" } + +// BridgeState returns /bridge/state — "online"/"offline" for the +// Z2M bridge process itself. +func BridgeState(base string) string { return base + "/bridge/state" } + +// BridgeEvent returns /bridge/event — device-level lifecycle +// events (paired, removed, interview started/finished). Logged only +// in v0.1; the bridge/devices retained topic drives reconciliation. +func BridgeEvent(base string) string { return base + "/bridge/event" } + +// DeviceTopics returns the per-device topic bundle for friendlyName. +func DeviceTopics(base, friendlyName string) Topics { + prefix := fmt.Sprintf("%s/%s", base, friendlyName) + return Topics{ + State: prefix, + Set: prefix + "/set", + Availability: prefix + "/availability", + } +} +``` + +- [ ] **Step 2: Write `topics_test.go`** + +```go +package z2m + +import "testing" + +func TestBridgeTopics(t *testing.T) { + const base = "zigbee2mqtt" + cases := []struct { + fn string + got string + want string + }{ + {"BridgeDevices", BridgeDevices(base), "zigbee2mqtt/bridge/devices"}, + {"BridgeState", BridgeState(base), "zigbee2mqtt/bridge/state"}, + {"BridgeEvent", BridgeEvent(base), "zigbee2mqtt/bridge/event"}, + } + for _, tc := range cases { + if tc.got != tc.want { + t.Errorf("%s: got %q, want %q", tc.fn, tc.got, tc.want) + } + } +} + +func TestDeviceTopics(t *testing.T) { + got := DeviceTopics("zigbee2mqtt", "kitchen_light") + want := Topics{ + State: "zigbee2mqtt/kitchen_light", + Set: "zigbee2mqtt/kitchen_light/set", + Availability: "zigbee2mqtt/kitchen_light/availability", + } + if got != want { + t.Errorf("DeviceTopics: got %+v, want %+v", got, want) + } +} + +func TestDeviceTopicsCustomBase(t *testing.T) { + got := DeviceTopics("home/zigbee", "office") + if got.Set != "home/zigbee/office/set" { + t.Errorf("custom base: got %q", got.Set) + } +} +``` + +- [ ] **Step 3: Write `payload.go`** + +```go +package z2m + +import "encoding/json" + +// Device is one element of /bridge/devices. +// +// Only the fields the driver consumes are modelled. Z2M sends many +// more (manufacturer, model_id, network_address, power_source, ...); +// they decode into nothing harmful and are ignored. +type Device struct { + IEEEAddress string `json:"ieee_address"` + FriendlyName string `json:"friendly_name"` + Type string `json:"type"` // "Coordinator" | "EndDevice" | "Router" + Definition Definition `json:"definition"` +} + +// Definition wraps the device's exposes tree. +type Definition struct { + Vendor string `json:"vendor"` + Model string `json:"model"` + Description string `json:"description"` + Exposes []Expose `json:"exposes"` +} + +// Expose is the recursive node type that describes one capability or +// composite. Z2M's exposes tree mixes leaf types ("numeric", "binary", +// "enum", "text") with composites ("light", "switch", "climate", "lock", +// "fan", "cover") whose Features hold the real leaves. +// +// The fields decoded here are the union of what leaves and composites +// use. Empty fields are ignored at read time. +type Expose struct { + Type string `json:"type"` + Name string `json:"name,omitempty"` + Property string `json:"property,omitempty"` + Description string `json:"description,omitempty"` + Access uint8 `json:"access,omitempty"` // bitmask: 1=published, 2=settable, 4=gettable + Unit string `json:"unit,omitempty"` + ValueMin *float64 `json:"value_min,omitempty"` + ValueMax *float64 `json:"value_max,omitempty"` + ValueOn any `json:"value_on,omitempty"` // string or bool + ValueOff any `json:"value_off,omitempty"` // string or bool + Features []Expose `json:"features,omitempty"` +} + +// AccessPublished reports whether bit 0 of Access is set (Z2M +// publishes the property on the state topic). Effectively "is this +// readable in our context". +func (e Expose) AccessPublished() bool { return e.Access&0x01 != 0 } + +// AccessSettable reports whether bit 1 of Access is set (settable via +// /set). Used to skip writable non-light properties (smart-plug state) +// in v0.1. +func (e Expose) AccessSettable() bool { return e.Access&0x02 != 0 } + +// BridgeState is the payload of /bridge/state. +type BridgeState struct { + State string `json:"state"` // "online" | "offline" +} + +// AvailabilityState is the payload of //availability. +type AvailabilityState struct { + State string `json:"state"` // "online" | "offline" +} + +// StatePayload is the per-device state-push payload. Each device's +// shape differs, so the values are captured raw. Use +// state.MergeState to interpret each property. +type StatePayload map[string]json.RawMessage +``` + +- [ ] **Step 4: Write `testdata/bridge_devices.json`** + +A representative captured payload covering the device classes the spec calls out: a colour light, a multi-sensor (motion + temp + humidity + battery), a contact sensor, a smart plug, and a coordinator. Save as `drivers/z2m/internal/z2m/testdata/bridge_devices.json`: + +```json +[ + { + "ieee_address": "0x00158d0001234abc", + "friendly_name": "kitchen_light", + "type": "Router", + "definition": { + "vendor": "IKEA", + "model": "LED1545G12", + "description": "TRADFRI LED bulb E26/E27 980 lumen, dimmable, white spectrum, opal white", + "exposes": [ + { + "type": "light", + "features": [ + {"type": "binary", "name": "state", "property": "state", "access": 7, "value_on": "ON", "value_off": "OFF"}, + {"type": "numeric", "name": "brightness", "property": "brightness", "access": 7, "value_min": 0, "value_max": 254}, + {"type": "numeric", "name": "color_temp", "property": "color_temp", "access": 7, "value_min": 250, "value_max": 454}, + {"type": "composite", "name": "color_xy", "property": "color", "access": 7, "features": [ + {"type": "numeric", "name": "x", "property": "x", "access": 7}, + {"type": "numeric", "name": "y", "property": "y", "access": 7} + ]} + ] + }, + {"type": "numeric", "name": "linkquality", "property": "linkquality", "access": 1, "unit": "lqi", "value_min": 0, "value_max": 255} + ] + } + }, + { + "ieee_address": "0x00158d0009876543", + "friendly_name": "hallway_motion", + "type": "EndDevice", + "definition": { + "vendor": "Aqara", + "model": "RTCGQ11LM", + "description": "Aqara human body movement and illuminance sensor", + "exposes": [ + {"type": "binary", "name": "occupancy", "property": "occupancy", "access": 1, "value_on": true, "value_off": false}, + {"type": "numeric", "name": "battery", "property": "battery", "access": 1, "unit": "%", "value_min": 0, "value_max": 100}, + {"type": "numeric", "name": "temperature", "property": "temperature", "access": 1, "unit": "°C"}, + {"type": "numeric", "name": "humidity", "property": "humidity", "access": 1, "unit": "%"}, + {"type": "numeric", "name": "linkquality", "property": "linkquality", "access": 1, "unit": "lqi"}, + {"type": "numeric", "name": "voltage", "property": "voltage", "access": 1, "unit": "mV"} + ] + } + }, + { + "ieee_address": "0x00158d0002468ace", + "friendly_name": "front_door", + "type": "EndDevice", + "definition": { + "vendor": "Aqara", + "model": "MCCGQ11LM", + "description": "Aqara door & window contact sensor", + "exposes": [ + {"type": "binary", "name": "contact", "property": "contact", "access": 1, "value_on": false, "value_off": true}, + {"type": "numeric", "name": "battery", "property": "battery", "access": 1, "unit": "%"}, + {"type": "numeric", "name": "linkquality", "property": "linkquality", "access": 1, "unit": "lqi"} + ] + } + }, + { + "ieee_address": "0x00158d0011223344", + "friendly_name": "office_plug", + "type": "Router", + "definition": { + "vendor": "Innr", + "model": "SP 220", + "description": "Smart plug", + "exposes": [ + {"type": "switch", "features": [ + {"type": "binary", "name": "state", "property": "state", "access": 7, "value_on": "ON", "value_off": "OFF"} + ]}, + {"type": "numeric", "name": "power", "property": "power", "access": 1, "unit": "W"}, + {"type": "numeric", "name": "linkquality", "property": "linkquality", "access": 1, "unit": "lqi"} + ] + } + }, + { + "ieee_address": "0x00124b0000000000", + "friendly_name": "Coordinator", + "type": "Coordinator", + "definition": {"vendor": "", "model": "", "description": "", "exposes": []} + } +] +``` + +- [ ] **Step 5: Write `payload_test.go`** + +```go +package z2m + +import ( + "encoding/json" + "os" + "path/filepath" + "testing" +) + +func TestDecodeBridgeDevices(t *testing.T) { + raw, err := os.ReadFile(filepath.Join("testdata", "bridge_devices.json")) + if err != nil { + t.Fatalf("read fixture: %v", err) + } + var devices []Device + if err := json.Unmarshal(raw, &devices); err != nil { + t.Fatalf("decode: %v", err) + } + if got, want := len(devices), 5; got != want { + t.Fatalf("device count: got %d, want %d", got, want) + } + + // Spot-check the colour light: it should expose a "light" composite + // with four feature children. + light := findDevice(devices, "kitchen_light") + if light == nil { + t.Fatal("kitchen_light not found") + } + if len(light.Definition.Exposes) != 2 { + t.Errorf("kitchen_light top-level exposes: got %d, want 2", len(light.Definition.Exposes)) + } + lightExpose := light.Definition.Exposes[0] + if lightExpose.Type != "light" { + t.Errorf("first expose type: got %q, want %q", lightExpose.Type, "light") + } + if len(lightExpose.Features) != 4 { + t.Errorf("light features count: got %d, want 4", len(lightExpose.Features)) + } + + // Spot-check the multi-sensor. + motion := findDevice(devices, "hallway_motion") + if motion == nil { + t.Fatal("hallway_motion not found") + } + occ := findExpose(motion.Definition.Exposes, "occupancy") + if occ == nil { + t.Fatal("occupancy expose not found") + } + if occ.Type != "binary" { + t.Errorf("occupancy type: got %q, want %q", occ.Type, "binary") + } + if !occ.AccessPublished() { + t.Error("occupancy AccessPublished=false") + } + if occ.AccessSettable() { + t.Error("occupancy AccessSettable=true; expected read-only") + } +} + +func TestAccessBits(t *testing.T) { + cases := []struct { + access uint8 + published bool + settable bool + }{ + {0, false, false}, + {1, true, false}, + {2, false, true}, + {3, true, true}, + {7, true, true}, + } + for _, tc := range cases { + e := Expose{Access: tc.access} + if got := e.AccessPublished(); got != tc.published { + t.Errorf("Access=%d: AccessPublished=%v, want %v", tc.access, got, tc.published) + } + if got := e.AccessSettable(); got != tc.settable { + t.Errorf("Access=%d: AccessSettable=%v, want %v", tc.access, got, tc.settable) + } + } +} + +func TestDecodeBridgeState(t *testing.T) { + var s BridgeState + if err := json.Unmarshal([]byte(`{"state":"online"}`), &s); err != nil { + t.Fatalf("decode: %v", err) + } + if s.State != "online" { + t.Errorf("State = %q, want %q", s.State, "online") + } +} + +func TestDecodeStatePayload(t *testing.T) { + raw := []byte(`{"state":"ON","brightness":128,"color_temp":250}`) + var p StatePayload + if err := json.Unmarshal(raw, &p); err != nil { + t.Fatalf("decode: %v", err) + } + if got := len(p); got != 3 { + t.Errorf("payload size: got %d, want 3", got) + } + if string(p["brightness"]) != "128" { + t.Errorf("brightness raw: %q", string(p["brightness"])) + } +} + +func findDevice(devices []Device, name string) *Device { + for i, d := range devices { + if d.FriendlyName == name { + return &devices[i] + } + } + return nil +} + +func findExpose(exposes []Expose, property string) *Expose { + for i, e := range exposes { + if e.Property == property { + return &exposes[i] + } + if found := findExpose(e.Features, property); found != nil { + return found + } + } + return nil +} +``` + +- [ ] **Step 6: Run tests; expect pass** + +```bash +go test ./drivers/z2m/internal/z2m/... -v +``` + +Expected: all tests pass. + +- [ ] **Step 7: Commit** + +```bash +git add drivers/z2m/internal/z2m/ +git commit -m "$(cat <<'EOF' +feat(z2m): topic constructors and payload types + +drivers/z2m/internal/z2m/ models the Z2M topic namespace +(BridgeDevices, BridgeState, BridgeEvent, DeviceTopics) and the +JSON payload shapes (Device, Definition, Expose, BridgeState, +AvailabilityState, StatePayload). Decoding is verified against +a captured bridge/devices fixture covering a colour light, +multi-sensor, contact sensor, smart plug, and coordinator. + +Pure types, no I/O. Used by the reconciler and main wiring in +later tasks. + +Co-Authored-By: Claude Opus 4.7 (1M context) +EOF +)" +``` + +--- + +## Task 5: state.EntityID + +**Goal:** Implement the EntityID function per the spec. Covers two cases — light (no property suffix) and sensor (with property suffix). + +**Files:** +- Create: `drivers/z2m/internal/state/ids.go` +- Create: `drivers/z2m/internal/state/ids_test.go` + +- [ ] **Step 1: Write the failing tests** + +`drivers/z2m/internal/state/ids_test.go`: + +```go +package state + +import "testing" + +func TestEntityIDLight(t *testing.T) { + got := EntityID("0x00158d0001234abc", "light", "") + want := "light.z2m_01234abc" + if got != want { + t.Errorf("EntityID light: got %q, want %q", got, want) + } +} + +func TestEntityIDNumericSensor(t *testing.T) { + got := EntityID("0x00158d0009876543", "numeric_sensor", "temperature") + want := "numeric_sensor.z2m_09876543_temperature" + if got != want { + t.Errorf("EntityID numeric_sensor: got %q, want %q", got, want) + } +} + +func TestEntityIDBinarySensor(t *testing.T) { + got := EntityID("0x00158d0002468ace", "binary_sensor", "contact") + want := "binary_sensor.z2m_02468ace_contact" + if got != want { + t.Errorf("EntityID binary_sensor: got %q, want %q", got, want) + } +} + +func TestEntityIDShortIEEE(t *testing.T) { + // IEEEs shorter than 8 hex chars (post-prefix-strip) are passed through. + got := EntityID("0x12", "light", "") + want := "light.z2m_12" + if got != want { + t.Errorf("short IEEE: got %q, want %q", got, want) + } +} + +func TestEntityIDNoOxPrefix(t *testing.T) { + // Already without "0x" prefix. + got := EntityID("00158d0001234abc", "light", "") + want := "light.z2m_01234abc" + if got != want { + t.Errorf("no-prefix: got %q, want %q", got, want) + } +} + +func TestEntityIDCollisionFreeAcrossFixture(t *testing.T) { + // Sanity check: the fixture's IEEEs all yield distinct IDs even + // with property suffixes. + cases := []string{ + EntityID("0x00158d0001234abc", "light", ""), + EntityID("0x00158d0009876543", "binary_sensor", "occupancy"), + EntityID("0x00158d0009876543", "numeric_sensor", "temperature"), + EntityID("0x00158d0009876543", "numeric_sensor", "humidity"), + EntityID("0x00158d0009876543", "numeric_sensor", "battery"), + EntityID("0x00158d0002468ace", "binary_sensor", "contact"), + EntityID("0x00158d0002468ace", "numeric_sensor", "battery"), + EntityID("0x00158d0011223344", "numeric_sensor", "power"), + } + seen := map[string]bool{} + for _, id := range cases { + if seen[id] { + t.Errorf("collision on %q", id) + } + seen[id] = true + } +} +``` + +- [ ] **Step 2: Run tests to confirm they fail** + +```bash +go test ./drivers/z2m/internal/state/... -run TestEntityID +``` + +Expected: build fails ("EntityID undefined"). + +- [ ] **Step 3: Implement `ids.go`** + +```go +package state + +import "strings" + +// EntityID returns the gohome entity ID for a Z2M (device, property) +// pair. The last 8 hex chars of the IEEE address are used as the +// stable identifier — short enough to scan in logs, immune to +// friendly_name changes, and unambiguous within one Z2M instance. +// +// Lights collapse all light properties (state, brightness, color_temp, +// color) into a single light.* entity, so prop is empty for lights. +// Sensors append _ so a multi-sensor's properties get distinct +// IDs. +func EntityID(ieee, kind, prop string) string { + last8 := lastHex8(ieee) + if prop == "" { + return kind + ".z2m_" + last8 + } + return kind + ".z2m_" + last8 + "_" + prop +} + +func lastHex8(ieee string) string { + id := strings.TrimPrefix(ieee, "0x") + if len(id) > 8 { + id = id[len(id)-8:] + } + return id +} +``` + +- [ ] **Step 4: Run tests to confirm pass** + +```bash +go test ./drivers/z2m/internal/state/... -run TestEntityID -v +``` + +Expected: all `TestEntityID*` tests pass. + +- [ ] **Step 5: Commit** + +```bash +git add drivers/z2m/internal/state/ids.go drivers/z2m/internal/state/ids_test.go +git commit -m "$(cat <<'EOF' +feat(z2m): EntityID derives gohome ID from Z2M IEEE address + +Lights → light.z2m_; sensors → .z2m__. +Short, stable across friendly_name changes, collision-free within +one Z2M instance. + +Co-Authored-By: Claude Opus 4.7 (1M context) +EOF +)" +``` + +--- + +## Task 6: state.EntitiesFor — Z2M device → gohome entities + +**Goal:** Walk a `z2m.Device`'s exposes tree and emit a slice of `EntitySpec` plus side metadata the caller needs (which property feeds which entity). Apply the blocklist, skip writable non-light properties (with INFO log), dispatch on leaf type. + +**Files:** +- Create: `drivers/z2m/internal/state/mapping.go` +- Create: `drivers/z2m/internal/state/mapping_test.go` + +- [ ] **Step 1: Write the failing tests** + +`drivers/z2m/internal/state/mapping_test.go`: + +```go +package state + +import ( + "encoding/json" + "os" + "path/filepath" + "reflect" + "sort" + "strings" + "testing" + + "github.com/fdatoo/gohome-driverkit/driver" + entityv1 "github.com/fdatoo/gohome/gen/gohome/entity/v1" + + "github.com/fdatoo/gohome/drivers/z2m/internal/z2m" +) + +// loadFixture is shared with reconcile_test; keep it in this file. +func loadFixture(t *testing.T) []z2m.Device { + t.Helper() + raw, err := os.ReadFile(filepath.Join("..", "z2m", "testdata", "bridge_devices.json")) + if err != nil { + t.Fatalf("read fixture: %v", err) + } + var devices []z2m.Device + if err := json.Unmarshal(raw, &devices); err != nil { + t.Fatalf("decode fixture: %v", err) + } + return devices +} + +func deviceByName(t *testing.T, devices []z2m.Device, name string) z2m.Device { + t.Helper() + for _, d := range devices { + if d.FriendlyName == name { + return d + } + } + t.Fatalf("device %q not found", name) + return z2m.Device{} +} + +func entityIDs(out []EntityResult) []string { + ids := make([]string, len(out)) + for i, r := range out { + ids[i] = r.EntityID + } + sort.Strings(ids) + return ids +} + +func TestEntitiesForColorLight(t *testing.T) { + dev := deviceByName(t, loadFixture(t), "kitchen_light") + got := EntitiesFor(dev) + if len(got) != 1 { + t.Fatalf("kitchen_light entities: got %d, want 1", len(got)) + } + r := got[0] + if r.EntityID != "light.z2m_01234abc" { + t.Errorf("entityID: got %q, want %q", r.EntityID, "light.z2m_01234abc") + } + if r.Spec.EntityType != "light" { + t.Errorf("entity type: got %q, want %q", r.Spec.EntityType, "light") + } + wantCaps := []string{"set_brightness", "set_color", "set_color_temp", "turn_off", "turn_on"} + gotCaps := append([]string(nil), r.Spec.Capabilities...) + sort.Strings(gotCaps) + if !reflect.DeepEqual(gotCaps, wantCaps) { + t.Errorf("capabilities: got %v, want %v", gotCaps, wantCaps) + } +} + +func TestEntitiesForMultiSensor(t *testing.T) { + dev := deviceByName(t, loadFixture(t), "hallway_motion") + got := EntitiesFor(dev) + want := []string{ + "binary_sensor.z2m_09876543_occupancy", + "numeric_sensor.z2m_09876543_battery", + "numeric_sensor.z2m_09876543_humidity", + "numeric_sensor.z2m_09876543_temperature", + } + if !reflect.DeepEqual(entityIDs(got), want) { + t.Errorf("entity ids: got %v, want %v", entityIDs(got), want) + } + // linkquality and voltage are blocked. + for _, r := range got { + if strings.Contains(r.EntityID, "linkquality") || strings.Contains(r.EntityID, "voltage") { + t.Errorf("blocked property surfaced: %q", r.EntityID) + } + } +} + +func TestEntitiesForContactSensor(t *testing.T) { + dev := deviceByName(t, loadFixture(t), "front_door") + got := EntitiesFor(dev) + want := []string{ + "binary_sensor.z2m_02468ace_contact", + "numeric_sensor.z2m_02468ace_battery", + } + if !reflect.DeepEqual(entityIDs(got), want) { + t.Errorf("entity ids: got %v, want %v", entityIDs(got), want) + } +} + +func TestEntitiesForSmartPlugSkipsWritableState(t *testing.T) { + dev := deviceByName(t, loadFixture(t), "office_plug") + got := EntitiesFor(dev) + want := []string{"numeric_sensor.z2m_11223344_power"} + if !reflect.DeepEqual(entityIDs(got), want) { + t.Errorf("entity ids: got %v, want %v", entityIDs(got), want) + } +} + +func TestEntitiesForCoordinator(t *testing.T) { + dev := deviceByName(t, loadFixture(t), "Coordinator") + got := EntitiesFor(dev) + if len(got) != 0 { + t.Errorf("coordinator entities: got %d, want 0 (%v)", len(got), entityIDs(got)) + } +} + +func TestEntitiesForPropertyToEntityMap(t *testing.T) { + // Each result records which Z2M property feeds it. main uses this + // to fan a state-topic payload out to the right entity IDs. + dev := deviceByName(t, loadFixture(t), "hallway_motion") + got := EntitiesFor(dev) + for _, r := range got { + if r.Property == "" { + t.Errorf("sensor result missing Property: %+v", r) + } + } + // Light entities have a synthetic Property="" (multiple properties + // feed one entity) — verified separately. + light := deviceByName(t, loadFixture(t), "kitchen_light") + for _, r := range EntitiesFor(light) { + if r.Property != "" { + t.Errorf("light result Property: got %q, want \"\"", r.Property) + } + } +} + +// Round-trip sanity: an EntitySpec carries an InitialState whose Kind +// matches the entity type. +func TestEntitiesForInitialStateKinds(t *testing.T) { + devices := loadFixture(t) + for _, dev := range devices { + for _, r := range EntitiesFor(dev) { + if r.Spec.InitialState == nil { + continue + } + switch r.Spec.EntityType { + case "light": + if _, ok := r.Spec.InitialState.Kind.(*entityv1.Attributes_Light); !ok { + t.Errorf("%s: InitialState.Kind not Light", r.EntityID) + } + case "numeric_sensor": + if _, ok := r.Spec.InitialState.Kind.(*entityv1.Attributes_NumericSensor); !ok { + t.Errorf("%s: InitialState.Kind not NumericSensor", r.EntityID) + } + case "binary_sensor": + if _, ok := r.Spec.InitialState.Kind.(*entityv1.Attributes_BinarySensor); !ok { + t.Errorf("%s: InitialState.Kind not BinarySensor", r.EntityID) + } + } + } + } + _ = driver.EntitySpec{} // ensure the import is used +} +``` + +- [ ] **Step 2: Run tests; confirm they fail** + +```bash +go test ./drivers/z2m/internal/state/... -run TestEntitiesFor +``` + +Expected: build error ("EntitiesFor undefined", "EntityResult undefined"). + +- [ ] **Step 3: Implement `mapping.go`** + +```go +package state + +import ( + "log/slog" + + "github.com/fdatoo/gohome-driverkit/driver" + entityv1 "github.com/fdatoo/gohome/gen/gohome/entity/v1" + + "github.com/fdatoo/gohome/drivers/z2m/internal/z2m" +) + +// blockedProperties never become entities. linkquality/voltage are +// noise; update_available/last_seen are housekeeping. +var blockedProperties = map[string]bool{ + "linkquality": true, + "voltage": true, + "update_available": true, + "last_seen": true, +} + +// numericSensorProperties are the read-only numeric leaves we surface. +// Anything not in this set is ignored (debug log). +var numericSensorProperties = map[string]bool{ + "temperature": true, + "humidity": true, + "illuminance": true, + "battery": true, + "pressure": true, + "power": true, + "energy": true, + "current": true, +} + +// binarySensorProperties are the read-only binary leaves we surface. +var binarySensorProperties = map[string]bool{ + "occupancy": true, + "contact": true, + "water_leak": true, + "smoke": true, + "tamper": true, + "vibration": true, +} + +// EntityResult is one entity to register, plus the Z2M property name +// (if any) the caller should listen for on the device's state topic. +// Property is empty for lights (a light entity merges multiple +// properties — state, brightness, color_temp, color — under one ID; +// the caller iterates the StatePayload itself). +type EntityResult struct { + EntityID string + Spec driver.EntitySpec + Property string +} + +// EntitiesFor walks the device's exposes tree and returns one entity +// per supported (read-only) leaf, plus one collapsed light entity for +// any "light" composite. Unknown leaf types are skipped silently +// (debug-logged at the caller level if log verbosity is up). Writable +// non-light properties (smart plug state) are skipped with one INFO +// log line so users can see what they're missing. +func EntitiesFor(dev z2m.Device) []EntityResult { + var out []EntityResult + for _, e := range dev.Definition.Exposes { + out = append(out, mapExpose(dev, e)...) + } + return out +} + +func mapExpose(dev z2m.Device, e z2m.Expose) []EntityResult { + switch e.Type { + case "light": + return []EntityResult{lightEntity(dev, e)} + case "switch": + // v0.1: writable Switch is out of scope. Surface any read-only + // child properties (e.g. power on smart plugs) but skip the + // settable state child with an INFO log. + var out []EntityResult + for _, f := range e.Features { + out = append(out, mapExpose(dev, f)...) + } + return out + case "numeric": + if blockedProperties[e.Property] { + return nil + } + if e.AccessSettable() && !blockedProperties[e.Property] { + // Writable numeric — out of scope (no actuator class for + // numerics in v0.1). Skip silently. + return nil + } + if !numericSensorProperties[e.Property] { + slog.Debug("z2m: unrecognised numeric property; skipping", + "device", dev.FriendlyName, "property", e.Property) + return nil + } + return []EntityResult{numericSensorEntity(dev, e)} + case "binary": + if blockedProperties[e.Property] { + return nil + } + if e.AccessSettable() { + // Writable binary outside a `light` composite — typically a + // smart plug's `state`. Skip in v0.1 with a one-shot INFO so + // the user sees what's not surfaced. + slog.Info("z2m: writable binary property skipped (Switch class out of scope in v0.1)", + "device", dev.FriendlyName, "property", e.Property) + return nil + } + if !binarySensorProperties[e.Property] { + slog.Debug("z2m: unrecognised binary property; skipping", + "device", dev.FriendlyName, "property", e.Property) + return nil + } + return []EntityResult{binarySensorEntity(dev, e)} + default: + // composite / climate / cover / lock / fan / enum / text / list — + // out of scope in v0.1. Composite is handled inside light/switch + // above; everything else falls through silently. + slog.Debug("z2m: unsupported expose type; skipping", + "device", dev.FriendlyName, "type", e.Type, "name", e.Name) + return nil + } +} + +func lightEntity(dev z2m.Device, e z2m.Expose) EntityResult { + caps := []string{"turn_on", "turn_off"} + for _, f := range e.Features { + switch f.Property { + case "brightness": + caps = append(caps, "set_brightness") + case "color_temp": + caps = append(caps, "set_color_temp") + case "color": + caps = append(caps, "set_color") + } + } + return EntityResult{ + EntityID: EntityID(dev.IEEEAddress, "light", ""), + Spec: driver.EntitySpec{ + EntityType: "light", + FriendlyName: dev.FriendlyName, + Capabilities: caps, + InitialState: &entityv1.Attributes{ + Available: true, + Kind: &entityv1.Attributes_Light{Light: &entityv1.Light{}}, + }, + }, + Property: "", + } +} + +func numericSensorEntity(dev z2m.Device, e z2m.Expose) EntityResult { + return EntityResult{ + EntityID: EntityID(dev.IEEEAddress, "numeric_sensor", e.Property), + Spec: driver.EntitySpec{ + EntityType: "numeric_sensor", + FriendlyName: dev.FriendlyName + " " + e.Property, + Capabilities: nil, // read-only + InitialState: &entityv1.Attributes{ + Available: true, + Kind: &entityv1.Attributes_NumericSensor{ + NumericSensor: &entityv1.NumericSensor{Unit: e.Unit}, + }, + }, + }, + Property: e.Property, + } +} + +func binarySensorEntity(dev z2m.Device, e z2m.Expose) EntityResult { + return EntityResult{ + EntityID: EntityID(dev.IEEEAddress, "binary_sensor", e.Property), + Spec: driver.EntitySpec{ + EntityType: "binary_sensor", + FriendlyName: dev.FriendlyName + " " + e.Property, + Capabilities: nil, // read-only + InitialState: &entityv1.Attributes{ + Available: true, + Kind: &entityv1.Attributes_BinarySensor{ + BinarySensor: &entityv1.BinarySensor{}, + }, + }, + }, + Property: e.Property, + } +} +``` + +- [ ] **Step 4: Run tests; expect pass** + +```bash +go test ./drivers/z2m/internal/state/... -run TestEntitiesFor -v +``` + +Expected: all subtests pass. + +- [ ] **Step 5: Commit** + +```bash +git add drivers/z2m/internal/state/mapping.go drivers/z2m/internal/state/mapping_test.go +git commit -m "$(cat <<'EOF' +feat(z2m): EntitiesFor maps Z2M devices to gohome entities + +Walks the device's exposes tree, collapsing 'light' composites +into one light.* entity and fanning per-property numeric/binary +leaves into numeric_sensor.* / binary_sensor.* entities. Applies +a blocklist for noisy properties (linkquality, voltage, +update_available, last_seen) and skips writable non-light +properties in v0.1 (Switch class out of scope) with INFO log. + +Verified against the captured bridge/devices fixture: colour light +yields one light.* with four capabilities; the multi-sensor yields +one binary_sensor and three numeric_sensor entities; the contact +sensor yields one binary_sensor and one numeric_sensor; the smart +plug yields only its read-only power; coordinator yields zero. + +Co-Authored-By: Claude Opus 4.7 (1M context) +EOF +)" +``` + +--- + +## Task 7: state.CommandToPayload — Carport command → Z2M /set JSON + +**Goal:** Translate a `(capability, args)` pair into the JSON payload Z2M expects on `//set`. Validates argument ranges; bad input returns an error before the network. + +**Files:** +- Create: `drivers/z2m/internal/state/command.go` +- Create: `drivers/z2m/internal/state/command_test.go` + +- [ ] **Step 1: Write the failing tests** + +`drivers/z2m/internal/state/command_test.go`: + +```go +package state + +import ( + "encoding/json" + "strings" + "testing" +) + +func TestCommandToPayloadTurnOnOff(t *testing.T) { + for _, tc := range []struct { + cap string + state string + }{ + {"turn_on", "ON"}, + {"turn_off", "OFF"}, + } { + got, err := CommandToPayload(tc.cap, nil) + if err != nil { + t.Fatalf("%s: err = %v", tc.cap, err) + } + var decoded map[string]any + if err := json.Unmarshal(got, &decoded); err != nil { + t.Fatalf("%s: decode = %v", tc.cap, err) + } + if decoded["state"] != tc.state { + t.Errorf("%s: state = %v, want %q", tc.cap, decoded["state"], tc.state) + } + } +} + +func TestCommandToPayloadSetBrightness(t *testing.T) { + got, err := CommandToPayload("set_brightness", map[string]string{"brightness": "200"}) + if err != nil { + t.Fatalf("err = %v", err) + } + var decoded map[string]any + _ = json.Unmarshal(got, &decoded) + if decoded["brightness"].(float64) != 200 { + t.Errorf("brightness = %v, want 200", decoded["brightness"]) + } +} + +func TestCommandToPayloadSetBrightnessOutOfRange(t *testing.T) { + for _, raw := range []string{"-1", "256", "foo", ""} { + _, err := CommandToPayload("set_brightness", map[string]string{"brightness": raw}) + if err == nil { + t.Errorf("brightness=%q: expected error", raw) + } + } +} + +func TestCommandToPayloadSetBrightnessMissing(t *testing.T) { + _, err := CommandToPayload("set_brightness", map[string]string{}) + if err == nil || !strings.Contains(err.Error(), "brightness") { + t.Errorf("expected missing-arg error; got %v", err) + } +} + +func TestCommandToPayloadSetColorTemp(t *testing.T) { + got, err := CommandToPayload("set_color_temp", map[string]string{"color_temp": "300"}) + if err != nil { + t.Fatalf("err = %v", err) + } + var decoded map[string]any + _ = json.Unmarshal(got, &decoded) + if decoded["color_temp"].(float64) != 300 { + t.Errorf("color_temp = %v, want 300", decoded["color_temp"]) + } +} + +func TestCommandToPayloadSetColorTempRange(t *testing.T) { + for _, raw := range []string{"50", "1000", "abc"} { + _, err := CommandToPayload("set_color_temp", map[string]string{"color_temp": raw}) + if err == nil { + t.Errorf("color_temp=%q: expected error", raw) + } + } +} + +func TestCommandToPayloadSetColorHex(t *testing.T) { + got, err := CommandToPayload("set_color", map[string]string{"hex": "#FF8800"}) + if err != nil { + t.Fatalf("err = %v", err) + } + var decoded map[string]any + _ = json.Unmarshal(got, &decoded) + color, ok := decoded["color"].(map[string]any) + if !ok { + t.Fatalf("color block missing or wrong type: %T %v", decoded["color"], decoded["color"]) + } + if color["hex"] != "#FF8800" { + t.Errorf("hex = %v, want #FF8800", color["hex"]) + } +} + +func TestCommandToPayloadSetColorRGB(t *testing.T) { + got, err := CommandToPayload("set_color", map[string]string{"r": "255", "g": "136", "b": "0"}) + if err != nil { + t.Fatalf("err = %v", err) + } + var decoded map[string]any + _ = json.Unmarshal(got, &decoded) + color := decoded["color"].(map[string]any) + if color["hex"] != "#FF8800" { + t.Errorf("hex from rgb = %v, want #FF8800", color["hex"]) + } +} + +func TestCommandToPayloadSetColorBadInput(t *testing.T) { + for _, args := range []map[string]string{ + {}, + {"hex": "zz"}, + {"hex": "#FF"}, + {"r": "-1", "g": "0", "b": "0"}, + {"r": "256", "g": "0", "b": "0"}, + {"r": "0", "g": "0"}, // missing b + } { + if _, err := CommandToPayload("set_color", args); err == nil { + t.Errorf("expected error for args %v", args) + } + } +} + +func TestCommandToPayloadUnknownCapability(t *testing.T) { + if _, err := CommandToPayload("set_warp_drive", nil); err == nil { + t.Error("expected error for unknown capability") + } +} +``` + +- [ ] **Step 2: Run tests; confirm they fail** + +```bash +go test ./drivers/z2m/internal/state/... -run TestCommandToPayload +``` + +Expected: build fails ("CommandToPayload undefined"). + +- [ ] **Step 3: Implement `command.go`** + +```go +package state + +import ( + "encoding/json" + "fmt" + "strconv" + "strings" +) + +// CommandToPayload translates a Carport (capability, args) pair into +// the JSON payload Z2M expects on //set. Returns an +// error for unknown capabilities or out-of-range arguments — caller +// surfaces this as CARPORT_INTERNAL without hitting the network. +func CommandToPayload(capability string, args map[string]string) ([]byte, error) { + switch capability { + case "turn_on": + return json.Marshal(map[string]any{"state": "ON"}) + case "turn_off": + return json.Marshal(map[string]any{"state": "OFF"}) + case "set_brightness": + raw, ok := args["brightness"] + if !ok { + return nil, fmt.Errorf("set_brightness: missing brightness arg") + } + v, err := strconv.Atoi(raw) + if err != nil || v < 0 || v > 255 { + return nil, fmt.Errorf("set_brightness: brightness must be integer 0-255, got %q", raw) + } + return json.Marshal(map[string]any{"brightness": v}) + case "set_color_temp": + raw, ok := args["color_temp"] + if !ok { + return nil, fmt.Errorf("set_color_temp: missing color_temp arg") + } + v, err := strconv.Atoi(raw) + if err != nil || v < 153 || v > 500 { + return nil, fmt.Errorf("set_color_temp: color_temp must be integer 153-500 mireds, got %q", raw) + } + return json.Marshal(map[string]any{"color_temp": v}) + case "set_color": + hex, err := parseColorToHex(args) + if err != nil { + return nil, fmt.Errorf("set_color: %w", err) + } + return json.Marshal(map[string]any{"color": map[string]any{"hex": hex}}) + default: + return nil, fmt.Errorf("unknown capability %q", capability) + } +} + +// parseColorToHex extracts an RGB triple from args (hex= or r/g/b=) +// and returns it as "#RRGGBB" — the format Z2M understands universally +// across vendors. +func parseColorToHex(args map[string]string) (string, error) { + if h, ok := args["hex"]; ok { + s := strings.TrimPrefix(h, "#") + if len(s) != 6 { + return "", fmt.Errorf("hex must be 6 chars (with optional leading #), got %q", h) + } + if _, err := strconv.ParseUint(s, 16, 32); err != nil { + return "", fmt.Errorf("hex parse: %w", err) + } + return "#" + strings.ToUpper(s), nil + } + rs, hasR := args["r"] + gs, hasG := args["g"] + bs, hasB := args["b"] + if !hasR && !hasG && !hasB { + return "", fmt.Errorf("provide hex=#RRGGBB or r/g/b") + } + if !hasR || !hasG || !hasB { + return "", fmt.Errorf("r, g, and b must all be set") + } + r, err := parseByte(rs, "r") + if err != nil { + return "", err + } + g, err := parseByte(gs, "g") + if err != nil { + return "", err + } + b, err := parseByte(bs, "b") + if err != nil { + return "", err + } + return fmt.Sprintf("#%02X%02X%02X", r, g, b), nil +} + +func parseByte(s, name string) (uint8, error) { + v, err := strconv.Atoi(s) + if err != nil || v < 0 || v > 255 { + return 0, fmt.Errorf("%s must be integer 0-255, got %q", name, s) + } + return uint8(v), nil +} +``` + +- [ ] **Step 4: Run tests; expect pass** + +```bash +go test ./drivers/z2m/internal/state/... -run TestCommandToPayload -v +``` + +Expected: all subtests pass. + +- [ ] **Step 5: Commit** + +```bash +git add drivers/z2m/internal/state/command.go drivers/z2m/internal/state/command_test.go +git commit -m "$(cat <<'EOF' +feat(z2m): CommandToPayload validates and serialises light commands + +Maps Carport capabilities (turn_on, turn_off, set_brightness, +set_color_temp, set_color) to the JSON payloads Z2M's /set topic +expects. Range validation runs before any network I/O so bad input +surfaces as CARPORT_INTERNAL synchronously. + +set_color emits color: {hex: "#RRGGBB"} — Z2M understands hex +across every vendor and avoids per-bulb gamut clamping (Z2M does +its own). + +Co-Authored-By: Claude Opus 4.7 (1M context) +EOF +)" +``` + +--- + +## Task 8: state.MergeState — apply one state-topic property update + +**Goal:** Given a previous Attributes pointer (or nil), one property name, and one raw JSON value from a state-topic payload, produce a new Attributes. The kind of the previous Attributes determines how the property is interpreted (light merges into Light fields; sensors set their value). + +**Files:** +- Create: `drivers/z2m/internal/state/merge.go` +- Create: `drivers/z2m/internal/state/merge_test.go` + +- [ ] **Step 1: Write the failing tests** + +`drivers/z2m/internal/state/merge_test.go`: + +```go +package state + +import ( + "encoding/json" + "testing" + + entityv1 "github.com/fdatoo/gohome/gen/gohome/entity/v1" +) + +func lightAttrs(on bool, brightness, colorTemp, colorRGB uint32) *entityv1.Attributes { + return &entityv1.Attributes{ + Available: true, + Kind: &entityv1.Attributes_Light{ + Light: &entityv1.Light{ + On: on, Brightness: brightness, ColorTemp: colorTemp, ColorRgb: colorRGB, + }, + }, + } +} + +func raw(t *testing.T, v any) json.RawMessage { + t.Helper() + b, err := json.Marshal(v) + if err != nil { + t.Fatalf("marshal: %v", err) + } + return b +} + +func TestMergeStateLightOn(t *testing.T) { + prev := lightAttrs(false, 0, 0, 0) + got, err := MergeState(prev, "state", raw(t, "ON")) + if err != nil { + t.Fatalf("err = %v", err) + } + if !got.GetLight().GetOn() { + t.Error("expected on=true") + } +} + +func TestMergeStateLightOff(t *testing.T) { + prev := lightAttrs(true, 200, 0, 0) + got, _ := MergeState(prev, "state", raw(t, "OFF")) + if got.GetLight().GetOn() { + t.Error("expected on=false") + } + // Brightness preserved. + if got.GetLight().GetBrightness() != 200 { + t.Error("brightness should be preserved across on/off") + } +} + +func TestMergeStateLightBrightness(t *testing.T) { + prev := lightAttrs(true, 0, 0, 0) + got, _ := MergeState(prev, "brightness", raw(t, 128)) + if got.GetLight().GetBrightness() != 128 { + t.Errorf("brightness = %d, want 128", got.GetLight().GetBrightness()) + } +} + +func TestMergeStateLightColorTemp(t *testing.T) { + prev := lightAttrs(true, 200, 0, 0xFF8800) + got, _ := MergeState(prev, "color_temp", raw(t, 300)) + if got.GetLight().GetColorTemp() != 300 { + t.Errorf("color_temp = %d, want 300", got.GetLight().GetColorTemp()) + } + // Setting color_temp clears color_rgb (mutually exclusive). + if got.GetLight().GetColorRgb() != 0 { + t.Errorf("color_rgb should clear when color_temp set; got %#x", got.GetLight().GetColorRgb()) + } +} + +func TestMergeStateLightColor(t *testing.T) { + prev := lightAttrs(true, 200, 250, 0) + // Z2M color block: {x: ..., y: ...} + got, err := MergeState(prev, "color", raw(t, map[string]float64{"x": 0.6915, "y": 0.3083})) + if err != nil { + t.Fatalf("err = %v", err) + } + // Resulting RGB should be approximately red (high R, low G, low B). + rgb := got.GetLight().GetColorRgb() + r := uint8(rgb >> 16) + if r < 200 { + t.Errorf("expected red-dominant; got %#x", rgb) + } + if got.GetLight().GetColorTemp() != 0 { + t.Errorf("color_temp should clear when color set; got %d", got.GetLight().GetColorTemp()) + } +} + +func TestMergeStateNumericSensor(t *testing.T) { + prev := &entityv1.Attributes{ + Available: true, + Kind: &entityv1.Attributes_NumericSensor{ + NumericSensor: &entityv1.NumericSensor{Unit: "°C"}, + }, + } + got, err := MergeState(prev, "temperature", raw(t, 21.5)) + if err != nil { + t.Fatalf("err = %v", err) + } + if got.GetNumericSensor().GetValue() != 21.5 { + t.Errorf("value = %g, want 21.5", got.GetNumericSensor().GetValue()) + } + if got.GetNumericSensor().GetUnit() != "°C" { + t.Errorf("unit dropped: got %q", got.GetNumericSensor().GetUnit()) + } +} + +func TestMergeStateBinarySensor(t *testing.T) { + prev := &entityv1.Attributes{ + Available: true, + Kind: &entityv1.Attributes_BinarySensor{BinarySensor: &entityv1.BinarySensor{}}, + } + for _, tc := range []struct { + raw any + want bool + }{ + {true, true}, + {false, false}, + } { + got, err := MergeState(prev, "occupancy", raw(t, tc.raw)) + if err != nil { + t.Fatalf("err = %v", err) + } + if got.GetBinarySensor().GetOn() != tc.want { + t.Errorf("on = %v, want %v (raw=%v)", got.GetBinarySensor().GetOn(), tc.want, tc.raw) + } + } +} + +func TestMergeStateNilPrev(t *testing.T) { + // nil prev should error rather than panic — the caller didn't + // initialise InitialState, which is a programmer bug. + if _, err := MergeState(nil, "state", raw(t, "ON")); err == nil { + t.Error("expected error for nil prev") + } +} + +func TestMergeStateUnknownPropertyForLight(t *testing.T) { + // Unknown light property → no-op (returns prev unchanged) without + // error. Caller logs at debug. + prev := lightAttrs(true, 200, 0, 0) + got, err := MergeState(prev, "effect", raw(t, "blink")) + if err != nil { + t.Fatalf("err = %v", err) + } + if got != prev { + t.Error("expected prev returned unchanged on unknown property") + } +} +``` + +- [ ] **Step 2: Run tests; confirm they fail** + +```bash +go test ./drivers/z2m/internal/state/... -run TestMergeState +``` + +Expected: build fails ("MergeState undefined"). + +- [ ] **Step 3: Implement `merge.go`** + +```go +package state + +import ( + "encoding/json" + "errors" + "fmt" + + "github.com/fdatoo/gohome-driverkit/colorconv" + entityv1 "github.com/fdatoo/gohome/gen/gohome/entity/v1" +) + +// MergeState applies one (property, raw-value) update from a Z2M +// state-topic payload to prev and returns the new Attributes. The +// kind of prev is the contract: a Light entity's caller iterates the +// payload and accumulates updates by calling MergeState repeatedly; +// a sensor entity gets one matching property per call. +// +// Returns prev unchanged (and no error) if the property doesn't apply +// to prev's kind — callers don't need to know which properties go +// where; a state-topic payload from a multi-property device gets fanned +// out by iterating cache.entityByTopic, and each entity sees only the +// payload's keys, ignoring the ones it doesn't care about. +func MergeState(prev *entityv1.Attributes, property string, value json.RawMessage) (*entityv1.Attributes, error) { + if prev == nil { + return nil, errors.New("MergeState: prev is nil") + } + switch k := prev.Kind.(type) { + case *entityv1.Attributes_Light: + return mergeLight(prev, k.Light, property, value) + case *entityv1.Attributes_NumericSensor: + return mergeNumericSensor(prev, k.NumericSensor, property, value) + case *entityv1.Attributes_BinarySensor: + return mergeBinarySensor(prev, k.BinarySensor, property, value) + default: + return nil, fmt.Errorf("MergeState: unsupported kind %T", prev.Kind) + } +} + +func mergeLight(prev *entityv1.Attributes, light *entityv1.Light, property string, value json.RawMessage) (*entityv1.Attributes, error) { + next := &entityv1.Light{ + On: light.GetOn(), + Brightness: light.GetBrightness(), + ColorTemp: light.GetColorTemp(), + ColorRgb: light.GetColorRgb(), + } + switch property { + case "state": + var s string + if err := json.Unmarshal(value, &s); err != nil { + return nil, fmt.Errorf("light state: %w", err) + } + next.On = s == "ON" + case "brightness": + var v uint32 + if err := json.Unmarshal(value, &v); err != nil { + return nil, fmt.Errorf("brightness: %w", err) + } + next.Brightness = v + case "color_temp": + var v uint32 + if err := json.Unmarshal(value, &v); err != nil { + return nil, fmt.Errorf("color_temp: %w", err) + } + next.ColorTemp = v + next.ColorRgb = 0 + case "color": + var xy struct { + X float64 `json:"x"` + Y float64 `json:"y"` + } + if err := json.Unmarshal(value, &xy); err != nil { + return nil, fmt.Errorf("color: %w", err) + } + r, g, b := colorconv.XYToRGB(colorconv.XY{X: xy.X, Y: xy.Y}) + next.ColorRgb = colorconv.PackRGB(r, g, b) + next.ColorTemp = 0 + default: + return prev, nil // unknown property → no-op + } + return &entityv1.Attributes{ + Available: prev.GetAvailable(), + Kind: &entityv1.Attributes_Light{Light: next}, + }, nil +} + +func mergeNumericSensor(prev *entityv1.Attributes, sensor *entityv1.NumericSensor, _ string, value json.RawMessage) (*entityv1.Attributes, error) { + var v float64 + if err := json.Unmarshal(value, &v); err != nil { + return nil, fmt.Errorf("numeric sensor: %w", err) + } + return &entityv1.Attributes{ + Available: prev.GetAvailable(), + Kind: &entityv1.Attributes_NumericSensor{ + NumericSensor: &entityv1.NumericSensor{Unit: sensor.GetUnit(), Value: v}, + }, + }, nil +} + +func mergeBinarySensor(prev *entityv1.Attributes, _ *entityv1.BinarySensor, _ string, value json.RawMessage) (*entityv1.Attributes, error) { + // Z2M binary properties may be reported as bool OR string ("ON"/"OFF"). + // Try bool first; fall back to string. + var b bool + if err := json.Unmarshal(value, &b); err == nil { + return &entityv1.Attributes{ + Available: prev.GetAvailable(), + Kind: &entityv1.Attributes_BinarySensor{BinarySensor: &entityv1.BinarySensor{On: b}}, + }, nil + } + var s string + if err := json.Unmarshal(value, &s); err != nil { + return nil, fmt.Errorf("binary sensor: not bool or string") + } + on := s == "ON" || s == "true" + return &entityv1.Attributes{ + Available: prev.GetAvailable(), + Kind: &entityv1.Attributes_BinarySensor{BinarySensor: &entityv1.BinarySensor{On: on}}, + }, nil +} +``` + +- [ ] **Step 4: Run tests; expect pass** + +```bash +go test ./drivers/z2m/internal/state/... -run TestMergeState -v +``` + +Expected: all subtests pass. + +- [ ] **Step 5: Commit** + +```bash +git add drivers/z2m/internal/state/merge.go drivers/z2m/internal/state/merge_test.go +git commit -m "$(cat <<'EOF' +feat(z2m): MergeState applies one property update to Attributes + +Per-kind dispatch: Light merges state/brightness/color_temp/color +fields (with mutual exclusivity between color and color_temp); +NumericSensor sets value preserving unit; BinarySensor accepts +both bool and string ("ON"/"OFF") payloads, since Z2M devices +disagree on representation. + +Unknown light properties are no-ops rather than errors — a +multi-property state push fans out to several entities and each +ignores keys it doesn't recognise. + +Co-Authored-By: Claude Opus 4.7 (1M context) +EOF +)" +``` + +--- + +## Task 9: state.Reconcile — diff Z2M device lists into Actions + +**Goal:** Pure diff. Given previous and next device lists, return the ordered list of actions main should apply: AddEntity for new devices, UnregisterEntity for gone, UpdateAttrs for renames. + +**Files:** +- Create: `drivers/z2m/internal/state/reconcile.go` +- Create: `drivers/z2m/internal/state/reconcile_test.go` + +- [ ] **Step 1: Write the failing tests** + +`drivers/z2m/internal/state/reconcile_test.go`: + +```go +package state + +import ( + "sort" + "testing" + + "github.com/fdatoo/gohome/drivers/z2m/internal/z2m" +) + +func actionTypes(actions []Action) []string { + out := make([]string, len(actions)) + for i, a := range actions { + switch a.(type) { + case AddEntity: + out[i] = "add" + case UnregisterEntity: + out[i] = "remove" + case UpdateAttrs: + out[i] = "update" + default: + out[i] = "unknown" + } + } + sort.Strings(out) + return out +} + +func TestReconcileEmptyToN(t *testing.T) { + devices := loadFixture(t) + actions := Reconcile(nil, devices) + for _, a := range actions { + if _, ok := a.(AddEntity); !ok { + t.Errorf("expected only AddEntity actions, got %T", a) + } + } + // 5 devices in fixture: kitchen_light(1) + hallway_motion(4) + + // front_door(2) + office_plug(1) + Coordinator(0) = 8 entities. + if len(actions) != 8 { + t.Errorf("action count: got %d, want 8", len(actions)) + } +} + +func TestReconcileNoOp(t *testing.T) { + devices := loadFixture(t) + actions := Reconcile(devices, devices) + if len(actions) != 0 { + t.Errorf("no-op should produce zero actions; got %d (%v)", len(actions), actionTypes(actions)) + } +} + +func TestReconcileAdd(t *testing.T) { + all := loadFixture(t) + prev := all[:len(all)-1] // drop coordinator + // Coordinator yields zero entities, so add it back: still zero adds. + actions := Reconcile(prev, all) + if len(actions) != 0 { + t.Errorf("adding a coordinator should add zero entities; got %v", actionTypes(actions)) + } + + // Add a real device (kitchen_light only in next). + prev = []z2m.Device{} + next := []z2m.Device{deviceByName(t, all, "front_door")} + actions = Reconcile(prev, next) + if len(actions) != 2 { // contact + battery + t.Errorf("front_door: got %d adds, want 2", len(actions)) + } + for _, a := range actions { + if _, ok := a.(AddEntity); !ok { + t.Errorf("expected AddEntity, got %T", a) + } + } +} + +func TestReconcileRemove(t *testing.T) { + all := loadFixture(t) + prev := []z2m.Device{deviceByName(t, all, "front_door")} + next := []z2m.Device{} + actions := Reconcile(prev, next) + if len(actions) != 2 { + t.Errorf("front_door removal: got %d, want 2", len(actions)) + } + for _, a := range actions { + if _, ok := a.(UnregisterEntity); !ok { + t.Errorf("expected UnregisterEntity, got %T", a) + } + } +} + +func TestReconcileRename(t *testing.T) { + all := loadFixture(t) + original := deviceByName(t, all, "front_door") + renamed := original + renamed.FriendlyName = "back_door" + prev := []z2m.Device{original} + next := []z2m.Device{renamed} + actions := Reconcile(prev, next) + // 2 entities (contact + battery) → 2 UpdateAttrs. + if len(actions) != 2 { + t.Fatalf("rename: got %d actions, want 2", len(actions)) + } + for _, a := range actions { + ua, ok := a.(UpdateAttrs) + if !ok { + t.Errorf("expected UpdateAttrs, got %T", a) + continue + } + if ua.NewFriendlyName != "back_door "+ua.Property { + t.Errorf("UpdateAttrs.NewFriendlyName: got %q, want %q", ua.NewFriendlyName, "back_door "+ua.Property) + } + } +} + +func TestReconcileMixed(t *testing.T) { + all := loadFixture(t) + prev := []z2m.Device{deviceByName(t, all, "front_door")} + next := []z2m.Device{deviceByName(t, all, "kitchen_light")} + actions := Reconcile(prev, next) + // 2 removes (front_door entities) + 1 add (kitchen_light entity). + want := []string{"add", "remove", "remove"} + if got := actionTypes(actions); !equalStringSlices(got, want) { + t.Errorf("mixed: got %v, want %v", got, want) + } +} + +func TestReconcileAddBeforeRemoveOrder(t *testing.T) { + // Within one cycle, AddEntity actions must precede UnregisterEntity + // actions so retained state delivery for added topics can race-free + // with the registration. UpdateAttrs go last (relabeling). + all := loadFixture(t) + prev := []z2m.Device{deviceByName(t, all, "front_door")} + next := []z2m.Device{deviceByName(t, all, "kitchen_light")} + actions := Reconcile(prev, next) + + var addedAt, removedAt int = -1, -1 + for i, a := range actions { + switch a.(type) { + case AddEntity: + if addedAt == -1 { + addedAt = i + } + case UnregisterEntity: + if removedAt == -1 { + removedAt = i + } + } + } + if addedAt == -1 || removedAt == -1 { + t.Fatalf("expected both add and remove; got %v", actionTypes(actions)) + } + if addedAt > removedAt { + t.Errorf("expected adds before removes; got order %v", actionTypes(actions)) + } +} + +func TestReconcileTopicsCarried(t *testing.T) { + // AddEntity carries the device's per-device topics so main can + // subscribe in lock-step. + all := loadFixture(t) + next := []z2m.Device{deviceByName(t, all, "kitchen_light")} + actions := Reconcile(nil, next) + if len(actions) != 1 { + t.Fatalf("got %d actions, want 1", len(actions)) + } + add, ok := actions[0].(AddEntity) + if !ok { + t.Fatalf("expected AddEntity, got %T", actions[0]) + } + wantState := "zigbee2mqtt/kitchen_light" + // Reconcile is base-agnostic; it stores friendly_name and lets main + // build topics. Verify FriendlyName is what we expect. + if add.FriendlyName != "kitchen_light" { + t.Errorf("FriendlyName: got %q, want %q", add.FriendlyName, "kitchen_light") + } + // EntityID should match what EntityID() produces. + if add.EntityID != "light.z2m_01234abc" { + t.Errorf("EntityID: got %q, want %q", add.EntityID, "light.z2m_01234abc") + } + _ = wantState +} + +func equalStringSlices(a, b []string) bool { + if len(a) != len(b) { + return false + } + for i := range a { + if a[i] != b[i] { + return false + } + } + return true +} +``` + +- [ ] **Step 2: Run tests; confirm they fail** + +```bash +go test ./drivers/z2m/internal/state/... -run TestReconcile +``` + +Expected: build fails ("Reconcile undefined", "Action undefined", etc.). + +- [ ] **Step 3: Implement `reconcile.go`** + +```go +package state + +import ( + "sort" + + "github.com/fdatoo/gohome-driverkit/driver" + + "github.com/fdatoo/gohome/drivers/z2m/internal/z2m" +) + +// Action is what Reconcile emits. main switches on the concrete type +// to apply each (subscribe + driver.AddEntity, etc.). +type Action interface{ isAction() } + +// AddEntity instructs main to register the entity, install capability +// handlers (if any), and subscribe to the device's state + +// availability topics. FriendlyName is the Z2M friendly_name (used +// to compute /set topic); IEEE is carried so command handlers can +// look up the device address-stable. +type AddEntity struct { + EntityID string + Spec driver.EntitySpec + IEEE string + FriendlyName string + Property string // "" for lights; the Z2M property name for sensors +} + +func (AddEntity) isAction() {} + +// UnregisterEntity instructs main to unsubscribe from the device's +// topics and call driver.UnregisterEntity. +type UnregisterEntity struct { + EntityID string + FriendlyName string // for unsubscribe; main rebuilds topics +} + +func (UnregisterEntity) isAction() {} + +// UpdateAttrs is emitted on friendly_name change so the entity can be +// relabeled without re-registration. Property is "" for lights and the +// Z2M property name for sensors (used to build the new friendly name). +type UpdateAttrs struct { + EntityID string + NewFriendlyName string + Property string +} + +func (UpdateAttrs) isAction() {} + +// Reconcile diffs prev → next at the entity level. The returned slice +// is ordered: all AddEntity first, then UnregisterEntity, then +// UpdateAttrs. This ordering matters for the v0.1 race window between +// retained-state delivery and entity registration (subscribe ahead of +// registration is the safer direction). +func Reconcile(prev, next []z2m.Device) []Action { + prevByIEEE := indexByIEEE(prev) + nextByIEEE := indexByIEEE(next) + + var adds, removes, updates []Action + + // Walk next: anything missing from prev is an add; anything with a + // different friendly_name is an update. + for ieee, ndev := range nextByIEEE { + entries := EntitiesFor(ndev) + pdev, existed := prevByIEEE[ieee] + if !existed { + for _, r := range entries { + adds = append(adds, AddEntity{ + EntityID: r.EntityID, + Spec: r.Spec, + IEEE: ieee, + FriendlyName: ndev.FriendlyName, + Property: r.Property, + }) + } + continue + } + if pdev.FriendlyName != ndev.FriendlyName { + for _, r := range entries { + updates = append(updates, UpdateAttrs{ + EntityID: r.EntityID, + NewFriendlyName: r.Spec.FriendlyName, + Property: r.Property, + }) + } + } + // Composition changes (a device's exposes tree gains/loses a + // property without a rename) are not handled in v0.1: real Z2M + // firmware updates are rare, and the user can restart the driver + // to pick them up. Documented as a caveat in the README. + } + + // Walk prev: anything missing from next is a remove. + for ieee, pdev := range prevByIEEE { + if _, stillThere := nextByIEEE[ieee]; stillThere { + continue + } + for _, r := range EntitiesFor(pdev) { + removes = append(removes, UnregisterEntity{ + EntityID: r.EntityID, + FriendlyName: pdev.FriendlyName, + }) + } + } + + sortByEntityID(adds) + sortByEntityID(removes) + sortByEntityID(updates) + + out := make([]Action, 0, len(adds)+len(removes)+len(updates)) + out = append(out, adds...) + out = append(out, removes...) + out = append(out, updates...) + return out +} + +func indexByIEEE(devices []z2m.Device) map[string]z2m.Device { + out := make(map[string]z2m.Device, len(devices)) + for _, d := range devices { + out[d.IEEEAddress] = d + } + return out +} + +func sortByEntityID(actions []Action) { + sort.SliceStable(actions, func(i, j int) bool { + return entityIDOf(actions[i]) < entityIDOf(actions[j]) + }) +} + +func entityIDOf(a Action) string { + switch v := a.(type) { + case AddEntity: + return v.EntityID + case UnregisterEntity: + return v.EntityID + case UpdateAttrs: + return v.EntityID + } + return "" +} +``` + +- [ ] **Step 4: Run tests; expect pass** + +```bash +go test ./drivers/z2m/internal/state/... -run TestReconcile -v +``` + +Expected: all subtests pass. + +- [ ] **Step 5: Run the whole state package to ensure nothing regressed** + +```bash +go test ./drivers/z2m/internal/state/... -v +``` + +Expected: every test passes. + +- [ ] **Step 6: Commit** + +```bash +git add drivers/z2m/internal/state/reconcile.go drivers/z2m/internal/state/reconcile_test.go +git commit -m "$(cat <<'EOF' +feat(z2m): Reconcile diffs device lists into ordered Actions + +AddEntity / UnregisterEntity / UpdateAttrs cover the three +mutations the bridge/devices retained topic produces: +new pairings, removed devices, and friendly_name renames. +Adds come before removes in the ordered list so subscribe +happens before registration on a swap (avoids retained-state +race), with UpdateAttrs last. + +Composition changes (device firmware grows a property) are +deferred — user restarts the driver to pick them up; documented +in README later. + +Co-Authored-By: Claude Opus 4.7 (1M context) +EOF +)" +``` + +--- + +## Task 10: internal/mqtt — paho.mqtt.golang wrapper + +**Goal:** Thin, testable wrapper exposing only the operations main needs. Keep the wrapper small enough that the real broker (mochi) integration tests in Task 12 fully cover it; the unit tests here just sanity-check construction and option wiring. + +**Files:** +- Create: `drivers/z2m/internal/mqtt/client.go` +- Create: `drivers/z2m/internal/mqtt/client_test.go` + +- [ ] **Step 1: Write the failing tests** + +`drivers/z2m/internal/mqtt/client_test.go`: + +```go +package mqtt + +import ( + "context" + "net" + "testing" + "time" + + mqttserver "github.com/mochi-mqtt/server/v2" + "github.com/mochi-mqtt/server/v2/hooks/auth" + "github.com/mochi-mqtt/server/v2/listeners" +) + +// startBroker starts an in-memory mochi broker on a free TCP port and +// returns its address (host:port). Cleanup is registered via t. +func startBroker(t *testing.T) string { + t.Helper() + server := mqttserver.New(nil) + if err := server.AddHook(new(auth.AllowHook), nil); err != nil { + t.Fatalf("AddHook: %v", err) + } + l, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatalf("listen: %v", err) + } + addr := l.Addr().String() + _ = l.Close() + tcp := listeners.NewTCP(listeners.Config{ID: "t1", Address: addr}) + if err := server.AddListener(tcp); err != nil { + t.Fatalf("AddListener: %v", err) + } + go func() { _ = server.Serve() }() + t.Cleanup(func() { _ = server.Close() }) + // Give the listener a moment to bind. + time.Sleep(50 * time.Millisecond) + return addr +} + +func TestClientConnectPublishSubscribe(t *testing.T) { + addr := startBroker(t) + + c, err := New(Config{ + BrokerURL: "tcp://" + addr, + ClientID: "test-client", + }) + if err != nil { + t.Fatalf("New: %v", err) + } + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + if err := c.Connect(ctx); err != nil { + t.Fatalf("Connect: %v", err) + } + defer c.Close() + + got := make(chan []byte, 1) + if err := c.Subscribe("test/topic", func(_ string, payload []byte) { + got <- append([]byte(nil), payload...) + }); err != nil { + t.Fatalf("Subscribe: %v", err) + } + + if err := c.Publish("test/topic", []byte("hello"), false); err != nil { + t.Fatalf("Publish: %v", err) + } + + select { + case payload := <-got: + if string(payload) != "hello" { + t.Errorf("payload = %q, want %q", payload, "hello") + } + case <-time.After(2 * time.Second): + t.Error("subscriber never received payload") + } +} + +func TestClientUnsubscribe(t *testing.T) { + addr := startBroker(t) + c, err := New(Config{BrokerURL: "tcp://" + addr, ClientID: "u-test"}) + if err != nil { + t.Fatalf("New: %v", err) + } + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + if err := c.Connect(ctx); err != nil { + t.Fatalf("Connect: %v", err) + } + defer c.Close() + + got := make(chan struct{}, 1) + _ = c.Subscribe("u/topic", func(_ string, _ []byte) { got <- struct{}{} }) + if err := c.Unsubscribe("u/topic"); err != nil { + t.Fatalf("Unsubscribe: %v", err) + } + _ = c.Publish("u/topic", []byte("x"), false) + select { + case <-got: + t.Error("received payload after unsubscribe") + case <-time.After(300 * time.Millisecond): + } +} + +func TestClientOnConnect(t *testing.T) { + addr := startBroker(t) + c, err := New(Config{BrokerURL: "tcp://" + addr, ClientID: "cb"}) + if err != nil { + t.Fatalf("New: %v", err) + } + called := make(chan struct{}, 1) + c.OnConnect(func() { called <- struct{}{} }) + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + if err := c.Connect(ctx); err != nil { + t.Fatalf("Connect: %v", err) + } + defer c.Close() + + select { + case <-called: + case <-time.After(2 * time.Second): + t.Error("OnConnect callback never fired") + } +} + +func TestClientConfigRequired(t *testing.T) { + if _, err := New(Config{ClientID: "x"}); err == nil { + t.Error("expected error for missing BrokerURL") + } +} +``` + +- [ ] **Step 2: Run tests; confirm they fail** + +```bash +go test ./drivers/z2m/internal/mqtt/... -v +``` + +Expected: build fails ("New undefined", etc.). + +- [ ] **Step 3: Implement `client.go`** + +```go +package mqtt + +import ( + "context" + "crypto/tls" + "errors" + "fmt" + "sync" + "time" + + paho "github.com/eclipse/paho.mqtt.golang" +) + +// Config carries the parameters needed to construct a Client. All +// fields except BrokerURL and ClientID are optional. +type Config struct { + BrokerURL string + ClientID string + Username string + Password string + TLSSkipVerify bool +} + +// Handler is the per-message callback registered with Subscribe. +type Handler func(topic string, payload []byte) + +// Client is the subset of paho.Client the Z2M driver uses. The thin +// wrapper exists so tests can hold a concrete type and the driver +// doesn't grow a transitive paho dependency through every layer. +type Client struct { + cfg Config + + mu sync.Mutex + c paho.Client + handlers map[string]Handler + onConnect func() + onDisconnect func(error) +} + +// New constructs a Client. BrokerURL and ClientID are required. +func New(cfg Config) (*Client, error) { + if cfg.BrokerURL == "" { + return nil, errors.New("mqtt: BrokerURL required") + } + if cfg.ClientID == "" { + return nil, errors.New("mqtt: ClientID required") + } + return &Client{ + cfg: cfg, + handlers: make(map[string]Handler), + }, nil +} + +// OnConnect registers a callback that fires on every successful +// (re)connect. Use this to re-assert subscriptions after broker churn. +func (c *Client) OnConnect(cb func()) { + c.mu.Lock() + c.onConnect = cb + c.mu.Unlock() +} + +// OnDisconnect registers a callback that fires when the connection +// drops. paho's auto-reconnect runs in the background; this is purely +// informational. +func (c *Client) OnDisconnect(cb func(error)) { + c.mu.Lock() + c.onDisconnect = cb + c.mu.Unlock() +} + +// Connect dials the broker and blocks until the first connect +// succeeds or ctx is cancelled. +func (c *Client) Connect(ctx context.Context) error { + opts := paho.NewClientOptions(). + AddBroker(c.cfg.BrokerURL). + SetClientID(c.cfg.ClientID). + SetAutoReconnect(true). + SetCleanSession(false). + SetConnectRetry(true). + SetConnectRetryInterval(2 * time.Second). + SetMaxReconnectInterval(30 * time.Second). + SetKeepAlive(60 * time.Second). + SetPingTimeout(10 * time.Second) + + if c.cfg.Username != "" { + opts.SetUsername(c.cfg.Username) + } + if c.cfg.Password != "" { + opts.SetPassword(c.cfg.Password) + } + if c.cfg.TLSSkipVerify { + opts.SetTLSConfig(&tls.Config{InsecureSkipVerify: true}) // #nosec G402 — opt-in via config + } + + opts.SetOnConnectHandler(func(_ paho.Client) { + c.mu.Lock() + cb := c.onConnect + // Re-assert subscriptions on every connect so reconnects + // don't silently lose them. + for topic, h := range c.handlers { + topic, h := topic, h + c.c.Subscribe(topic, 0, func(_ paho.Client, msg paho.Message) { + h(msg.Topic(), msg.Payload()) + }) + } + c.mu.Unlock() + if cb != nil { + cb() + } + }) + opts.SetConnectionLostHandler(func(_ paho.Client, err error) { + c.mu.Lock() + cb := c.onDisconnect + c.mu.Unlock() + if cb != nil { + cb(err) + } + }) + + c.mu.Lock() + c.c = paho.NewClient(opts) + c.mu.Unlock() + + tok := c.c.Connect() + select { + case <-ctx.Done(): + return ctx.Err() + case <-tokenDone(tok): + } + if err := tok.Error(); err != nil { + return fmt.Errorf("mqtt connect: %w", err) + } + return nil +} + +// Subscribe registers h for topic. Idempotent across reconnects: the +// OnConnect handler re-applies every entry in c.handlers. +func (c *Client) Subscribe(topic string, h Handler) error { + c.mu.Lock() + c.handlers[topic] = h + cli := c.c + c.mu.Unlock() + if cli == nil || !cli.IsConnected() { + return nil // re-applied on next OnConnect + } + tok := cli.Subscribe(topic, 0, func(_ paho.Client, msg paho.Message) { + h(msg.Topic(), msg.Payload()) + }) + tok.Wait() + return tok.Error() +} + +// Unsubscribe drops the handler for topic and tells the broker. +func (c *Client) Unsubscribe(topic string) error { + c.mu.Lock() + delete(c.handlers, topic) + cli := c.c + c.mu.Unlock() + if cli == nil || !cli.IsConnected() { + return nil + } + tok := cli.Unsubscribe(topic) + tok.Wait() + return tok.Error() +} + +// Publish writes payload to topic. retained controls whether the +// broker stores it for future subscribers (Z2M's bridge/devices +// uses retained; /set commands do not). +func (c *Client) Publish(topic string, payload []byte, retained bool) error { + c.mu.Lock() + cli := c.c + c.mu.Unlock() + if cli == nil { + return errors.New("mqtt: not connected") + } + tok := cli.Publish(topic, 0, retained, payload) + tok.Wait() + return tok.Error() +} + +// Close disconnects cleanly. Idempotent. +func (c *Client) Close() { + c.mu.Lock() + cli := c.c + c.c = nil + c.mu.Unlock() + if cli != nil && cli.IsConnected() { + cli.Disconnect(250) + } +} + +func tokenDone(t paho.Token) <-chan struct{} { + ch := make(chan struct{}) + go func() { + t.Wait() + close(ch) + }() + return ch +} +``` + +- [ ] **Step 4: Run tests; expect pass** + +```bash +go test ./drivers/z2m/internal/mqtt/... -v +``` + +Expected: all tests pass. (If `mochi-mqtt/server/v2`'s API differs slightly from what's shown — e.g. listener config field renames between versions — adjust the test setup; the production code in `client.go` is paho-only and doesn't touch mochi.) + +- [ ] **Step 5: Commit** + +```bash +git add drivers/z2m/internal/mqtt/ +git commit -m "$(cat <<'EOF' +feat(z2m): MQTT client wrapper around paho.mqtt.golang + +Thin facade exposing Connect/Subscribe/Unsubscribe/Publish/Close +plus OnConnect/OnDisconnect callbacks. paho's auto-reconnect is +left on; the OnConnect callback re-asserts subscriptions so +reconnects don't silently drop them. + +Tested against an in-process mochi-mqtt broker. + +Co-Authored-By: Claude Opus 4.7 (1M context) +EOF +)" +``` + +--- + +## Task 11: cmd/z2m-driver/main.go — config + buildDriver + +**Goal:** Load config from environment, construct the MQTT client, perform the initial reconciliation, and wire everything into the driverkit. This task lands the non-handler scaffolding; Task 12 adds the runtime handlers and integration tests. + +**Files:** +- Create: `drivers/z2m/cmd/z2m-driver/main.go` + +- [ ] **Step 1: Write `main.go`** + +```go +package main + +import ( + "context" + "crypto/rand" + "encoding/hex" + "errors" + "fmt" + "log/slog" + "os" + "os/signal" + "strconv" + "sync" + "syscall" + + "github.com/fdatoo/gohome-driverkit/driver" + entityv1 "github.com/fdatoo/gohome/gen/gohome/entity/v1" + + "github.com/fdatoo/gohome/drivers/z2m/internal/mqtt" + "github.com/fdatoo/gohome/drivers/z2m/internal/state" + "github.com/fdatoo/gohome/drivers/z2m/internal/z2m" +) + +const driverName, driverVersion = "driver.z2m", "0.1.0" + +func main() { + cfg, err := loadConfig() + if err != nil { + fmt.Fprintf(os.Stderr, "z2m-driver: config: %v\n", err) + os.Exit(1) + } + + logger := slog.New(slog.NewJSONHandler(os.Stderr, &slog.HandlerOptions{ + Level: parseLogLevel(os.Getenv("Z2M_LOG_LEVEL")), + })).With( + "instance_id", os.Getenv("GOHOME_CARPORT_INSTANCE_ID"), + "broker_url", cfg.BrokerURL, + "base_topic", cfg.BaseTopic, + ) + slog.SetDefault(logger) + + ctx, cancel := signal.NotifyContext(context.Background(), syscall.SIGTERM, syscall.SIGINT) + defer cancel() + + mq, err := mqtt.New(mqtt.Config{ + BrokerURL: cfg.BrokerURL, + ClientID: cfg.ClientID, + Username: cfg.Username, + Password: cfg.Password, + TLSSkipVerify: cfg.TLSSkipVerify, + }) + if err != nil { + fmt.Fprintf(os.Stderr, "z2m-driver: mqtt new: %v\n", err) + os.Exit(1) + } + if err := mq.Connect(ctx); err != nil { + fmt.Fprintf(os.Stderr, "z2m-driver: mqtt connect: %v\n", err) + os.Exit(1) + } + defer mq.Close() + + d := driver.New(driverName, driverVersion) + cache := newStateCache() + app := &app{cfg: cfg, mq: mq, d: d, cache: cache} + + mq.OnDisconnect(func(err error) { + slog.Warn("mqtt disconnected", "error", err) + _ = d.EmitDriverEvent("broker_disconnected", err.Error()) + }) + mq.OnConnect(func() { + _ = d.EmitDriverEvent("broker_reconnected", "") + }) + + app.subscribeBridgeTopics() + + runErr := d.Run(ctx) + if runErr != nil && !errors.Is(runErr, context.Canceled) { + slog.Error("driver run exited", "error", runErr) + os.Exit(1) + } +} + +// config holds parsed environment variables. +type config struct { + BrokerURL string + Username string + Password string + BaseTopic string + ClientID string + TLSSkipVerify bool +} + +func loadConfig() (config, error) { + c := config{ + BrokerURL: os.Getenv("Z2M_BROKER_URL"), + Username: os.Getenv("Z2M_USERNAME"), + Password: os.Getenv("Z2M_PASSWORD"), + BaseTopic: os.Getenv("Z2M_BASE_TOPIC"), + ClientID: os.Getenv("Z2M_CLIENT_ID"), + } + if c.BrokerURL == "" { + return config{}, errors.New("Z2M_BROKER_URL is required") + } + if c.BaseTopic == "" { + c.BaseTopic = "zigbee2mqtt" + } + if c.ClientID == "" { + var b [4]byte + _, _ = rand.Read(b[:]) + c.ClientID = "gohome-z2m-" + hex.EncodeToString(b[:]) + } + if v := os.Getenv("Z2M_TLS_SKIP_VERIFY"); v != "" { + b, err := strconv.ParseBool(v) + if err != nil { + return config{}, errors.New("Z2M_TLS_SKIP_VERIFY must be a boolean") + } + c.TLSSkipVerify = b + } + return c, nil +} + +func parseLogLevel(s string) slog.Level { + switch s { + case "debug": + return slog.LevelDebug + case "warn": + return slog.LevelWarn + case "error": + return slog.LevelError + default: + return slog.LevelInfo + } +} + +// stateCache holds the driver's runtime view: which entities exist +// and what the last published state was, so MergeState has a base to +// merge against; which Z2M IEEE addresses we know about, fed to the +// next Reconcile; which entity IDs are downstream of a given state +// topic (a multi-property device's state topic fans out to N entities). +type stateCache struct { + mu sync.Mutex + entities map[string]*entityv1.Attributes // entityID → last attrs + devices map[string]z2m.Device // ieee → last-seen device + entityByTopic map[string][]entityListener // state topic → which entities consume it + friendlyByEnt map[string]string // entityID → friendly_name (for /set) + ieeeByEnt map[string]string // entityID → ieee (for log context) +} + +// entityListener is one entity's binding inside a state topic: which +// Z2M property it cares about (empty string means a light, which +// consumes every recognised property in the payload). +type entityListener struct { + EntityID string + Property string +} + +func newStateCache() *stateCache { + return &stateCache{ + entities: map[string]*entityv1.Attributes{}, + devices: map[string]z2m.Device{}, + entityByTopic: map[string][]entityListener{}, + friendlyByEnt: map[string]string{}, + ieeeByEnt: map[string]string{}, + } +} + +// app bundles the long-lived dependencies that handlers need so we +// can pass one pointer rather than five. +type app struct { + cfg config + mq *mqtt.Client + d *driver.Driver + cache *stateCache +} + +// subscribeBridgeTopics is filled in in Task 12. Stubbed here so +// main.go compiles. +func (a *app) subscribeBridgeTopics() { + // Implementation lands in Task 12. +} +``` + +- [ ] **Step 2: Build** + +```bash +go build ./drivers/z2m/cmd/z2m-driver +``` + +Expected: silent success (no tests in this task). + +- [ ] **Step 3: Confirm config validation** + +Quick smoke test from the shell — run the binary with no env vars and confirm the friendly error appears: + +```bash +go run ./drivers/z2m/cmd/z2m-driver +``` + +Expected stderr: `z2m-driver: config: Z2M_BROKER_URL is required`. Exit code: 1. + +- [ ] **Step 4: Commit** + +```bash +git add drivers/z2m/cmd/z2m-driver/main.go +git commit -m "$(cat <<'EOF' +feat(z2m): main.go config loading and driverkit wiring scaffold + +Reads Z2M_BROKER_URL / Z2M_USERNAME / Z2M_PASSWORD / +Z2M_BASE_TOPIC / Z2M_CLIENT_ID / Z2M_TLS_SKIP_VERIFY from env; +dials the broker via the internal/mqtt wrapper; constructs the +driver and a stateCache; forwards broker connect/disconnect to +driver events. + +The reconciliation handlers (subscribeBridgeTopics) land next. + +Co-Authored-By: Claude Opus 4.7 (1M context) +EOF +)" +``` + +--- + +## Task 12: cmd/z2m-driver/main.go — handlers + integration tests + +**Goal:** Wire up the runtime handlers (`bridge/devices` reconciliation, per-device state push, availability, capability handlers) and prove the whole loop end-to-end against an embedded mochi broker through the `drivertest` harness. + +**Files:** +- Modify: `drivers/z2m/cmd/z2m-driver/main.go` (replace `subscribeBridgeTopics` stub with real impl) +- Create: `drivers/z2m/cmd/z2m-driver/main_test.go` + +- [ ] **Step 1: Replace `subscribeBridgeTopics` and add handlers in `main.go`** + +Delete the empty `subscribeBridgeTopics` method from Task 11 and append the following to `main.go` (placement: after `newStateCache` and before `app`'s definition, or at the bottom — Go doesn't care). Keep the existing imports; add nothing new — every package referenced is already imported. + +```go +// subscribeBridgeTopics installs the four bridge-level subscriptions +// that drive reconciliation, plus a dummy retained-payload handler +// for bridge/event (logged only). +func (a *app) subscribeBridgeTopics() { + _ = a.mq.Subscribe(z2m.BridgeDevices(a.cfg.BaseTopic), a.onBridgeDevices) + _ = a.mq.Subscribe(z2m.BridgeState(a.cfg.BaseTopic), a.onBridgeState) + _ = a.mq.Subscribe(z2m.BridgeEvent(a.cfg.BaseTopic), a.onBridgeEvent) +} + +// onBridgeDevices is the reconciliation entry point. Z2M republishes +// the full device list (retained) on every network change, so this is +// the single source of truth for AddEntity/UnregisterEntity/UpdateAttrs. +func (a *app) onBridgeDevices(_ string, payload []byte) { + var devices []z2m.Device + if err := json.Unmarshal(payload, &devices); err != nil { + // Z2M's republish is idempotent and the next valid payload + // heals; do not wipe registry on parse error. + slog.Error("bridge/devices parse failed; skipping reconciliation cycle", + "error", err, "bytes", len(payload)) + return + } + + a.cache.mu.Lock() + prev := make([]z2m.Device, 0, len(a.cache.devices)) + for _, d := range a.cache.devices { + prev = append(prev, d) + } + a.cache.mu.Unlock() + + actions := state.Reconcile(prev, devices) + + for _, action := range actions { + switch act := action.(type) { + case state.AddEntity: + a.applyAdd(act) + case state.UnregisterEntity: + a.applyRemove(act) + case state.UpdateAttrs: + a.applyUpdate(act) + } + } + + a.cache.mu.Lock() + a.cache.devices = make(map[string]z2m.Device, len(devices)) + for _, d := range devices { + a.cache.devices[d.IEEEAddress] = d + } + a.cache.mu.Unlock() +} + +func (a *app) applyAdd(act state.AddEntity) { + if err := a.d.AddEntity(act.EntityID, act.Spec); err != nil { + if errors.Is(err, driver.ErrEntityAlreadyRegistered) { + return // race-safe: bridge/devices replays produce duplicates + } + slog.Error("AddEntity failed", "entity_id", act.EntityID, "error", err) + return + } + + topics := z2m.DeviceTopics(a.cfg.BaseTopic, act.FriendlyName) + + a.cache.mu.Lock() + a.cache.friendlyByEnt[act.EntityID] = act.FriendlyName + a.cache.ieeeByEnt[act.EntityID] = act.IEEE + if act.Spec.InitialState != nil { + a.cache.entities[act.EntityID] = act.Spec.InitialState + } + a.cache.entityByTopic[topics.State] = append( + a.cache.entityByTopic[topics.State], + entityListener{EntityID: act.EntityID, Property: act.Property}, + ) + a.cache.mu.Unlock() + + // Capability handlers — only lights have any. + if act.Spec.EntityType == "light" { + ent := act.EntityID + friendly := act.FriendlyName + for _, capName := range act.Spec.Capabilities { + cap := capName + a.d.OnCapability(ent, cap, func(_ context.Context, _ string, args map[string]string) (*entityv1.Attributes, error) { + payload, err := state.CommandToPayload(cap, args) + if err != nil { + return nil, err + } + setTopic := z2m.DeviceTopics(a.cfg.BaseTopic, friendly).Set + if err := a.mq.Publish(setTopic, payload, false); err != nil { + return nil, fmt.Errorf("publish to %s: %w", setTopic, err) + } + // Don't update state here — the echo on the state topic + // arrives within ~100ms and goes through MergeState. + return nil, nil + }) + } + } + + // Subscribe ahead of any retained-state delivery so we don't race. + _ = a.mq.Subscribe(topics.State, a.onDeviceState) + _ = a.mq.Subscribe(topics.Availability, a.onDeviceAvailability) +} + +func (a *app) applyRemove(act state.UnregisterEntity) { + topics := z2m.DeviceTopics(a.cfg.BaseTopic, act.FriendlyName) + + a.cache.mu.Lock() + delete(a.cache.entities, act.EntityID) + delete(a.cache.friendlyByEnt, act.EntityID) + delete(a.cache.ieeeByEnt, act.EntityID) + listeners := a.cache.entityByTopic[topics.State] + pruned := listeners[:0] + for _, l := range listeners { + if l.EntityID != act.EntityID { + pruned = append(pruned, l) + } + } + if len(pruned) == 0 { + delete(a.cache.entityByTopic, topics.State) + _ = a.mq.Unsubscribe(topics.State) + _ = a.mq.Unsubscribe(topics.Availability) + } else { + a.cache.entityByTopic[topics.State] = pruned + } + a.cache.mu.Unlock() + + if err := a.d.UnregisterEntity(act.EntityID); err != nil && !errors.Is(err, driver.ErrEntityUnknown) { + slog.Warn("UnregisterEntity failed", "entity_id", act.EntityID, "error", err) + } +} + +func (a *app) applyUpdate(act state.UpdateAttrs) { + a.cache.mu.Lock() + prev := a.cache.entities[act.EntityID] + a.cache.mu.Unlock() + if prev == nil { + return + } + // Re-emit the same attrs; the EntityRegistered event is register-once, + // so renames don't propagate via the event log in v0.1. Documented. + _ = a.d.EmitState(act.EntityID, prev) +} + +// onDeviceState handles a per-device state-push payload by fanning it +// out to every entity that subscribes to the topic. +func (a *app) onDeviceState(topic string, payload []byte) { + var sp z2m.StatePayload + if err := json.Unmarshal(payload, &sp); err != nil { + slog.Warn("device state parse failed", "topic", topic, "error", err) + return + } + + a.cache.mu.Lock() + listeners := append([]entityListener(nil), a.cache.entityByTopic[topic]...) + a.cache.mu.Unlock() + + for _, l := range listeners { + a.applyStateUpdate(l, sp) + } +} + +func (a *app) applyStateUpdate(l entityListener, sp z2m.StatePayload) { + a.cache.mu.Lock() + prev := a.cache.entities[l.EntityID] + a.cache.mu.Unlock() + if prev == nil { + return + } + + next := prev + if l.Property == "" { + // Light: iterate all known properties in the payload. + for prop, raw := range sp { + n, err := state.MergeState(next, prop, raw) + if err != nil { + slog.Debug("MergeState skipped", "entity_id", l.EntityID, "property", prop, "error", err) + continue + } + next = n + } + } else { + raw, ok := sp[l.Property] + if !ok { + return // property not in this payload; ignore + } + n, err := state.MergeState(next, l.Property, raw) + if err != nil { + slog.Debug("MergeState failed", "entity_id", l.EntityID, "property", l.Property, "error", err) + return + } + next = n + } + if next == prev { + return // no change + } + + a.cache.mu.Lock() + a.cache.entities[l.EntityID] = next + a.cache.mu.Unlock() + if err := a.d.EmitState(l.EntityID, next); err != nil && !errors.Is(err, driver.ErrNotConnected) { + slog.Warn("EmitState failed", "entity_id", l.EntityID, "error", err) + } +} + +// onDeviceAvailability sets Available=true/false on every entity +// downstream of the device's state topic. +func (a *app) onDeviceAvailability(topic string, payload []byte) { + // Topic is ...//availability — strip the suffix to get the state topic. + stateTopic := strings.TrimSuffix(topic, "/availability") + + var av z2m.AvailabilityState + available := true + // Z2M can publish either {"state":"online"} or the bare string "online". + if err := json.Unmarshal(payload, &av); err == nil && av.State != "" { + available = av.State == "online" + } else { + s := strings.Trim(string(payload), `" `) + available = s == "online" + } + + a.cache.mu.Lock() + listeners := append([]entityListener(nil), a.cache.entityByTopic[stateTopic]...) + a.cache.mu.Unlock() + + for _, l := range listeners { + a.cache.mu.Lock() + prev := a.cache.entities[l.EntityID] + a.cache.mu.Unlock() + if prev == nil { + continue + } + next := proto.Clone(prev).(*entityv1.Attributes) + next.Available = available + a.cache.mu.Lock() + a.cache.entities[l.EntityID] = next + a.cache.mu.Unlock() + _ = a.d.EmitState(l.EntityID, next) + } +} + +// onBridgeState marks every entity unavailable when the Z2M bridge +// itself goes offline. On return-to-online the next bridge/devices +// retained replay will restore correct per-entity availability. +func (a *app) onBridgeState(_ string, payload []byte) { + var bs z2m.BridgeState + if err := json.Unmarshal(payload, &bs); err != nil { + // Tolerate bare-string variant. + bs.State = strings.Trim(string(payload), `" `) + } + if bs.State == "online" { + _ = a.d.EmitDriverEvent("bridge_online", "") + return + } + _ = a.d.EmitDriverEvent("bridge_offline", "") + + a.cache.mu.Lock() + ids := make([]string, 0, len(a.cache.entities)) + for id := range a.cache.entities { + ids = append(ids, id) + } + a.cache.mu.Unlock() + + for _, id := range ids { + a.cache.mu.Lock() + prev := a.cache.entities[id] + a.cache.mu.Unlock() + if prev == nil { + continue + } + next := proto.Clone(prev).(*entityv1.Attributes) + next.Available = false + a.cache.mu.Lock() + a.cache.entities[id] = next + a.cache.mu.Unlock() + _ = a.d.EmitState(id, next) + } +} + +// onBridgeEvent is informational — pairing / removal lifecycle is +// already covered by the bridge/devices retained payload. +func (a *app) onBridgeEvent(_ string, payload []byte) { + slog.Debug("bridge/event", "payload", string(payload)) +} +``` + +Add the new imports at the top of `main.go`: + +```go +import ( + "context" + "crypto/rand" + "encoding/hex" + "encoding/json" // NEW + "errors" + "fmt" + "log/slog" + "os" + "os/signal" + "strconv" + "strings" // NEW + "sync" + "syscall" + + "google.golang.org/protobuf/proto" // NEW + + "github.com/fdatoo/gohome-driverkit/driver" + entityv1 "github.com/fdatoo/gohome/gen/gohome/entity/v1" + + "github.com/fdatoo/gohome/drivers/z2m/internal/mqtt" + "github.com/fdatoo/gohome/drivers/z2m/internal/state" + "github.com/fdatoo/gohome/drivers/z2m/internal/z2m" +) +``` + +- [ ] **Step 2: Build** + +```bash +go build ./drivers/z2m/cmd/z2m-driver +``` + +Expected: silent success. + +- [ ] **Step 3: Write `main_test.go`** + +`drivers/z2m/cmd/z2m-driver/main_test.go`: + +```go +package main + +import ( + "context" + "encoding/json" + "net" + "os" + "path/filepath" + "sync" + "sync/atomic" + "testing" + "time" + + mqttserver "github.com/mochi-mqtt/server/v2" + "github.com/mochi-mqtt/server/v2/hooks/auth" + mqttlisteners "github.com/mochi-mqtt/server/v2/listeners" + "github.com/mochi-mqtt/server/v2/packets" + + "github.com/fdatoo/gohome-driverkit/driver" + "github.com/fdatoo/gohome-driverkit/drivertest" + + "github.com/fdatoo/gohome/drivers/z2m/internal/mqtt" +) + +const baseTopic = "zigbee2mqtt" + +// startBroker brings up an in-process MQTT broker and returns +// (addr, server) so the test can publish from the broker side. +func startBroker(t *testing.T) (string, *mqttserver.Server) { + t.Helper() + server := mqttserver.New(nil) + if err := server.AddHook(new(auth.AllowHook), nil); err != nil { + t.Fatalf("AddHook: %v", err) + } + l, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatalf("listen: %v", err) + } + addr := l.Addr().String() + _ = l.Close() + tcp := mqttlisteners.NewTCP(mqttlisteners.Config{ID: "t1", Address: addr}) + if err := server.AddListener(tcp); err != nil { + t.Fatalf("AddListener: %v", err) + } + go func() { _ = server.Serve() }() + t.Cleanup(func() { _ = server.Close() }) + time.Sleep(50 * time.Millisecond) + return addr, server +} + +// publish helper: from-broker direction (simulates Z2M). +func publish(t *testing.T, server *mqttserver.Server, topic string, payload []byte, retained bool) { + t.Helper() + if err := server.Publish(topic, payload, retained, 0); err != nil { + t.Fatalf("server.Publish %s: %v", topic, err) + } +} + +// buildTestApp wires up an *app pointing at the test broker and a +// stand-in driverkit Driver. Returns the app, the driver, and a +// drivertest harness already connected. +func buildTestApp(t *testing.T, brokerAddr string) (*app, *driver.Driver, *drivertest.Harness) { + t.Helper() + mq, err := mqtt.New(mqtt.Config{ + BrokerURL: "tcp://" + brokerAddr, + ClientID: "test-driver", + }) + if err != nil { + t.Fatalf("mqtt.New: %v", err) + } + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + if err := mq.Connect(ctx); err != nil { + t.Fatalf("mq.Connect: %v", err) + } + t.Cleanup(mq.Close) + + d := driver.New(driverName, driverVersion) + a := &app{ + cfg: config{BaseTopic: baseTopic}, + mq: mq, + d: d, + cache: newStateCache(), + } + a.subscribeBridgeTopics() + + h := drivertest.New(t, d) + t.Cleanup(h.Close) + return a, d, h +} + +func loadFixturePayload(t *testing.T, name string) []byte { + t.Helper() + raw, err := os.ReadFile(filepath.Join("..", "..", "internal", "z2m", "testdata", name)) + if err != nil { + t.Fatalf("read %s: %v", name, err) + } + return raw +} + +// suppressUnused stops the linter complaining about unused imports +// when individual tests don't reach for every helper. +var _ = packets.Properties{} +var _ atomic.Bool +var _ sync.Mutex + +// ---- tests ---- + +func TestZ2M_InitialReconcile(t *testing.T) { + addr, server := startBroker(t) + a, _, h := buildTestApp(t, addr) + _ = a + + // Publish the fixture as retained on bridge/devices. + publish(t, server, baseTopic+"/bridge/devices", + loadFixturePayload(t, "bridge_devices.json"), true) + + // drivertest's harness only sees entities at handshake time. Open + // a fresh harness AFTER reconciliation has run so we observe the + // post-add state. + deadline := time.Now().Add(2 * time.Second) + for time.Now().Before(deadline) { + a.cache.mu.Lock() + n := len(a.cache.entities) + a.cache.mu.Unlock() + if n >= 8 { // 1 light + 4 motion + 2 contact + 1 plug-power + break + } + time.Sleep(20 * time.Millisecond) + } + a.cache.mu.Lock() + got := len(a.cache.entities) + a.cache.mu.Unlock() + if got != 8 { + t.Errorf("entity count after initial reconcile: got %d, want 8", got) + } + _ = h +} + +func TestZ2M_TurnOnRoundtrip(t *testing.T) { + addr, server := startBroker(t) + a, _, _ := buildTestApp(t, addr) + + // Capture publishes from the driver to the broker — Z2M-side observer. + pub := make(chan struct{ topic string; payload []byte }, 4) + if err := server.AddHook(&capturingHook{out: pub}, nil); err != nil { + t.Fatalf("AddHook: %v", err) + } + + publish(t, server, baseTopic+"/bridge/devices", + loadFixturePayload(t, "bridge_devices.json"), true) + // Wait for entities to register. + waitFor(t, func() bool { + a.cache.mu.Lock() + _, ok := a.cache.friendlyByEnt["light.z2m_01234abc"] + a.cache.mu.Unlock() + return ok + }) + + // Reconnect drivertest so it sees the registered entity. + h := drivertest.New(t, a.d) + defer h.Close() + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + res, err := h.SendCommand(ctx, "light.z2m_01234abc", "turn_on", nil) + if err != nil { + t.Fatalf("SendCommand: %v", err) + } + if !res.GetOk() { + t.Errorf("turn_on returned ok=false: %s", res.GetErrorMessage()) + } + + // Drain observed publishes; expect one /set publish containing state:ON. + deadline := time.Now().Add(2 * time.Second) + var seenSet bool + for time.Now().Before(deadline) && !seenSet { + select { + case p := <-pub: + if p.topic == baseTopic+"/kitchen_light/set" { + var decoded map[string]any + _ = json.Unmarshal(p.payload, &decoded) + if decoded["state"] == "ON" { + seenSet = true + } + } + case <-time.After(50 * time.Millisecond): + } + } + if !seenSet { + t.Error("did not observe /set publish with state:ON") + } +} + +func TestZ2M_HotAddRemove(t *testing.T) { + addr, server := startBroker(t) + a, _, _ := buildTestApp(t, addr) + + // Publish a single-device list. + one := []byte(`[{"ieee_address":"0x00158d0001234abc","friendly_name":"kitchen_light","type":"Router","definition":{"vendor":"","model":"","description":"","exposes":[]}}]`) + publish(t, server, baseTopic+"/bridge/devices", one, true) + waitFor(t, func() bool { + a.cache.mu.Lock() + defer a.cache.mu.Unlock() + return len(a.cache.devices) == 1 + }) + + // Republish with a different device — old should disappear, new should appear. + two := []byte(`[{"ieee_address":"0x00158d0009876543","friendly_name":"hallway_motion","type":"EndDevice","definition":{"vendor":"","model":"","description":"","exposes":[{"type":"binary","name":"occupancy","property":"occupancy","access":1}]}}]`) + publish(t, server, baseTopic+"/bridge/devices", two, true) + waitFor(t, func() bool { + a.cache.mu.Lock() + defer a.cache.mu.Unlock() + _, oldGone := a.cache.devices["0x00158d0001234abc"] + _, newPresent := a.cache.devices["0x00158d0009876543"] + return !oldGone && newPresent + }) +} + +func TestZ2M_BridgeOfflineMarksEntitiesUnavailable(t *testing.T) { + addr, server := startBroker(t) + a, _, _ := buildTestApp(t, addr) + + publish(t, server, baseTopic+"/bridge/devices", + loadFixturePayload(t, "bridge_devices.json"), true) + waitFor(t, func() bool { + a.cache.mu.Lock() + defer a.cache.mu.Unlock() + return len(a.cache.entities) >= 8 + }) + + publish(t, server, baseTopic+"/bridge/state", []byte(`{"state":"offline"}`), true) + waitFor(t, func() bool { + a.cache.mu.Lock() + defer a.cache.mu.Unlock() + for _, attrs := range a.cache.entities { + if attrs.GetAvailable() { + return false + } + } + return true + }) +} + +// ---- helpers ---- + +// capturingHook is a mochi hook that records every PUBLISH the driver +// sends to the broker (i.e. every /set publish in the integration tests). +type capturingHook struct { + mqttserver.HookBase + out chan<- struct { + topic string + payload []byte + } +} + +func (h *capturingHook) ID() string { return "capturing" } +func (h *capturingHook) Provides(b byte) bool { return b == mqttserver.OnPublished } +func (h *capturingHook) OnPublished(_ *mqttserver.Client, pk packets.Packet) { + select { + case h.out <- struct { + topic string + payload []byte + }{topic: pk.TopicName, payload: append([]byte(nil), pk.Payload...)}: + default: + } +} + +func waitFor(t *testing.T, cond func() bool) { + t.Helper() + deadline := time.Now().Add(2 * time.Second) + for time.Now().Before(deadline) { + if cond() { + return + } + time.Sleep(20 * time.Millisecond) + } + t.Fatal("waitFor: condition never became true") +} +``` + +> **Note for the executor:** mochi-mqtt's hook API has shifted across minor versions. The exact hook interface (`Provides` constants, `OnPublished` vs `OnPublish` signature, `packets.Packet` field names) may need a small adjustment to compile against whatever version `go mod tidy` resolved. The plan's logical assertions (subscribe → publish → observe) are version-stable; only the hook plumbing surface needs verification. Run `go doc github.com/mochi-mqtt/server/v2 Hook` and adapt the hook to match. If a clean fit isn't obvious in 5 minutes, fall back to capturing publishes by subscribing a separate paho client to `/+/set` from the test side and reading payloads off its handler (covers the same observability with a smaller surface). + +- [ ] **Step 4: Run tests; iterate on any mochi-API friction** + +```bash +go test ./drivers/z2m/cmd/z2m-driver/... -v -timeout 60s +``` + +Expected: tests pass. If a mochi API mismatch surfaces, fix per the note above and re-run. + +- [ ] **Step 5: Run the full suite to check for regressions** + +```bash +go test ./... +``` + +Expected: every test in the workspace passes. + +- [ ] **Step 6: Commit** + +```bash +git add drivers/z2m/cmd/z2m-driver/ +git commit -m "$(cat <<'EOF' +feat(z2m): bridge handlers + integration tests + +Wires up the four bridge subscriptions (devices, state, event, +plus per-device state/availability) into the Reconcile output: +AddEntity registers entity + capability handlers + subscribes +state/availability; UnregisterEntity unsubscribes + drops the +entity; UpdateAttrs re-emits last attrs with the new label. + +State-topic payloads fan out to every entity that listens on +the topic (a multi-property device hands the same payload to +several entities, each consuming its own property). + +Integration-tested against an in-process mochi-mqtt broker: +initial reconciliation, turn_on round-trip via /set, hot +add/remove on bridge/devices republish, bridge-offline +propagation to per-entity Available=false. + +Co-Authored-By: Claude Opus 4.7 (1M context) +EOF +)" +``` + +--- + +## Task 13: README + catalog page update + +**Goal:** Document the driver. Mirror the structure of `drivers/hue/README.md` (quick start + caveats); update the existing first-party catalog stub to match the actual implementation. + +**Files:** +- Create: `drivers/z2m/README.md` +- Modify: `docs/docs/drivers/first-party.md` (the existing `### Zigbee2MQTT (`driver.zigbee2mqtt`)` block) + +- [ ] **Step 1: Write `drivers/z2m/README.md`** + +```markdown +# driver-z2m + +GoHome Carport driver for [Zigbee2MQTT](https://www.zigbee2mqtt.io/). + +- One driver instance = one Z2M deployment (= one MQTT broker hosting it). +- Discovers all paired devices automatically; hot add/remove is live. +- Surfaces three device classes in v0.1: lights, numeric sensors, binary sensors. + +## Quick start + +### 1. Reach your Z2M's MQTT broker + +The driver does not talk to Z2M directly — it talks to whatever MQTT broker Z2M publishes to (Mosquitto, EMQX, NanoMQ, etc.). You need: + +- Broker URL (`tcp://host:1883` or `ssl://host:8883`). +- Optional username/password. +- The base topic Z2M is configured with — almost always `zigbee2mqtt`, configurable in Z2M's `configuration.yaml` under `mqtt.base_topic`. + +### 2. Configure the driver + +The driver reads the following environment variables, set by `gohomed`: + +| Variable | Required | Default | Purpose | +|---|---|---|---| +| `Z2M_BROKER_URL` | yes | — | `tcp://host:1883` or `ssl://host:8883` | +| `Z2M_USERNAME` | no | — | MQTT broker username | +| `Z2M_PASSWORD` | no | — | MQTT broker password | +| `Z2M_BASE_TOPIC` | no | `zigbee2mqtt` | Z2M's `mqtt.base_topic` setting | +| `Z2M_CLIENT_ID` | no | `gohome-z2m-` | MQTT client identifier | +| `Z2M_TLS_SKIP_VERIFY` | no | `false` | Skip TLS verification (self-signed brokers) | + +## What gets surfaced + +- **Lights** become a single `light.*` entity per device, with `turn_on`, `turn_off`, `set_brightness` (if dimmable), `set_color_temp` (if tunable-white), and `set_color` (if RGB-capable). +- **Numeric properties** (`temperature`, `humidity`, `illuminance`, `battery`, `pressure`, `power`, `energy`, `current`) become read-only `numeric_sensor.*` entities. +- **Binary properties** (`occupancy`, `contact`, `water_leak`, `smoke`, `tamper`, `vibration`) become read-only `binary_sensor.*` entities. +- **Multi-property devices fan out**: a motion sensor exposing occupancy + temperature + humidity + battery yields four entities. + +## Out of scope (v0.1) + +- Z2M network management (pairing, removal, OTA updates, name changes from gohome). Use the Z2M dashboard or its own MQTT API directly. +- Action sensors (`action: "single"`, etc.) — these are events, not state. +- Climate, cover, lock, fan device classes (no proto support yet). +- Switch / smart-plug actuators (writable `state` properties). Smart plugs that also expose `power`/`energy` will surface those read-only entities; the writable `state` is logged once at INFO and skipped. + +## Known caveats + +- New devices paired in Z2M show up automatically — no driver restart needed. +- If Z2M is configured to publish state non-retained (recent default), entity state stays at the mapper-assigned defaults until the device's next state change. Toggling the device once seeds it; subsequent state is live. +- Per-device `availability` requires Z2M's availability feature to be enabled server-side. Without it, entities default to `Available=true` and can drift if a battery device dies — Z2M's `bridge/devices` topic doesn't carry liveness on its own. +- A successful publish to `//set` is reported as `ok=true`. If Z2M silently ignores the command (invalid friendly_name, device unreachable, etc.), gohome won't know — there's no MQTT 5 request/response in v0.1. +- A device that adds a property after pairing (firmware update) is not picked up until the driver restarts. + +## Source + +[`drivers/z2m/`](.) in the gohome monorepo. +``` + +- [ ] **Step 2: Update `docs/docs/drivers/first-party.md`** + +Replace the existing `### Zigbee2MQTT (`driver.zigbee2mqtt`)` block (and its config table + caveats list) with a version that matches the actual implementation. Find the block starting at the `### Zigbee2MQTT` heading and ending at the closing `[Source repo](...)` line, and replace it with: + +```markdown +### Zigbee2MQTT (`driver.z2m`) + +!!! status-alpha "Alpha — shipped, interface evolving" + +Mirrors a [Zigbee2MQTT](https://www.zigbee2mqtt.io/) deployment into gohome over the MQTT broker that Z2M publishes to. Discovers all paired devices on startup, then reconciles live via the retained `bridge/devices` topic. v0.1 surfaces three device classes: lights (`light.*`), numeric sensors (`numeric_sensor.*`), and binary sensors (`binary_sensor.*`). + +**Config fields (env)** + +| Variable | Required | Default | Purpose | +|---|---|---|---| +| `Z2M_BROKER_URL` | yes | — | `tcp://host:1883` or `ssl://host:8883` | +| `Z2M_USERNAME` | no | — | MQTT broker username | +| `Z2M_PASSWORD` | no | — | MQTT broker password | +| `Z2M_BASE_TOPIC` | no | `zigbee2mqtt` | Z2M's `mqtt.base_topic` setting | +| `Z2M_CLIENT_ID` | no | `gohome-z2m-` | MQTT client identifier | +| `Z2M_TLS_SKIP_VERIFY` | no | `false` | Skip TLS verification | + +**Known caveats** + +- New devices paired in Z2M are picked up automatically; no driver restart needed. +- Smart-plug actuators (writable `state`) are out of scope in v0.1 — read-only sub-properties (`power`, `energy`) still surface. +- Per-device availability depends on Z2M's availability feature being enabled server-side; otherwise entities default to `Available=true`. +- `/set` publishes are best-effort: a successful publish is reported as `ok=true` even if Z2M silently ignores the command (no MQTT 5 request/response in v0.1). + +[Source repo](https://github.com/fdatoo/gohome/tree/main/drivers/z2m) +``` + +- [ ] **Step 3: Build + test (sanity)** + +```bash +go build ./... +go test ./... +``` + +Expected: all green. + +- [ ] **Step 4: Commit** + +```bash +git add drivers/z2m/README.md docs/docs/drivers/first-party.md +git commit -m "$(cat <<'EOF' +docs(z2m): driver README and first-party catalog entry + +Adds drivers/z2m/README.md with quick-start + caveats and +updates the existing zigbee2mqtt entry in +docs/docs/drivers/first-party.md to reflect the actual driver +(driver.z2m, env-var config, three device classes, known +limitations). + +Co-Authored-By: Claude Opus 4.7 (1M context) +EOF +)" +``` + +--- + +## Self-Review Checklist + +Run through this before declaring the plan ready for execution. + +- **Spec coverage:** + - [x] colorconv extraction — Tasks 1-2 + - [x] `internal/mqtt.Client` — Task 10 + - [x] `internal/z2m` topics + payload model — Task 4 + - [x] `internal/state.EntitiesFor` (light/numeric/binary, blocklist, writable-skip with INFO) — Task 6 + - [x] `internal/state.MergeState` — Task 8 + - [x] `internal/state.Reconcile` + Action types (Add/Unregister/UpdateAttrs) — Task 9 + - [x] `internal/state.CommandToPayload` — Task 7 + - [x] `internal/state.EntityID` — Task 5 + - [x] `cmd/z2m-driver/main.go` — Tasks 11-12 + - [x] Configuration env vars — Task 11 + - [x] Data flow: startup, command, state push, hot add/remove, availability, bridge state, MQTT disconnect — Task 12 + - [x] Error handling: missing broker URL, unparseable bridge/devices, unknown property type, unknown property in payload, missing availability — Tasks 6/8/11/12 + - [x] Testing: state unit tests + integration tests with mochi broker — Tasks 5-9, 12 + - [x] README + catalog — Task 13 + +- **Type consistency:** + - `EntityResult.Property` (string, "" for lights) is used in Tasks 6, 9, 11, 12 with the same meaning. + - `state.AddEntity` carries `IEEE`, `FriendlyName`, `Property`, `EntityID`, `Spec` — consumed identically in Task 12. + - `entityListener{EntityID, Property}` in `stateCache.entityByTopic` is established in Task 11 and consumed in Task 12. + - `colorconv.XY` / `colorconv.Gamut` introduced in Task 1, consumed in Tasks 2 (Hue) and 8 (Z2M MergeState color). + +- **No placeholders:** Every step has either a complete code block, a complete command, or a precise edit description. No "TODO" / "fill in" / "similar to above". + +--- + +## Execution + +This plan is intended for execution via **superpowers:subagent-driven-development**: dispatch a fresh subagent per task, two-stage review (spec then code quality) between tasks, all in the same session. Stay on the existing `feat/zigbee2mqtt` branch (the design header's `feat/z2m-driver` is aspirational; the work-in-progress branch is what's checked out). From c69d60cef946f76928f8a0042140fda7e3e9d6ce Mon Sep 17 00:00:00 2001 From: Fynn Datoo Date: Fri, 1 May 2026 00:15:21 -0700 Subject: [PATCH 02/16] feat(driverkit): add colorconv package MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New shared package gohome-driverkit/colorconv/ holds CIE-xy ↔ RGB, HSV ↔ RGB, gamut clamping, and packed RGB helpers. Extracted from drivers/hue/internal/bridge/colormath.go to be consumed by Hue and Z2M drivers (and third-party drivers). Hue still owns its own copy in this commit; Task 2 migrates it. Co-Authored-By: Claude Opus 4.7 (1M context) --- gohome-driverkit/colorconv/colorconv.go | 237 +++++++++++++++++++ gohome-driverkit/colorconv/colorconv_test.go | 149 ++++++++++++ 2 files changed, 386 insertions(+) create mode 100644 gohome-driverkit/colorconv/colorconv.go create mode 100644 gohome-driverkit/colorconv/colorconv_test.go diff --git a/gohome-driverkit/colorconv/colorconv.go b/gohome-driverkit/colorconv/colorconv.go new file mode 100644 index 0000000..c6f3c1a --- /dev/null +++ b/gohome-driverkit/colorconv/colorconv.go @@ -0,0 +1,237 @@ +// Package colorconv provides pure colour-space conversions for driver +// authors: CIE 1931 xy ↔ sRGB, HSV ↔ sRGB, gamut clamping, and packed +// RGB helpers. No I/O, no allocations beyond return values, no logging. +// +// Both first-party drivers (Hue, Z2M) consume this package; third-party +// drivers are encouraged to as well. +package colorconv + +import "math" + +// XY is a CIE 1931 chromaticity point. Both dimensions are 0..1. +// JSON-tagged so drivers can decode wire payloads directly. +type XY struct { + X float64 `json:"x"` + Y float64 `json:"y"` +} + +// Gamut is the triangle of representable colours for one bulb. Drivers +// without per-bulb gamut info pass the zero value, which disables +// clamping. +type Gamut struct { + Red XY `json:"red"` + Green XY `json:"green"` + Blue XY `json:"blue"` +} + +// PackRGB packs three bytes into a 0xRRGGBB uint32. +func PackRGB(r, g, b uint8) uint32 { + return uint32(r)<<16 | uint32(g)<<8 | uint32(b) +} + +// UnpackRGB unpacks a 0xRRGGBB uint32 into three bytes. +func UnpackRGB(packed uint32) (uint8, uint8, uint8) { + return uint8(packed >> 16), uint8(packed >> 8), uint8(packed) +} + +// RGBToXY converts an 8-bit-per-channel sRGB triple to a CIE 1931 xy +// chromaticity point. Standard sRGB → linear → CIE conversion with the +// D65 white reference. The returned point may fall outside any specific +// bulb's gamut; use ClampToGamut to project it back. +func RGBToXY(r, g, b uint8) XY { + rf := gammaInverse(float64(r) / 255.0) + gf := gammaInverse(float64(g) / 255.0) + bf := gammaInverse(float64(b) / 255.0) + + X := rf*0.4124564 + gf*0.3575761 + bf*0.1804375 + Y := rf*0.2126729 + gf*0.7151522 + bf*0.0721750 + Z := rf*0.0193339 + gf*0.1191920 + bf*0.9503041 + + sum := X + Y + Z + if sum == 0 { + return XY{0, 0} + } + return XY{X: X / sum, Y: Y / sum} +} + +// XYToRGB converts a CIE 1931 xy point back to 8-bit sRGB, clamped to +// [0, 255]. Brightness is normalised so the brightest channel reaches +// 255 — callers control intensity separately via dimming. +func XYToRGB(xy XY) (uint8, uint8, uint8) { + if xy.Y < 1e-9 { + return 0, 0, 0 + } + X := xy.X / xy.Y + Y := 1.0 + Z := (1.0 - xy.X - xy.Y) / xy.Y + + rl := X*3.2404542 + Y*-1.5371385 + Z*-0.4985314 + gl := X*-0.9692660 + Y*1.8760108 + Z*0.0415560 + bl := X*0.0556434 + Y*-0.2040259 + Z*1.0572252 + + maxC := math.Max(rl, math.Max(gl, bl)) + if maxC > 1.0 { + rl, gl, bl = rl/maxC, gl/maxC, bl/maxC + } + return floatToByte(gammaForward(rl)), floatToByte(gammaForward(gl)), floatToByte(gammaForward(bl)) +} + +// ClampToGamut projects xy onto the gamut triangle if outside. +// Inside-or-on returns xy unchanged. A zero Gamut (all corners at 0,0) +// disables clamping — returns xy unchanged. +func ClampToGamut(xy XY, g Gamut) XY { + if g.Red == (XY{}) && g.Green == (XY{}) && g.Blue == (XY{}) { + return xy + } + if pointInTriangle(xy, g.Red, g.Green, g.Blue) { + return xy + } + a := closestOnSegment(xy, g.Red, g.Green) + b := closestOnSegment(xy, g.Green, g.Blue) + c := closestOnSegment(xy, g.Blue, g.Red) + best, bestD := a, distSq(xy, a) + if d := distSq(xy, b); d < bestD { + best, bestD = b, d + } + if d := distSq(xy, c); d < bestD { + best = c + } + return best +} + +// RGBToHSV converts 8-bit sRGB to HSV with hue in [0, 360), saturation +// and value in [0, 1]. Used by drivers whose target accepts {hue, sat} +// natively (some Z2M devices). +func RGBToHSV(r, g, b uint8) (h, s, v float64) { + rf, gf, bf := float64(r)/255.0, float64(g)/255.0, float64(b)/255.0 + maxC := math.Max(rf, math.Max(gf, bf)) + minC := math.Min(rf, math.Min(gf, bf)) + v = maxC + d := maxC - minC + if maxC == 0 { + return 0, 0, 0 + } + s = d / maxC + if d == 0 { + return 0, s, v + } + switch maxC { + case rf: + h = (gf - bf) / d + if gf < bf { + h += 6 + } + case gf: + h = (bf-rf)/d + 2 + case bf: + h = (rf-gf)/d + 4 + } + h *= 60 + return h, s, v +} + +// HSVToRGB converts HSV (hue [0,360), saturation/value [0,1]) to 8-bit +// sRGB. Inputs outside their domains are clamped. +func HSVToRGB(h, s, v float64) (uint8, uint8, uint8) { + if s < 0 { + s = 0 + } + if s > 1 { + s = 1 + } + if v < 0 { + v = 0 + } + if v > 1 { + v = 1 + } + h = math.Mod(h, 360) + if h < 0 { + h += 360 + } + c := v * s + x := c * (1 - math.Abs(math.Mod(h/60, 2)-1)) + m := v - c + var rf, gf, bf float64 + switch { + case h < 60: + rf, gf, bf = c, x, 0 + case h < 120: + rf, gf, bf = x, c, 0 + case h < 180: + rf, gf, bf = 0, c, x + case h < 240: + rf, gf, bf = 0, x, c + case h < 300: + rf, gf, bf = x, 0, c + default: + rf, gf, bf = c, 0, x + } + return floatToByte(rf + m), floatToByte(gf + m), floatToByte(bf + m) +} + +// --- internal helpers --- + +func gammaInverse(c float64) float64 { + if c > 0.04045 { + return math.Pow((c+0.055)/1.055, 2.4) + } + return c / 12.92 +} + +func gammaForward(c float64) float64 { + if c <= 0 { + return 0 + } + if c <= 0.0031308 { + return 12.92 * c + } + return 1.055*math.Pow(c, 1.0/2.4) - 0.055 +} + +func floatToByte(f float64) uint8 { + if f < 0 { + return 0 + } + if f > 1 { + return 255 + } + return uint8(math.Round(f * 255)) +} + +func crossSign(a, b, c XY) float64 { + return (a.X-c.X)*(b.Y-c.Y) - (b.X-c.X)*(a.Y-c.Y) +} + +func pointInTriangle(p, a, b, c XY) bool { + const eps = 1e-9 + d1 := crossSign(p, a, b) + d2 := crossSign(p, b, c) + d3 := crossSign(p, c, a) + hasNeg := d1 < -eps || d2 < -eps || d3 < -eps + hasPos := d1 > eps || d2 > eps || d3 > eps + return !hasNeg || !hasPos +} + +func closestOnSegment(p, a, b XY) XY { + dx := b.X - a.X + dy := b.Y - a.Y + denom := dx*dx + dy*dy + if denom == 0 { + return a + } + t := ((p.X-a.X)*dx + (p.Y-a.Y)*dy) / denom + switch { + case t < 0: + return a + case t > 1: + return b + } + return XY{a.X + t*dx, a.Y + t*dy} +} + +func distSq(a, b XY) float64 { + dx := a.X - b.X + dy := a.Y - b.Y + return dx*dx + dy*dy +} diff --git a/gohome-driverkit/colorconv/colorconv_test.go b/gohome-driverkit/colorconv/colorconv_test.go new file mode 100644 index 0000000..6d8acd0 --- /dev/null +++ b/gohome-driverkit/colorconv/colorconv_test.go @@ -0,0 +1,149 @@ +package colorconv + +import ( + "math" + "testing" +) + +func TestPackUnpackRGB(t *testing.T) { + for _, tc := range []struct { + r, g, b uint8 + packed uint32 + }{ + {0, 0, 0, 0x000000}, + {255, 255, 255, 0xFFFFFF}, + {0xFF, 0x88, 0x00, 0xFF8800}, + {0x12, 0x34, 0x56, 0x123456}, + } { + got := PackRGB(tc.r, tc.g, tc.b) + if got != tc.packed { + t.Errorf("PackRGB(%d,%d,%d) = %#x, want %#x", tc.r, tc.g, tc.b, got, tc.packed) + } + r, g, b := UnpackRGB(tc.packed) + if r != tc.r || g != tc.g || b != tc.b { + t.Errorf("UnpackRGB(%#x) = (%d,%d,%d), want (%d,%d,%d)", tc.packed, r, g, b, tc.r, tc.g, tc.b) + } + } +} + +func TestRGBXYRoundTrip(t *testing.T) { + // Round-tripping primary colours must stay close. The conversion is + // lossy because XY drops luminance, but pure primaries should + // reconstruct to within 8 LSB after re-normalisation. + for _, tc := range []struct { + name string + r, g, b uint8 + }{ + {"red", 255, 0, 0}, + {"green", 0, 255, 0}, + {"blue", 0, 0, 255}, + {"orange", 0xFF, 0x88, 0}, + } { + t.Run(tc.name, func(t *testing.T) { + xy := RGBToXY(tc.r, tc.g, tc.b) + r2, g2, b2 := XYToRGB(xy) + if absDiff(r2, tc.r) > 8 || absDiff(g2, tc.g) > 8 || absDiff(b2, tc.b) > 16 { + t.Errorf("round-trip %s: got (%d,%d,%d), want (%d,%d,%d)", tc.name, r2, g2, b2, tc.r, tc.g, tc.b) + } + }) + } +} + +func TestRGBToXYBlack(t *testing.T) { + xy := RGBToXY(0, 0, 0) + if xy.X != 0 || xy.Y != 0 { + t.Errorf("black → %v, want zero", xy) + } +} + +func TestClampToGamutInside(t *testing.T) { + g := Gamut{ + Red: XY{0.7, 0.3}, + Green: XY{0.2, 0.7}, + Blue: XY{0.15, 0.05}, + } + p := XY{0.4, 0.4} + got := ClampToGamut(p, g) + if got != p { + t.Errorf("inside point modified: got %v, want %v", got, p) + } +} + +func TestClampToGamutOutside(t *testing.T) { + g := Gamut{ + Red: XY{0.7, 0.3}, + Green: XY{0.2, 0.7}, + Blue: XY{0.15, 0.05}, + } + // Far outside the triangle. + got := ClampToGamut(XY{0.9, 0.9}, g) + if !pointInTriangle(got, g.Red, g.Green, g.Blue) { + t.Errorf("outside point not clamped into triangle: got %v", got) + } +} + +func TestClampToGamutZeroGamut(t *testing.T) { + p := XY{0.9, 0.9} + got := ClampToGamut(p, Gamut{}) + if got != p { + t.Errorf("zero gamut should pass through: got %v, want %v", got, p) + } +} + +func TestHSVRGBRoundTrip(t *testing.T) { + for _, tc := range []struct { + name string + r, g, b uint8 + }{ + {"red", 255, 0, 0}, + {"green", 0, 255, 0}, + {"blue", 0, 0, 255}, + {"yellow", 255, 255, 0}, + {"grey", 128, 128, 128}, + } { + t.Run(tc.name, func(t *testing.T) { + h, s, v := RGBToHSV(tc.r, tc.g, tc.b) + r2, g2, b2 := HSVToRGB(h, s, v) + if absDiff(r2, tc.r) > 1 || absDiff(g2, tc.g) > 1 || absDiff(b2, tc.b) > 1 { + t.Errorf("round-trip %s via HSV: got (%d,%d,%d), want (%d,%d,%d)", tc.name, r2, g2, b2, tc.r, tc.g, tc.b) + } + }) + } +} + +func TestHSVToRGBKnownPoints(t *testing.T) { + cases := []struct { + h, s, v float64 + r, g, b uint8 + }{ + {0, 0, 0, 0, 0, 0}, // black + {0, 0, 1, 255, 255, 255}, // white + {0, 1, 1, 255, 0, 0}, // red + {120, 1, 1, 0, 255, 0}, // green + {240, 1, 1, 0, 0, 255}, // blue + } + for _, tc := range cases { + r, g, b := HSVToRGB(tc.h, tc.s, tc.v) + if r != tc.r || g != tc.g || b != tc.b { + t.Errorf("HSV(%g,%g,%g) → (%d,%d,%d), want (%d,%d,%d)", tc.h, tc.s, tc.v, r, g, b, tc.r, tc.g, tc.b) + } + } +} + +func TestHSVOutOfDomainClamped(t *testing.T) { + r, g, b := HSVToRGB(720, 2, 5) // hue wraps; sat/val clamp to 1 + r2, g2, b2 := HSVToRGB(0, 1, 1) + if r != r2 || g != g2 || b != b2 { + t.Errorf("clamped HSV mismatch: got (%d,%d,%d), want (%d,%d,%d)", r, g, b, r2, g2, b2) + } + // Negative inputs clamp to zero. + r, g, b = HSVToRGB(0, -1, -1) + if r != 0 || g != 0 || b != 0 { + t.Errorf("negative HSV → (%d,%d,%d), want (0,0,0)", r, g, b) + } +} + +func absDiff(a, b uint8) int { + d := int(a) - int(b) + return int(math.Abs(float64(d))) +} From 5899044dcc07c2105fdee714eec38a603e40c9f2 Mon Sep 17 00:00:00 2001 From: Fynn Datoo Date: Fri, 1 May 2026 00:18:33 -0700 Subject: [PATCH 03/16] refactor(hue): consume colorconv package Hue's private color math moves to gohome-driverkit/colorconv (added in the previous commit). bridge.ColorXY and bridge.Gamut become type aliases for backwards compatibility on JSON wire types; the math functions are dropped from the bridge package. This unblocks the Z2M driver from sharing the same pure-math code path without importing from another driver's internal/. Co-Authored-By: Claude Opus 4.7 (1M context) --- drivers/hue/internal/bridge/colormath.go | 144 ------------------ drivers/hue/internal/bridge/colormath_test.go | 120 --------------- drivers/hue/internal/bridge/types.go | 19 +-- drivers/hue/internal/state/mapping.go | 9 +- 4 files changed, 12 insertions(+), 280 deletions(-) delete mode 100644 drivers/hue/internal/bridge/colormath.go delete mode 100644 drivers/hue/internal/bridge/colormath_test.go diff --git a/drivers/hue/internal/bridge/colormath.go b/drivers/hue/internal/bridge/colormath.go deleted file mode 100644 index 65fd25a..0000000 --- a/drivers/hue/internal/bridge/colormath.go +++ /dev/null @@ -1,144 +0,0 @@ -package bridge - -import "math" - -// RGBToXY converts an 8-bit-per-channel sRGB triple to a CIE 1931 xy -// chromaticity point. Uses the standard sRGB → linear → CIE conversion -// with the D65 white reference. The returned point may fall outside -// any specific bulb's gamut; use ClampToGamut to project it back. -func RGBToXY(r, g, b uint8) ColorXY { - rf := gammaInverse(float64(r) / 255.0) - gf := gammaInverse(float64(g) / 255.0) - bf := gammaInverse(float64(b) / 255.0) - - // sRGB → CIE XYZ (D65). - X := rf*0.4124564 + gf*0.3575761 + bf*0.1804375 - Y := rf*0.2126729 + gf*0.7151522 + bf*0.0721750 - Z := rf*0.0193339 + gf*0.1191920 + bf*0.9503041 - - sum := X + Y + Z - if sum == 0 { - return ColorXY{0, 0} - } - return ColorXY{X: X / sum, Y: Y / sum} -} - -// XYToRGB converts a CIE 1931 xy point back to 8-bit sRGB, clamped to -// [0, 255]. Brightness is normalised so the brightest channel reaches -// 255 — callers control intensity separately via dimming. -func XYToRGB(xy ColorXY) (uint8, uint8, uint8) { - if xy.Y < 1e-9 { - return 0, 0, 0 - } - // Reconstruct XYZ at unit luminance. - X := xy.X / xy.Y - Y := 1.0 - Z := (1.0 - xy.X - xy.Y) / xy.Y - - // CIE XYZ → sRGB (D65) inverse matrix. - rl := X*3.2404542 + Y*-1.5371385 + Z*-0.4985314 - gl := X*-0.9692660 + Y*1.8760108 + Z*0.0415560 - bl := X*0.0556434 + Y*-0.2040259 + Z*1.0572252 - - // Normalise so brightest channel hits 1.0. - maxC := math.Max(rl, math.Max(gl, bl)) - if maxC > 1.0 { - rl, gl, bl = rl/maxC, gl/maxC, bl/maxC - } - return floatToByte(gammaForward(rl)), floatToByte(gammaForward(gl)), floatToByte(gammaForward(bl)) -} - -// ClampToGamut projects xy onto the gamut triangle if outside. -// Inside-or-on, returns xy unchanged. -func ClampToGamut(xy ColorXY, g Gamut) ColorXY { - if pointInTriangle(xy, g.Red, g.Green, g.Blue) { - return xy - } - a := closestOnSegment(xy, g.Red, g.Green) - b := closestOnSegment(xy, g.Green, g.Blue) - c := closestOnSegment(xy, g.Blue, g.Red) - best, bestD := a, distSq(xy, a) - if d := distSq(xy, b); d < bestD { - best, bestD = b, d - } - if d := distSq(xy, c); d < bestD { - best = c - } - return best -} - -// PackRGB packs three bytes into a 0xRRGGBB uint32. -func PackRGB(r, g, b uint8) uint32 { - return uint32(r)<<16 | uint32(g)<<8 | uint32(b) -} - -// UnpackRGB unpacks a 0xRRGGBB uint32 into three bytes. -func UnpackRGB(packed uint32) (uint8, uint8, uint8) { - return uint8(packed >> 16), uint8(packed >> 8), uint8(packed) -} - -// --- internal helpers --- - -func gammaInverse(c float64) float64 { - if c > 0.04045 { - return math.Pow((c+0.055)/1.055, 2.4) - } - return c / 12.92 -} - -func gammaForward(c float64) float64 { - if c <= 0 { - return 0 - } - if c <= 0.0031308 { - return 12.92 * c - } - return 1.055*math.Pow(c, 1.0/2.4) - 0.055 -} - -func floatToByte(f float64) uint8 { - if f < 0 { - return 0 - } - if f > 1 { - return 255 - } - return uint8(math.Round(f * 255)) -} - -func crossSign(a, b, c ColorXY) float64 { - return (a.X-c.X)*(b.Y-c.Y) - (b.X-c.X)*(a.Y-c.Y) -} - -func pointInTriangle(p, a, b, c ColorXY) bool { - const eps = 1e-9 - d1 := crossSign(p, a, b) - d2 := crossSign(p, b, c) - d3 := crossSign(p, c, a) - hasNeg := d1 < -eps || d2 < -eps || d3 < -eps - hasPos := d1 > eps || d2 > eps || d3 > eps - return !hasNeg || !hasPos -} - -func closestOnSegment(p, a, b ColorXY) ColorXY { - dx := b.X - a.X - dy := b.Y - a.Y - denom := dx*dx + dy*dy - if denom == 0 { - return a - } - t := ((p.X-a.X)*dx + (p.Y-a.Y)*dy) / denom - switch { - case t < 0: - return a - case t > 1: - return b - } - return ColorXY{a.X + t*dx, a.Y + t*dy} -} - -func distSq(a, b ColorXY) float64 { - dx := a.X - b.X - dy := a.Y - b.Y - return dx*dx + dy*dy -} diff --git a/drivers/hue/internal/bridge/colormath_test.go b/drivers/hue/internal/bridge/colormath_test.go deleted file mode 100644 index 344f6b0..0000000 --- a/drivers/hue/internal/bridge/colormath_test.go +++ /dev/null @@ -1,120 +0,0 @@ -package bridge - -import ( - "math" - "testing" -) - -func nearXY(t *testing.T, label string, got, want ColorXY, tol float64) { - t.Helper() - if math.Abs(got.X-want.X) > tol || math.Abs(got.Y-want.Y) > tol { - t.Errorf("%s: got (%.4f, %.4f), want (%.4f, %.4f) tol=%v", - label, got.X, got.Y, want.X, want.Y, tol) - } -} - -func TestRGBToXY_KnownValues(t *testing.T) { - cases := []struct { - name string - r, g, b uint8 - want ColorXY - }{ - // D65 white point. sRGB(255,255,255) → (0.3127, 0.3290). - {"white", 255, 255, 255, ColorXY{0.3127, 0.3290}}, - // sRGB primaries roughly map to: - {"red", 255, 0, 0, ColorXY{0.6400, 0.3300}}, - {"green", 0, 255, 0, ColorXY{0.3000, 0.6000}}, - {"blue", 0, 0, 255, ColorXY{0.1500, 0.0600}}, - } - for _, tc := range cases { - t.Run(tc.name, func(t *testing.T) { - got := RGBToXY(tc.r, tc.g, tc.b) - nearXY(t, tc.name, got, tc.want, 0.01) - }) - } -} - -func TestXYToRGB_RoundTripsKnownValues(t *testing.T) { - cases := []struct{ r, g, b uint8 }{ - {255, 255, 255}, - {255, 0, 0}, - {0, 255, 0}, - {0, 0, 255}, - {255, 136, 0}, // an arbitrary orange - } - for _, tc := range cases { - xy := RGBToXY(tc.r, tc.g, tc.b) - gr, gg, gb := XYToRGB(xy) - // 2 LSB tolerance; gamma round-trip is lossy. - check := func(name string, got, want uint8) { - d := int(got) - int(want) - if d < -2 || d > 2 { - t.Errorf("round-trip rgb (%d,%d,%d) → xy → (%d,%d,%d): %s drift=%d", - tc.r, tc.g, tc.b, gr, gg, gb, name, d) - } - } - check("r", gr, tc.r) - check("g", gg, tc.g) - check("b", gb, tc.b) - } -} - -func TestClampToGamut_InsideUnchanged(t *testing.T) { - gamutC := Gamut{ - Red: ColorXY{0.6915, 0.3083}, - Green: ColorXY{0.1700, 0.7000}, - Blue: ColorXY{0.1532, 0.0475}, - } - inside := ColorXY{0.3, 0.3} // near white, well inside Gamut C - got := ClampToGamut(inside, gamutC) - nearXY(t, "inside", got, inside, 1e-9) -} - -func TestClampToGamut_OutsideProjects(t *testing.T) { - gamutC := Gamut{ - Red: ColorXY{0.6915, 0.3083}, - Green: ColorXY{0.1700, 0.7000}, - Blue: ColorXY{0.1532, 0.0475}, - } - // A point well outside (large x, large y). - outside := ColorXY{0.9, 0.9} - got := ClampToGamut(outside, gamutC) - // Result must be inside or on edge. - if !insideTriangle(got, gamutC) { - t.Fatalf("clamped point (%.4f, %.4f) not on/inside gamut", got.X, got.Y) - } -} - -func TestPackUnpackRGB(t *testing.T) { - cases := [][3]uint8{ - {0, 0, 0}, - {255, 255, 255}, - {255, 136, 0}, - {18, 52, 86}, - } - for _, c := range cases { - packed := PackRGB(c[0], c[1], c[2]) - gr, gg, gb := UnpackRGB(packed) - if gr != c[0] || gg != c[1] || gb != c[2] { - t.Errorf("packed=0x%06X round-trip failed: in=(%d,%d,%d) out=(%d,%d,%d)", - packed, c[0], c[1], c[2], gr, gg, gb) - } - } - if got := PackRGB(0xFF, 0x88, 0x00); got != 0xFF8800 { - t.Errorf("PackRGB(0xFF, 0x88, 0x00) = 0x%06X, want 0xFF8800", got) - } -} - -// insideTriangle is a barycentric inside-or-on-edge check used by tests. -func insideTriangle(p ColorXY, g Gamut) bool { - const eps = 1e-9 - d := func(a, b, c ColorXY) float64 { - return (a.X-c.X)*(b.Y-c.Y) - (b.X-c.X)*(a.Y-c.Y) - } - d1 := d(p, g.Red, g.Green) - d2 := d(p, g.Green, g.Blue) - d3 := d(p, g.Blue, g.Red) - hasNeg := d1 < -eps || d2 < -eps || d3 < -eps - hasPos := d1 > eps || d2 > eps || d3 > eps - return !hasNeg || !hasPos -} diff --git a/drivers/hue/internal/bridge/types.go b/drivers/hue/internal/bridge/types.go index 4dc32f0..90ccc0c 100644 --- a/drivers/hue/internal/bridge/types.go +++ b/drivers/hue/internal/bridge/types.go @@ -1,6 +1,8 @@ // Package bridge is the HTTPS + SSE client for the Philips Hue CLIP v2 API. package bridge +import "github.com/fdatoo/gohome-driverkit/colorconv" + // Light is a single light resource as returned by GET /clip/v2/resource/light. // Only the fields we use are modeled; the bridge sends more. type Light struct { @@ -101,19 +103,12 @@ type Color struct { Gamut Gamut `json:"gamut"` } -// ColorXY is a CIE 1931 chromaticity point. Both dimensions are 0..1. -type ColorXY struct { - X float64 `json:"x"` - Y float64 `json:"y"` -} +// ColorXY is an alias to keep wire-format JSON tags working without +// touching every call site. New code should use colorconv.XY directly. +type ColorXY = colorconv.XY -// Gamut is the triangle of representable colors for one bulb model. -// Hue v2 returns the three corners explicitly. -type Gamut struct { - Red ColorXY `json:"red"` - Green ColorXY `json:"green"` - Blue ColorXY `json:"blue"` -} +// Gamut is an alias to colorconv.Gamut for the same reason. +type Gamut = colorconv.Gamut // ColorUpdate is the body shape for a PUT — only xy, no gamut. type ColorUpdate struct { diff --git a/drivers/hue/internal/state/mapping.go b/drivers/hue/internal/state/mapping.go index 55a6127..0a363d8 100644 --- a/drivers/hue/internal/state/mapping.go +++ b/drivers/hue/internal/state/mapping.go @@ -7,6 +7,7 @@ import ( "math" "strconv" + "github.com/fdatoo/gohome-driverkit/colorconv" "github.com/fdatoo/gohome/drivers/hue/internal/bridge" entityv1 "github.com/fdatoo/gohome/gen/gohome/entity/v1" ) @@ -14,8 +15,8 @@ import ( // colorToRgb is the bridge xy → packed gohome RGB conversion. Used by // both LightToAttrs and MergeEvent to populate Light.ColorRgb. func colorToRgb(xy bridge.ColorXY) uint32 { - r, g, b := bridge.XYToRGB(xy) - return bridge.PackRGB(r, g, b) + r, g, b := colorconv.XYToRGB(xy) + return colorconv.PackRGB(r, g, b) } // EntityID returns the gohome entity ID for a Hue light. The first 8 chars @@ -128,8 +129,8 @@ func CommandToUpdate(capability string, args map[string]string, gamut bridge.Gam if err != nil { return bridge.LightUpdate{}, fmt.Errorf("set_color: %w", err) } - raw := bridge.RGBToXY(r, g, b) - clamped := bridge.ClampToGamut(raw, gamut) + raw := colorconv.RGBToXY(r, g, b) + clamped := colorconv.ClampToGamut(raw, gamut) u.On = &bridge.OnState{On: true} u.Color = &bridge.ColorUpdate{XY: clamped} default: From 509b5b92e16127f189ba51aa8d944b80e4020503 Mon Sep 17 00:00:00 2001 From: Fynn Datoo Date: Fri, 1 May 2026 00:21:34 -0700 Subject: [PATCH 04/16] feat(z2m): scaffolding and MQTT dependencies MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the drivers/z2m/ directory tree (cmd, internal/mqtt, internal/z2m, internal/state) with doc.go package headers. Pulls in paho.mqtt.golang for production use and mochi-mqtt/server/v2 for in-process broker testing. No driver logic yet — subsequent commits fill in topics, payloads, state translation, the MQTT wrapper, and main wiring. Co-Authored-By: Claude Opus 4.7 (1M context) --- drivers/z2m/cmd/z2m-driver/doc.go | 7 +++++++ drivers/z2m/internal/mqtt/doc.go | 6 ++++++ drivers/z2m/internal/state/doc.go | 5 +++++ drivers/z2m/internal/z2m/doc.go | 4 ++++ go.mod | 4 ++++ go.sum | 8 ++++++++ go.work.sum | 4 ++++ 7 files changed, 38 insertions(+) create mode 100644 drivers/z2m/cmd/z2m-driver/doc.go create mode 100644 drivers/z2m/internal/mqtt/doc.go create mode 100644 drivers/z2m/internal/state/doc.go create mode 100644 drivers/z2m/internal/z2m/doc.go diff --git a/drivers/z2m/cmd/z2m-driver/doc.go b/drivers/z2m/cmd/z2m-driver/doc.go new file mode 100644 index 0000000..279987e --- /dev/null +++ b/drivers/z2m/cmd/z2m-driver/doc.go @@ -0,0 +1,7 @@ +// Command z2m-driver is a Carport driver for Zigbee2MQTT. One driver +// instance mirrors a single Z2M deployment's devices into gohome: +// lights, numeric sensors, and binary sensors. +// +// Configuration is read from environment variables (Z2M_BROKER_URL, +// Z2M_USERNAME, ...). See the README for details. +package main diff --git a/drivers/z2m/internal/mqtt/doc.go b/drivers/z2m/internal/mqtt/doc.go new file mode 100644 index 0000000..22ee16f --- /dev/null +++ b/drivers/z2m/internal/mqtt/doc.go @@ -0,0 +1,6 @@ +// Package mqtt is a thin wrapper around eclipse/paho.mqtt.golang that +// exposes the subset of operations the Z2M driver needs: Connect, +// Subscribe, Unsubscribe, Publish, Close. Auto-reconnect is delegated +// to paho; OnConnect / OnDisconnect callbacks let main re-assert +// subscriptions and emit driver events on broker churn. +package mqtt diff --git a/drivers/z2m/internal/state/doc.go b/drivers/z2m/internal/state/doc.go new file mode 100644 index 0000000..0d68f0a --- /dev/null +++ b/drivers/z2m/internal/state/doc.go @@ -0,0 +1,5 @@ +// Package state translates between Zigbee2MQTT device descriptors and +// gohome entityv1.Attributes. Pure functions, no I/O. The exported +// surface is small: EntityID, EntitiesFor, MergeState, Reconcile, +// CommandToPayload. +package state diff --git a/drivers/z2m/internal/z2m/doc.go b/drivers/z2m/internal/z2m/doc.go new file mode 100644 index 0000000..0703c48 --- /dev/null +++ b/drivers/z2m/internal/z2m/doc.go @@ -0,0 +1,4 @@ +// Package z2m models Zigbee2MQTT topic namespaces and payload shapes. +// Pure types and topic constructors; no I/O. Decoders use +// encoding/json against captured fixtures. +package z2m diff --git a/go.mod b/go.mod index f0ad370..70b8caa 100644 --- a/go.mod +++ b/go.mod @@ -45,6 +45,7 @@ require ( github.com/danieljoos/wincred v1.2.3 // indirect github.com/davecgh/go-spew v1.1.1 // indirect github.com/dustin/go-humanize v1.0.1 // indirect + github.com/eclipse/paho.mqtt.golang v1.5.1 // indirect github.com/go-logfmt/logfmt v0.6.1 // indirect github.com/go-viper/mapstructure/v2 v2.5.0 // indirect github.com/go-webauthn/x v0.2.3 // indirect @@ -52,6 +53,7 @@ require ( github.com/golang-jwt/jwt/v5 v5.3.1 // indirect github.com/google/go-tpm v0.9.8 // indirect github.com/google/jsonschema-go v0.4.2 // indirect + github.com/gorilla/websocket v1.5.3 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/kr/text v0.2.0 // indirect github.com/kylelemons/godebug v1.1.0 // indirect @@ -59,6 +61,7 @@ require ( github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-runewidth v0.0.16 // indirect github.com/mfridman/interpolate v0.0.2 // indirect + github.com/mochi-mqtt/server/v2 v2.7.9 // indirect github.com/muesli/termenv v0.16.0 // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect github.com/ncruces/go-strftime v1.0.0 // indirect @@ -68,6 +71,7 @@ require ( github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect github.com/rivo/uniseg v0.4.7 // indirect github.com/rogpeppe/go-internal v1.14.1 // indirect + github.com/rs/xid v1.4.0 // indirect github.com/segmentio/asm v1.2.1 // indirect github.com/segmentio/encoding v0.5.4 // indirect github.com/sethvargo/go-retry v0.3.0 // indirect diff --git a/go.sum b/go.sum index 2d8269c..01a3cde 100644 --- a/go.sum +++ b/go.sum @@ -36,6 +36,8 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/eclipse/paho.mqtt.golang v1.5.1 h1:/VSOv3oDLlpqR2Epjn1Q7b2bSTplJIeV2ISgCl2W7nE= +github.com/eclipse/paho.mqtt.golang v1.5.1/go.mod h1:1/yJCneuyOoCOzKSsOTUc0AJfpsItBGWvYpBLimhArU= github.com/fxamacker/cbor/v2 v2.9.1 h1:2rWm8B193Ll4VdjsJY28jxs70IdDsHRWgQYAI80+rMQ= github.com/fxamacker/cbor/v2 v2.9.1/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ= github.com/go-logfmt/logfmt v0.6.1 h1:4hvbpePJKnIzH1B+8OR/JPbTx37NktoI9LE2QZBBkvE= @@ -68,6 +70,8 @@ github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17k github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= +github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= @@ -88,6 +92,8 @@ github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6T github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= github.com/mfridman/interpolate v0.0.2 h1:pnuTK7MQIxxFz1Gr+rjSIx9u7qVjf5VOoM/u6BbAxPY= github.com/mfridman/interpolate v0.0.2/go.mod h1:p+7uk6oE07mpE/Ik1b8EckO0O4ZXiGAfshKBWLUM9Xg= +github.com/mochi-mqtt/server/v2 v2.7.9 h1:y0g4vrSLAag7T07l2oCzOa/+nKVLoazKEWAArwqBNYI= +github.com/mochi-mqtt/server/v2 v2.7.9/go.mod h1:lZD3j35AVNqJL5cezlnSkuG05c0FCHSsfAKSPBOSbqc= github.com/modelcontextprotocol/go-sdk v1.5.0 h1:CHU0FIX9kpueNkxuYtfYQn1Z0slhFzBZuq+x6IiblIU= github.com/modelcontextprotocol/go-sdk v1.5.0/go.mod h1:gggDIhoemhWs3BGkGwd1umzEXCEMMvAnhTrnbXJKKKA= github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= @@ -122,6 +128,8 @@ github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs= github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro= github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= +github.com/rs/xid v1.4.0 h1:qd7wPTDkN6KQx2VmMBLrpHkiyQwgFXRnkOLacUiaSNY= +github.com/rs/xid v1.4.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/segmentio/asm v1.2.1 h1:DTNbBqs57ioxAD4PrArqftgypG4/qNpXoJx8TVXxPR0= github.com/segmentio/asm v1.2.1/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr5aAcs= diff --git a/go.work.sum b/go.work.sum index b2499e3..b93bde1 100644 --- a/go.work.sum +++ b/go.work.sum @@ -17,6 +17,7 @@ github.com/containerd/errdefs/pkg v0.3.0/go.mod h1:NJw6s9HwNuRhnjJhM7pylWwMyAkmC github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E= github.com/docker/go-connections v0.6.0/go.mod h1:AahvXYshr6JgfUJGdDCs2b5EZG/vmaMAntpSFH5BFKE= github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk= +github.com/eclipse/paho.mqtt.golang v1.5.1/go.mod h1:1/yJCneuyOoCOzKSsOTUc0AJfpsItBGWvYpBLimhArU= github.com/elastic/go-sysinfo v1.15.4/go.mod h1:ZBVXmqS368dOn/jvijV/zHLfakWTYHBZPk3G244lHrU= github.com/elastic/go-windows v1.0.2/go.mod h1:bGcDpBzXgYSqM0Gx3DM4+UxFj300SZLixie9u9ixLM8= github.com/envoyproxy/go-control-plane v0.14.0/go.mod h1:NcS5X47pLl/hfqxU70yPwL9ZMkUlwlKxtAohpi2wBEU= @@ -32,6 +33,7 @@ github.com/golang-jwt/jwt/v4 v4.5.2/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9/go.mod h1:8vg3r2VgvsThLBIFL93Qb5yWzgyZWhEmBwUJWevAkK0= github.com/golang-sql/sqlexp v0.1.0/go.mod h1:J4ad9Vo8ZCWQ2GMrC4UCQy1JpCbwU9m3EOqtpKwwwHI= github.com/golang/glog v1.2.5/go.mod h1:6AhwSGph0fcJtXVM/PEHPqZlFeoLxhs7/t5UDAwmO+w= +github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= github.com/jackc/pgx/v5 v5.8.0/go.mod h1:QVeDInX2m9VyzvNeiCJVjCkNFqzsNb43204HshNSZKw= @@ -46,6 +48,7 @@ github.com/microsoft/go-mssqldb v1.9.6/go.mod h1:yYMPDufyoF2vVuVCUGtZARr06DKFIhM github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo= github.com/moby/moby/api v1.53.0/go.mod h1:8mb+ReTlisw4pS6BRzCMts5M49W5M7bKt1cJy/YbAqc= github.com/moby/moby/client v0.2.2/go.mod h1:2EkIPVNCqR05CMIzL1mfA07t0HvVUUOl85pasRz/GmQ= +github.com/mochi-mqtt/server/v2 v2.7.9/go.mod h1:lZD3j35AVNqJL5cezlnSkuG05c0FCHSsfAKSPBOSbqc= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= github.com/mwitkow/go-conntrack v0.0.0-20190716064945-2f068394615f/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U= @@ -54,6 +57,7 @@ github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgr github.com/paulmach/orb v0.12.0/go.mod h1:5mULz1xQfs3bmQm63QEJA6lNGujuRafwA5S/EnuLaLU= github.com/pierrec/lz4/v4 v4.1.25/go.mod h1:EoQMVJgeeEOMsCqCzqFm2O0cJvljX2nGZjcRIPL34O4= github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10/go.mod h1:t/avpk3KcrXxUnYOhZhMXJlSEyie6gQbtLq5NM3loB8= +github.com/rs/xid v1.4.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg= github.com/shopspring/decimal v1.4.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME= github.com/spiffe/go-spiffe/v2 v2.6.0/go.mod h1:gm2SeUoMZEtpnzPNs2Csc0D/gX33k1xIx7lEzqblHEs= github.com/tursodatabase/libsql-client-go v0.0.0-20251219100830-236aa1ff8acc/go.mod h1:08inkKyguB6CGGssc/JzhmQWwBgFQBgjlYFjxjRh7nU= From 803c20a311caf308e855d336ac8c466fbf378e58 Mon Sep 17 00:00:00 2001 From: Fynn Datoo Date: Fri, 1 May 2026 00:24:17 -0700 Subject: [PATCH 05/16] feat(z2m): topic constructors and payload types drivers/z2m/internal/z2m/ models the Z2M topic namespace (BridgeDevices, BridgeState, BridgeEvent, DeviceTopics) and the JSON payload shapes (Device, Definition, Expose, BridgeState, AvailabilityState, StatePayload). Decoding is verified against a captured bridge/devices fixture covering a colour light, multi-sensor, contact sensor, smart plug, and coordinator. Pure types, no I/O. Used by the reconciler and main wiring in later tasks. Co-Authored-By: Claude Opus 4.7 (1M context) --- drivers/z2m/internal/z2m/payload.go | 69 ++++++++++ drivers/z2m/internal/z2m/payload_test.go | 126 ++++++++++++++++++ .../internal/z2m/testdata/bridge_devices.json | 83 ++++++++++++ drivers/z2m/internal/z2m/topics.go | 35 +++++ drivers/z2m/internal/z2m/topics_test.go | 40 ++++++ 5 files changed, 353 insertions(+) create mode 100644 drivers/z2m/internal/z2m/payload.go create mode 100644 drivers/z2m/internal/z2m/payload_test.go create mode 100644 drivers/z2m/internal/z2m/testdata/bridge_devices.json create mode 100644 drivers/z2m/internal/z2m/topics.go create mode 100644 drivers/z2m/internal/z2m/topics_test.go diff --git a/drivers/z2m/internal/z2m/payload.go b/drivers/z2m/internal/z2m/payload.go new file mode 100644 index 0000000..b61827f --- /dev/null +++ b/drivers/z2m/internal/z2m/payload.go @@ -0,0 +1,69 @@ +package z2m + +import "encoding/json" + +// Device is one element of /bridge/devices. +// +// Only the fields the driver consumes are modelled. Z2M sends many +// more (manufacturer, model_id, network_address, power_source, ...); +// they decode into nothing harmful and are ignored. +type Device struct { + IEEEAddress string `json:"ieee_address"` + FriendlyName string `json:"friendly_name"` + Type string `json:"type"` // "Coordinator" | "EndDevice" | "Router" + Definition Definition `json:"definition"` +} + +// Definition wraps the device's exposes tree. +type Definition struct { + Vendor string `json:"vendor"` + Model string `json:"model"` + Description string `json:"description"` + Exposes []Expose `json:"exposes"` +} + +// Expose is the recursive node type that describes one capability or +// composite. Z2M's exposes tree mixes leaf types ("numeric", "binary", +// "enum", "text") with composites ("light", "switch", "climate", "lock", +// "fan", "cover") whose Features hold the real leaves. +// +// The fields decoded here are the union of what leaves and composites +// use. Empty fields are ignored at read time. +type Expose struct { + Type string `json:"type"` + Name string `json:"name,omitempty"` + Property string `json:"property,omitempty"` + Description string `json:"description,omitempty"` + Access uint8 `json:"access,omitempty"` // bitmask: 1=published, 2=settable, 4=gettable + Unit string `json:"unit,omitempty"` + ValueMin *float64 `json:"value_min,omitempty"` + ValueMax *float64 `json:"value_max,omitempty"` + ValueOn any `json:"value_on,omitempty"` // string or bool + ValueOff any `json:"value_off,omitempty"` // string or bool + Features []Expose `json:"features,omitempty"` +} + +// AccessPublished reports whether bit 0 of Access is set (Z2M +// publishes the property on the state topic). Effectively "is this +// readable in our context". +func (e Expose) AccessPublished() bool { return e.Access&0x01 != 0 } + +// AccessSettable reports whether bit 1 of Access is set (settable via +// /set). Used to skip writable non-light properties (smart-plug state) +// in v0.1. +func (e Expose) AccessSettable() bool { return e.Access&0x02 != 0 } + +// BridgeStatePayload is the payload of /bridge/state. +type BridgeStatePayload struct { + State string `json:"state"` // "online" | "offline" +} + +// AvailabilityState is the payload of //availability. +type AvailabilityState struct { + State string `json:"state"` // "online" | "offline" +} + +// StatePayload is the per-device state-push payload. Each device's +// shape differs, so the values are captured raw. Use +// state.MergeState to interpret each property. +type StatePayload map[string]json.RawMessage diff --git a/drivers/z2m/internal/z2m/payload_test.go b/drivers/z2m/internal/z2m/payload_test.go new file mode 100644 index 0000000..65f9fdb --- /dev/null +++ b/drivers/z2m/internal/z2m/payload_test.go @@ -0,0 +1,126 @@ +package z2m + +import ( + "encoding/json" + "os" + "path/filepath" + "testing" +) + +func TestDecodeBridgeDevices(t *testing.T) { + raw, err := os.ReadFile(filepath.Join("testdata", "bridge_devices.json")) + if err != nil { + t.Fatalf("read fixture: %v", err) + } + var devices []Device + if err := json.Unmarshal(raw, &devices); err != nil { + t.Fatalf("decode: %v", err) + } + if got, want := len(devices), 5; got != want { + t.Fatalf("device count: got %d, want %d", got, want) + } + + // Spot-check the colour light: it should expose a "light" composite + // with four feature children. + light := findDevice(devices, "kitchen_light") + if light == nil { + t.Fatal("kitchen_light not found") + } + if len(light.Definition.Exposes) != 2 { + t.Errorf("kitchen_light top-level exposes: got %d, want 2", len(light.Definition.Exposes)) + } + lightExpose := light.Definition.Exposes[0] + if lightExpose.Type != "light" { + t.Errorf("first expose type: got %q, want %q", lightExpose.Type, "light") + } + if len(lightExpose.Features) != 4 { + t.Errorf("light features count: got %d, want 4", len(lightExpose.Features)) + } + + // Spot-check the multi-sensor. + motion := findDevice(devices, "hallway_motion") + if motion == nil { + t.Fatal("hallway_motion not found") + } + occ := findExpose(motion.Definition.Exposes, "occupancy") + if occ == nil { + t.Fatal("occupancy expose not found") + } + if occ.Type != "binary" { + t.Errorf("occupancy type: got %q, want %q", occ.Type, "binary") + } + if !occ.AccessPublished() { + t.Error("occupancy AccessPublished=false") + } + if occ.AccessSettable() { + t.Error("occupancy AccessSettable=true; expected read-only") + } +} + +func TestAccessBits(t *testing.T) { + cases := []struct { + access uint8 + published bool + settable bool + }{ + {0, false, false}, + {1, true, false}, + {2, false, true}, + {3, true, true}, + {7, true, true}, + } + for _, tc := range cases { + e := Expose{Access: tc.access} + if got := e.AccessPublished(); got != tc.published { + t.Errorf("Access=%d: AccessPublished=%v, want %v", tc.access, got, tc.published) + } + if got := e.AccessSettable(); got != tc.settable { + t.Errorf("Access=%d: AccessSettable=%v, want %v", tc.access, got, tc.settable) + } + } +} + +func TestDecodeBridgeState(t *testing.T) { + var s BridgeStatePayload + if err := json.Unmarshal([]byte(`{"state":"online"}`), &s); err != nil { + t.Fatalf("decode: %v", err) + } + if s.State != "online" { + t.Errorf("State = %q, want %q", s.State, "online") + } +} + +func TestDecodeStatePayload(t *testing.T) { + raw := []byte(`{"state":"ON","brightness":128,"color_temp":250}`) + var p StatePayload + if err := json.Unmarshal(raw, &p); err != nil { + t.Fatalf("decode: %v", err) + } + if got := len(p); got != 3 { + t.Errorf("payload size: got %d, want 3", got) + } + if string(p["brightness"]) != "128" { + t.Errorf("brightness raw: %q", string(p["brightness"])) + } +} + +func findDevice(devices []Device, name string) *Device { + for i, d := range devices { + if d.FriendlyName == name { + return &devices[i] + } + } + return nil +} + +func findExpose(exposes []Expose, property string) *Expose { + for i, e := range exposes { + if e.Property == property { + return &exposes[i] + } + if found := findExpose(e.Features, property); found != nil { + return found + } + } + return nil +} diff --git a/drivers/z2m/internal/z2m/testdata/bridge_devices.json b/drivers/z2m/internal/z2m/testdata/bridge_devices.json new file mode 100644 index 0000000..2efa8c3 --- /dev/null +++ b/drivers/z2m/internal/z2m/testdata/bridge_devices.json @@ -0,0 +1,83 @@ +[ + { + "ieee_address": "0x00158d0001234abc", + "friendly_name": "kitchen_light", + "type": "Router", + "definition": { + "vendor": "IKEA", + "model": "LED1545G12", + "description": "TRADFRI LED bulb E26/E27 980 lumen, dimmable, white spectrum, opal white", + "exposes": [ + { + "type": "light", + "features": [ + {"type": "binary", "name": "state", "property": "state", "access": 7, "value_on": "ON", "value_off": "OFF"}, + {"type": "numeric", "name": "brightness", "property": "brightness", "access": 7, "value_min": 0, "value_max": 254}, + {"type": "numeric", "name": "color_temp", "property": "color_temp", "access": 7, "value_min": 250, "value_max": 454}, + {"type": "composite", "name": "color_xy", "property": "color", "access": 7, "features": [ + {"type": "numeric", "name": "x", "property": "x", "access": 7}, + {"type": "numeric", "name": "y", "property": "y", "access": 7} + ]} + ] + }, + {"type": "numeric", "name": "linkquality", "property": "linkquality", "access": 1, "unit": "lqi", "value_min": 0, "value_max": 255} + ] + } + }, + { + "ieee_address": "0x00158d0009876543", + "friendly_name": "hallway_motion", + "type": "EndDevice", + "definition": { + "vendor": "Aqara", + "model": "RTCGQ11LM", + "description": "Aqara human body movement and illuminance sensor", + "exposes": [ + {"type": "binary", "name": "occupancy", "property": "occupancy", "access": 1, "value_on": true, "value_off": false}, + {"type": "numeric", "name": "battery", "property": "battery", "access": 1, "unit": "%", "value_min": 0, "value_max": 100}, + {"type": "numeric", "name": "temperature", "property": "temperature", "access": 1, "unit": "°C"}, + {"type": "numeric", "name": "humidity", "property": "humidity", "access": 1, "unit": "%"}, + {"type": "numeric", "name": "linkquality", "property": "linkquality", "access": 1, "unit": "lqi"}, + {"type": "numeric", "name": "voltage", "property": "voltage", "access": 1, "unit": "mV"} + ] + } + }, + { + "ieee_address": "0x00158d0002468ace", + "friendly_name": "front_door", + "type": "EndDevice", + "definition": { + "vendor": "Aqara", + "model": "MCCGQ11LM", + "description": "Aqara door & window contact sensor", + "exposes": [ + {"type": "binary", "name": "contact", "property": "contact", "access": 1, "value_on": false, "value_off": true}, + {"type": "numeric", "name": "battery", "property": "battery", "access": 1, "unit": "%"}, + {"type": "numeric", "name": "linkquality", "property": "linkquality", "access": 1, "unit": "lqi"} + ] + } + }, + { + "ieee_address": "0x00158d0011223344", + "friendly_name": "office_plug", + "type": "Router", + "definition": { + "vendor": "Innr", + "model": "SP 220", + "description": "Smart plug", + "exposes": [ + {"type": "switch", "features": [ + {"type": "binary", "name": "state", "property": "state", "access": 7, "value_on": "ON", "value_off": "OFF"} + ]}, + {"type": "numeric", "name": "power", "property": "power", "access": 1, "unit": "W"}, + {"type": "numeric", "name": "linkquality", "property": "linkquality", "access": 1, "unit": "lqi"} + ] + } + }, + { + "ieee_address": "0x00124b0000000000", + "friendly_name": "Coordinator", + "type": "Coordinator", + "definition": {"vendor": "", "model": "", "description": "", "exposes": []} + } +] diff --git a/drivers/z2m/internal/z2m/topics.go b/drivers/z2m/internal/z2m/topics.go new file mode 100644 index 0000000..08bd930 --- /dev/null +++ b/drivers/z2m/internal/z2m/topics.go @@ -0,0 +1,35 @@ +package z2m + +import "fmt" + +// Topics holds the four topics belonging to a single Z2M device, used +// by the reconciler so the caller can subscribe/unsubscribe in one +// pass. +type Topics struct { + State string // / + Set string // //set + Availability string // //availability +} + +// BridgeDevices returns /bridge/devices — the retained topic +// listing every paired device. Subscribing replays the current list. +func BridgeDevices(base string) string { return base + "/bridge/devices" } + +// BridgeState returns /bridge/state — "online"/"offline" for the +// Z2M bridge process itself. +func BridgeState(base string) string { return base + "/bridge/state" } + +// BridgeEvent returns /bridge/event — device-level lifecycle +// events (paired, removed, interview started/finished). Logged only +// in v0.1; the bridge/devices retained topic drives reconciliation. +func BridgeEvent(base string) string { return base + "/bridge/event" } + +// DeviceTopics returns the per-device topic bundle for friendlyName. +func DeviceTopics(base, friendlyName string) Topics { + prefix := fmt.Sprintf("%s/%s", base, friendlyName) + return Topics{ + State: prefix, + Set: prefix + "/set", + Availability: prefix + "/availability", + } +} diff --git a/drivers/z2m/internal/z2m/topics_test.go b/drivers/z2m/internal/z2m/topics_test.go new file mode 100644 index 0000000..c89363a --- /dev/null +++ b/drivers/z2m/internal/z2m/topics_test.go @@ -0,0 +1,40 @@ +package z2m + +import "testing" + +func TestBridgeTopics(t *testing.T) { + const base = "zigbee2mqtt" + cases := []struct { + fn string + got string + want string + }{ + {"BridgeDevices", BridgeDevices(base), "zigbee2mqtt/bridge/devices"}, + {"BridgeState", BridgeState(base), "zigbee2mqtt/bridge/state"}, + {"BridgeEvent", BridgeEvent(base), "zigbee2mqtt/bridge/event"}, + } + for _, tc := range cases { + if tc.got != tc.want { + t.Errorf("%s: got %q, want %q", tc.fn, tc.got, tc.want) + } + } +} + +func TestDeviceTopics(t *testing.T) { + got := DeviceTopics("zigbee2mqtt", "kitchen_light") + want := Topics{ + State: "zigbee2mqtt/kitchen_light", + Set: "zigbee2mqtt/kitchen_light/set", + Availability: "zigbee2mqtt/kitchen_light/availability", + } + if got != want { + t.Errorf("DeviceTopics: got %+v, want %+v", got, want) + } +} + +func TestDeviceTopicsCustomBase(t *testing.T) { + got := DeviceTopics("home/zigbee", "office") + if got.Set != "home/zigbee/office/set" { + t.Errorf("custom base: got %q", got.Set) + } +} From fdfe4304941515b23842b43d9587f5605119f0e1 Mon Sep 17 00:00:00 2001 From: Fynn Datoo Date: Fri, 1 May 2026 00:26:33 -0700 Subject: [PATCH 06/16] feat(z2m): EntityID derives gohome ID from Z2M IEEE address MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Lights → light.z2m_; sensors → .z2m__. Short, stable across friendly_name changes, collision-free within one Z2M instance. Co-Authored-By: Claude Opus 4.7 (1M context) --- drivers/z2m/internal/state/ids.go | 28 +++++++++++ drivers/z2m/internal/state/ids_test.go | 67 ++++++++++++++++++++++++++ 2 files changed, 95 insertions(+) create mode 100644 drivers/z2m/internal/state/ids.go create mode 100644 drivers/z2m/internal/state/ids_test.go diff --git a/drivers/z2m/internal/state/ids.go b/drivers/z2m/internal/state/ids.go new file mode 100644 index 0000000..79a0d95 --- /dev/null +++ b/drivers/z2m/internal/state/ids.go @@ -0,0 +1,28 @@ +package state + +import "strings" + +// EntityID returns the gohome entity ID for a Z2M (device, property) +// pair. The last 8 hex chars of the IEEE address are used as the +// stable identifier — short enough to scan in logs, immune to +// friendly_name changes, and unambiguous within one Z2M instance. +// +// Lights collapse all light properties (state, brightness, color_temp, +// color) into a single light.* entity, so prop is empty for lights. +// Sensors append _ so a multi-sensor's properties get distinct +// IDs. +func EntityID(ieee, kind, prop string) string { + last8 := lastHex8(ieee) + if prop == "" { + return kind + ".z2m_" + last8 + } + return kind + ".z2m_" + last8 + "_" + prop +} + +func lastHex8(ieee string) string { + id := strings.TrimPrefix(ieee, "0x") + if len(id) > 8 { + id = id[len(id)-8:] + } + return id +} diff --git a/drivers/z2m/internal/state/ids_test.go b/drivers/z2m/internal/state/ids_test.go new file mode 100644 index 0000000..fb7db67 --- /dev/null +++ b/drivers/z2m/internal/state/ids_test.go @@ -0,0 +1,67 @@ +package state + +import "testing" + +func TestEntityIDLight(t *testing.T) { + got := EntityID("0x00158d0001234abc", "light", "") + want := "light.z2m_01234abc" + if got != want { + t.Errorf("EntityID light: got %q, want %q", got, want) + } +} + +func TestEntityIDNumericSensor(t *testing.T) { + got := EntityID("0x00158d0009876543", "numeric_sensor", "temperature") + want := "numeric_sensor.z2m_09876543_temperature" + if got != want { + t.Errorf("EntityID numeric_sensor: got %q, want %q", got, want) + } +} + +func TestEntityIDBinarySensor(t *testing.T) { + got := EntityID("0x00158d0002468ace", "binary_sensor", "contact") + want := "binary_sensor.z2m_02468ace_contact" + if got != want { + t.Errorf("EntityID binary_sensor: got %q, want %q", got, want) + } +} + +func TestEntityIDShortIEEE(t *testing.T) { + // IEEEs shorter than 8 hex chars (post-prefix-strip) are passed through. + got := EntityID("0x12", "light", "") + want := "light.z2m_12" + if got != want { + t.Errorf("short IEEE: got %q, want %q", got, want) + } +} + +func TestEntityIDNoOxPrefix(t *testing.T) { + // Already without "0x" prefix. + got := EntityID("00158d0001234abc", "light", "") + want := "light.z2m_01234abc" + if got != want { + t.Errorf("no-prefix: got %q, want %q", got, want) + } +} + +func TestEntityIDCollisionFreeAcrossFixture(t *testing.T) { + // Sanity check: the fixture's IEEEs all yield distinct IDs even + // with property suffixes. + cases := []string{ + EntityID("0x00158d0001234abc", "light", ""), + EntityID("0x00158d0009876543", "binary_sensor", "occupancy"), + EntityID("0x00158d0009876543", "numeric_sensor", "temperature"), + EntityID("0x00158d0009876543", "numeric_sensor", "humidity"), + EntityID("0x00158d0009876543", "numeric_sensor", "battery"), + EntityID("0x00158d0002468ace", "binary_sensor", "contact"), + EntityID("0x00158d0002468ace", "numeric_sensor", "battery"), + EntityID("0x00158d0011223344", "numeric_sensor", "power"), + } + seen := map[string]bool{} + for _, id := range cases { + if seen[id] { + t.Errorf("collision on %q", id) + } + seen[id] = true + } +} From 0c122885e8910b38ffb2cbe1626e4840d9d3e7b9 Mon Sep 17 00:00:00 2001 From: Fynn Datoo Date: Fri, 1 May 2026 00:29:04 -0700 Subject: [PATCH 07/16] feat(z2m): EntitiesFor maps Z2M devices to gohome entities Walks the device's exposes tree, collapsing 'light' composites into one light.* entity and fanning per-property numeric/binary leaves into numeric_sensor.* / binary_sensor.* entities. Applies a blocklist for noisy properties (linkquality, voltage, update_available, last_seen) and skips writable non-light properties in v0.1 (Switch class out of scope) with INFO log. Verified against the captured bridge/devices fixture: colour light yields one light.* with four capabilities; the multi-sensor yields one binary_sensor and three numeric_sensor entities; the contact sensor yields one binary_sensor and one numeric_sensor; the smart plug yields only its read-only power; coordinator yields zero. Co-Authored-By: Claude Opus 4.7 (1M context) --- drivers/z2m/internal/state/mapping.go | 186 +++++++++++++++++++++ drivers/z2m/internal/state/mapping_test.go | 168 +++++++++++++++++++ 2 files changed, 354 insertions(+) create mode 100644 drivers/z2m/internal/state/mapping.go create mode 100644 drivers/z2m/internal/state/mapping_test.go diff --git a/drivers/z2m/internal/state/mapping.go b/drivers/z2m/internal/state/mapping.go new file mode 100644 index 0000000..42b587e --- /dev/null +++ b/drivers/z2m/internal/state/mapping.go @@ -0,0 +1,186 @@ +package state + +import ( + "log/slog" + + "github.com/fdatoo/gohome-driverkit/driver" + entityv1 "github.com/fdatoo/gohome/gen/gohome/entity/v1" + + "github.com/fdatoo/gohome/drivers/z2m/internal/z2m" +) + +// blockedProperties never become entities. linkquality/voltage are +// noise; update_available/last_seen are housekeeping. +var blockedProperties = map[string]bool{ + "linkquality": true, + "voltage": true, + "update_available": true, + "last_seen": true, +} + +// numericSensorProperties are the read-only numeric leaves we surface. +// Anything not in this set is ignored (debug log). +var numericSensorProperties = map[string]bool{ + "temperature": true, + "humidity": true, + "illuminance": true, + "battery": true, + "pressure": true, + "power": true, + "energy": true, + "current": true, +} + +// binarySensorProperties are the read-only binary leaves we surface. +var binarySensorProperties = map[string]bool{ + "occupancy": true, + "contact": true, + "water_leak": true, + "smoke": true, + "tamper": true, + "vibration": true, +} + +// EntityResult is one entity to register, plus the Z2M property name +// (if any) the caller should listen for on the device's state topic. +// Property is empty for lights (a light entity merges multiple +// properties — state, brightness, color_temp, color — under one ID; +// the caller iterates the StatePayload itself). +type EntityResult struct { + EntityID string + Spec driver.EntitySpec + Property string +} + +// EntitiesFor walks the device's exposes tree and returns one entity +// per supported (read-only) leaf, plus one collapsed light entity for +// any "light" composite. Unknown leaf types are skipped silently +// (debug-logged at the caller level if log verbosity is up). Writable +// non-light properties (smart plug state) are skipped with one INFO +// log line so users can see what they're missing. +func EntitiesFor(dev z2m.Device) []EntityResult { + var out []EntityResult + for _, e := range dev.Definition.Exposes { + out = append(out, mapExpose(dev, e)...) + } + return out +} + +func mapExpose(dev z2m.Device, e z2m.Expose) []EntityResult { + switch e.Type { + case "light": + return []EntityResult{lightEntity(dev, e)} + case "switch": + // v0.1: writable Switch is out of scope. Surface any read-only + // child properties (e.g. power on smart plugs) but skip the + // settable state child with an INFO log. + var out []EntityResult + for _, f := range e.Features { + out = append(out, mapExpose(dev, f)...) + } + return out + case "numeric": + if blockedProperties[e.Property] { + return nil + } + if e.AccessSettable() && !blockedProperties[e.Property] { + // Writable numeric — out of scope (no actuator class for + // numerics in v0.1). Skip silently. + return nil + } + if !numericSensorProperties[e.Property] { + slog.Debug("z2m: unrecognised numeric property; skipping", + "device", dev.FriendlyName, "property", e.Property) + return nil + } + return []EntityResult{numericSensorEntity(dev, e)} + case "binary": + if blockedProperties[e.Property] { + return nil + } + if e.AccessSettable() { + // Writable binary outside a `light` composite — typically a + // smart plug's `state`. Skip in v0.1 with a one-shot INFO so + // the user sees what's not surfaced. + slog.Info("z2m: writable binary property skipped (Switch class out of scope in v0.1)", + "device", dev.FriendlyName, "property", e.Property) + return nil + } + if !binarySensorProperties[e.Property] { + slog.Debug("z2m: unrecognised binary property; skipping", + "device", dev.FriendlyName, "property", e.Property) + return nil + } + return []EntityResult{binarySensorEntity(dev, e)} + default: + // composite / climate / cover / lock / fan / enum / text / list — + // out of scope in v0.1. Composite is handled inside light/switch + // above; everything else falls through silently. + slog.Debug("z2m: unsupported expose type; skipping", + "device", dev.FriendlyName, "type", e.Type, "name", e.Name) + return nil + } +} + +func lightEntity(dev z2m.Device, e z2m.Expose) EntityResult { + caps := []string{"turn_on", "turn_off"} + for _, f := range e.Features { + switch f.Property { + case "brightness": + caps = append(caps, "set_brightness") + case "color_temp": + caps = append(caps, "set_color_temp") + case "color": + caps = append(caps, "set_color") + } + } + return EntityResult{ + EntityID: EntityID(dev.IEEEAddress, "light", ""), + Spec: driver.EntitySpec{ + EntityType: "light", + FriendlyName: dev.FriendlyName, + Capabilities: caps, + InitialState: &entityv1.Attributes{ + Available: true, + Kind: &entityv1.Attributes_Light{Light: &entityv1.Light{}}, + }, + }, + Property: "", + } +} + +func numericSensorEntity(dev z2m.Device, e z2m.Expose) EntityResult { + return EntityResult{ + EntityID: EntityID(dev.IEEEAddress, "numeric_sensor", e.Property), + Spec: driver.EntitySpec{ + EntityType: "numeric_sensor", + FriendlyName: dev.FriendlyName + " " + e.Property, + Capabilities: nil, // read-only + InitialState: &entityv1.Attributes{ + Available: true, + Kind: &entityv1.Attributes_NumericSensor{ + NumericSensor: &entityv1.NumericSensor{Unit: e.Unit}, + }, + }, + }, + Property: e.Property, + } +} + +func binarySensorEntity(dev z2m.Device, e z2m.Expose) EntityResult { + return EntityResult{ + EntityID: EntityID(dev.IEEEAddress, "binary_sensor", e.Property), + Spec: driver.EntitySpec{ + EntityType: "binary_sensor", + FriendlyName: dev.FriendlyName + " " + e.Property, + Capabilities: nil, // read-only + InitialState: &entityv1.Attributes{ + Available: true, + Kind: &entityv1.Attributes_BinarySensor{ + BinarySensor: &entityv1.BinarySensor{}, + }, + }, + }, + Property: e.Property, + } +} diff --git a/drivers/z2m/internal/state/mapping_test.go b/drivers/z2m/internal/state/mapping_test.go new file mode 100644 index 0000000..0235358 --- /dev/null +++ b/drivers/z2m/internal/state/mapping_test.go @@ -0,0 +1,168 @@ +package state + +import ( + "encoding/json" + "os" + "path/filepath" + "reflect" + "sort" + "strings" + "testing" + + "github.com/fdatoo/gohome-driverkit/driver" + entityv1 "github.com/fdatoo/gohome/gen/gohome/entity/v1" + + "github.com/fdatoo/gohome/drivers/z2m/internal/z2m" +) + +// loadFixture is shared with reconcile_test; keep it in this file. +func loadFixture(t *testing.T) []z2m.Device { + t.Helper() + raw, err := os.ReadFile(filepath.Join("..", "z2m", "testdata", "bridge_devices.json")) + if err != nil { + t.Fatalf("read fixture: %v", err) + } + var devices []z2m.Device + if err := json.Unmarshal(raw, &devices); err != nil { + t.Fatalf("decode fixture: %v", err) + } + return devices +} + +func deviceByName(t *testing.T, devices []z2m.Device, name string) z2m.Device { + t.Helper() + for _, d := range devices { + if d.FriendlyName == name { + return d + } + } + t.Fatalf("device %q not found", name) + return z2m.Device{} +} + +func entityIDs(out []EntityResult) []string { + ids := make([]string, len(out)) + for i, r := range out { + ids[i] = r.EntityID + } + sort.Strings(ids) + return ids +} + +func TestEntitiesForColorLight(t *testing.T) { + dev := deviceByName(t, loadFixture(t), "kitchen_light") + got := EntitiesFor(dev) + if len(got) != 1 { + t.Fatalf("kitchen_light entities: got %d, want 1", len(got)) + } + r := got[0] + if r.EntityID != "light.z2m_01234abc" { + t.Errorf("entityID: got %q, want %q", r.EntityID, "light.z2m_01234abc") + } + if r.Spec.EntityType != "light" { + t.Errorf("entity type: got %q, want %q", r.Spec.EntityType, "light") + } + wantCaps := []string{"set_brightness", "set_color", "set_color_temp", "turn_off", "turn_on"} + gotCaps := append([]string(nil), r.Spec.Capabilities...) + sort.Strings(gotCaps) + if !reflect.DeepEqual(gotCaps, wantCaps) { + t.Errorf("capabilities: got %v, want %v", gotCaps, wantCaps) + } +} + +func TestEntitiesForMultiSensor(t *testing.T) { + dev := deviceByName(t, loadFixture(t), "hallway_motion") + got := EntitiesFor(dev) + want := []string{ + "binary_sensor.z2m_09876543_occupancy", + "numeric_sensor.z2m_09876543_battery", + "numeric_sensor.z2m_09876543_humidity", + "numeric_sensor.z2m_09876543_temperature", + } + if !reflect.DeepEqual(entityIDs(got), want) { + t.Errorf("entity ids: got %v, want %v", entityIDs(got), want) + } + // linkquality and voltage are blocked. + for _, r := range got { + if strings.Contains(r.EntityID, "linkquality") || strings.Contains(r.EntityID, "voltage") { + t.Errorf("blocked property surfaced: %q", r.EntityID) + } + } +} + +func TestEntitiesForContactSensor(t *testing.T) { + dev := deviceByName(t, loadFixture(t), "front_door") + got := EntitiesFor(dev) + want := []string{ + "binary_sensor.z2m_02468ace_contact", + "numeric_sensor.z2m_02468ace_battery", + } + if !reflect.DeepEqual(entityIDs(got), want) { + t.Errorf("entity ids: got %v, want %v", entityIDs(got), want) + } +} + +func TestEntitiesForSmartPlugSkipsWritableState(t *testing.T) { + dev := deviceByName(t, loadFixture(t), "office_plug") + got := EntitiesFor(dev) + want := []string{"numeric_sensor.z2m_11223344_power"} + if !reflect.DeepEqual(entityIDs(got), want) { + t.Errorf("entity ids: got %v, want %v", entityIDs(got), want) + } +} + +func TestEntitiesForCoordinator(t *testing.T) { + dev := deviceByName(t, loadFixture(t), "Coordinator") + got := EntitiesFor(dev) + if len(got) != 0 { + t.Errorf("coordinator entities: got %d, want 0 (%v)", len(got), entityIDs(got)) + } +} + +func TestEntitiesForPropertyToEntityMap(t *testing.T) { + // Each result records which Z2M property feeds it. main uses this + // to fan a state-topic payload out to the right entity IDs. + dev := deviceByName(t, loadFixture(t), "hallway_motion") + got := EntitiesFor(dev) + for _, r := range got { + if r.Property == "" { + t.Errorf("sensor result missing Property: %+v", r) + } + } + // Light entities have a synthetic Property="" (multiple properties + // feed one entity) — verified separately. + light := deviceByName(t, loadFixture(t), "kitchen_light") + for _, r := range EntitiesFor(light) { + if r.Property != "" { + t.Errorf("light result Property: got %q, want \"\"", r.Property) + } + } +} + +// Round-trip sanity: an EntitySpec carries an InitialState whose Kind +// matches the entity type. +func TestEntitiesForInitialStateKinds(t *testing.T) { + devices := loadFixture(t) + for _, dev := range devices { + for _, r := range EntitiesFor(dev) { + if r.Spec.InitialState == nil { + continue + } + switch r.Spec.EntityType { + case "light": + if _, ok := r.Spec.InitialState.Kind.(*entityv1.Attributes_Light); !ok { + t.Errorf("%s: InitialState.Kind not Light", r.EntityID) + } + case "numeric_sensor": + if _, ok := r.Spec.InitialState.Kind.(*entityv1.Attributes_NumericSensor); !ok { + t.Errorf("%s: InitialState.Kind not NumericSensor", r.EntityID) + } + case "binary_sensor": + if _, ok := r.Spec.InitialState.Kind.(*entityv1.Attributes_BinarySensor); !ok { + t.Errorf("%s: InitialState.Kind not BinarySensor", r.EntityID) + } + } + } + } + _ = driver.EntitySpec{} // ensure the import is used +} From 1b5db50e76044ac8a0c57211a4711507d8c9b0f3 Mon Sep 17 00:00:00 2001 From: Fynn Datoo Date: Fri, 1 May 2026 00:31:10 -0700 Subject: [PATCH 08/16] feat(z2m): CommandToPayload validates and serialises light commands MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Maps Carport capabilities (turn_on, turn_off, set_brightness, set_color_temp, set_color) to the JSON payloads Z2M's /set topic expects. Range validation runs before any network I/O so bad input surfaces as CARPORT_INTERNAL synchronously. set_color emits color: {hex: "#RRGGBB"} — Z2M understands hex across every vendor and avoids per-bulb gamut clamping (Z2M does its own). Co-Authored-By: Claude Opus 4.7 (1M context) --- drivers/z2m/internal/state/command.go | 95 +++++++++++++++ drivers/z2m/internal/state/command_test.go | 128 +++++++++++++++++++++ 2 files changed, 223 insertions(+) create mode 100644 drivers/z2m/internal/state/command.go create mode 100644 drivers/z2m/internal/state/command_test.go diff --git a/drivers/z2m/internal/state/command.go b/drivers/z2m/internal/state/command.go new file mode 100644 index 0000000..4f40042 --- /dev/null +++ b/drivers/z2m/internal/state/command.go @@ -0,0 +1,95 @@ +package state + +import ( + "encoding/json" + "fmt" + "strconv" + "strings" +) + +// CommandToPayload translates a Carport (capability, args) pair into +// the JSON payload Z2M expects on //set. Returns an +// error for unknown capabilities or out-of-range arguments — caller +// surfaces this as CARPORT_INTERNAL without hitting the network. +func CommandToPayload(capability string, args map[string]string) ([]byte, error) { + switch capability { + case "turn_on": + return json.Marshal(map[string]any{"state": "ON"}) + case "turn_off": + return json.Marshal(map[string]any{"state": "OFF"}) + case "set_brightness": + raw, ok := args["brightness"] + if !ok { + return nil, fmt.Errorf("set_brightness: missing brightness arg") + } + v, err := strconv.Atoi(raw) + if err != nil || v < 0 || v > 255 { + return nil, fmt.Errorf("set_brightness: brightness must be integer 0-255, got %q", raw) + } + return json.Marshal(map[string]any{"brightness": v}) + case "set_color_temp": + raw, ok := args["color_temp"] + if !ok { + return nil, fmt.Errorf("set_color_temp: missing color_temp arg") + } + v, err := strconv.Atoi(raw) + if err != nil || v < 153 || v > 500 { + return nil, fmt.Errorf("set_color_temp: color_temp must be integer 153-500 mireds, got %q", raw) + } + return json.Marshal(map[string]any{"color_temp": v}) + case "set_color": + hex, err := parseColorToHex(args) + if err != nil { + return nil, fmt.Errorf("set_color: %w", err) + } + return json.Marshal(map[string]any{"color": map[string]any{"hex": hex}}) + default: + return nil, fmt.Errorf("unknown capability %q", capability) + } +} + +// parseColorToHex extracts an RGB triple from args (hex= or r/g/b=) +// and returns it as "#RRGGBB" — the format Z2M understands universally +// across vendors. +func parseColorToHex(args map[string]string) (string, error) { + if h, ok := args["hex"]; ok { + s := strings.TrimPrefix(h, "#") + if len(s) != 6 { + return "", fmt.Errorf("hex must be 6 chars (with optional leading #), got %q", h) + } + if _, err := strconv.ParseUint(s, 16, 32); err != nil { + return "", fmt.Errorf("hex parse: %w", err) + } + return "#" + strings.ToUpper(s), nil + } + rs, hasR := args["r"] + gs, hasG := args["g"] + bs, hasB := args["b"] + if !hasR && !hasG && !hasB { + return "", fmt.Errorf("provide hex=#RRGGBB or r/g/b") + } + if !hasR || !hasG || !hasB { + return "", fmt.Errorf("r, g, and b must all be set") + } + r, err := parseByte(rs, "r") + if err != nil { + return "", err + } + g, err := parseByte(gs, "g") + if err != nil { + return "", err + } + b, err := parseByte(bs, "b") + if err != nil { + return "", err + } + return fmt.Sprintf("#%02X%02X%02X", r, g, b), nil +} + +func parseByte(s, name string) (uint8, error) { + v, err := strconv.Atoi(s) + if err != nil || v < 0 || v > 255 { + return 0, fmt.Errorf("%s must be integer 0-255, got %q", name, s) + } + return uint8(v), nil +} diff --git a/drivers/z2m/internal/state/command_test.go b/drivers/z2m/internal/state/command_test.go new file mode 100644 index 0000000..98d105a --- /dev/null +++ b/drivers/z2m/internal/state/command_test.go @@ -0,0 +1,128 @@ +package state + +import ( + "encoding/json" + "strings" + "testing" +) + +func TestCommandToPayloadTurnOnOff(t *testing.T) { + for _, tc := range []struct { + cap string + state string + }{ + {"turn_on", "ON"}, + {"turn_off", "OFF"}, + } { + got, err := CommandToPayload(tc.cap, nil) + if err != nil { + t.Fatalf("%s: err = %v", tc.cap, err) + } + var decoded map[string]any + if err := json.Unmarshal(got, &decoded); err != nil { + t.Fatalf("%s: decode = %v", tc.cap, err) + } + if decoded["state"] != tc.state { + t.Errorf("%s: state = %v, want %q", tc.cap, decoded["state"], tc.state) + } + } +} + +func TestCommandToPayloadSetBrightness(t *testing.T) { + got, err := CommandToPayload("set_brightness", map[string]string{"brightness": "200"}) + if err != nil { + t.Fatalf("err = %v", err) + } + var decoded map[string]any + _ = json.Unmarshal(got, &decoded) + if decoded["brightness"].(float64) != 200 { + t.Errorf("brightness = %v, want 200", decoded["brightness"]) + } +} + +func TestCommandToPayloadSetBrightnessOutOfRange(t *testing.T) { + for _, raw := range []string{"-1", "256", "foo", ""} { + _, err := CommandToPayload("set_brightness", map[string]string{"brightness": raw}) + if err == nil { + t.Errorf("brightness=%q: expected error", raw) + } + } +} + +func TestCommandToPayloadSetBrightnessMissing(t *testing.T) { + _, err := CommandToPayload("set_brightness", map[string]string{}) + if err == nil || !strings.Contains(err.Error(), "brightness") { + t.Errorf("expected missing-arg error; got %v", err) + } +} + +func TestCommandToPayloadSetColorTemp(t *testing.T) { + got, err := CommandToPayload("set_color_temp", map[string]string{"color_temp": "300"}) + if err != nil { + t.Fatalf("err = %v", err) + } + var decoded map[string]any + _ = json.Unmarshal(got, &decoded) + if decoded["color_temp"].(float64) != 300 { + t.Errorf("color_temp = %v, want 300", decoded["color_temp"]) + } +} + +func TestCommandToPayloadSetColorTempRange(t *testing.T) { + for _, raw := range []string{"50", "1000", "abc"} { + _, err := CommandToPayload("set_color_temp", map[string]string{"color_temp": raw}) + if err == nil { + t.Errorf("color_temp=%q: expected error", raw) + } + } +} + +func TestCommandToPayloadSetColorHex(t *testing.T) { + got, err := CommandToPayload("set_color", map[string]string{"hex": "#FF8800"}) + if err != nil { + t.Fatalf("err = %v", err) + } + var decoded map[string]any + _ = json.Unmarshal(got, &decoded) + color, ok := decoded["color"].(map[string]any) + if !ok { + t.Fatalf("color block missing or wrong type: %T %v", decoded["color"], decoded["color"]) + } + if color["hex"] != "#FF8800" { + t.Errorf("hex = %v, want #FF8800", color["hex"]) + } +} + +func TestCommandToPayloadSetColorRGB(t *testing.T) { + got, err := CommandToPayload("set_color", map[string]string{"r": "255", "g": "136", "b": "0"}) + if err != nil { + t.Fatalf("err = %v", err) + } + var decoded map[string]any + _ = json.Unmarshal(got, &decoded) + color := decoded["color"].(map[string]any) + if color["hex"] != "#FF8800" { + t.Errorf("hex from rgb = %v, want #FF8800", color["hex"]) + } +} + +func TestCommandToPayloadSetColorBadInput(t *testing.T) { + for _, args := range []map[string]string{ + {}, + {"hex": "zz"}, + {"hex": "#FF"}, + {"r": "-1", "g": "0", "b": "0"}, + {"r": "256", "g": "0", "b": "0"}, + {"r": "0", "g": "0"}, // missing b + } { + if _, err := CommandToPayload("set_color", args); err == nil { + t.Errorf("expected error for args %v", args) + } + } +} + +func TestCommandToPayloadUnknownCapability(t *testing.T) { + if _, err := CommandToPayload("set_warp_drive", nil); err == nil { + t.Error("expected error for unknown capability") + } +} From 2bcf61c07881bb788e1803e906c6b0e1337fd8e2 Mon Sep 17 00:00:00 2001 From: Fynn Datoo Date: Fri, 1 May 2026 00:33:18 -0700 Subject: [PATCH 09/16] feat(z2m): MergeState applies one property update to Attributes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per-kind dispatch: Light merges state/brightness/color_temp/color fields (with mutual exclusivity between color and color_temp); NumericSensor sets value preserving unit; BinarySensor accepts both bool and string ("ON"/"OFF") payloads, since Z2M devices disagree on representation. Unknown light properties are no-ops rather than errors — a multi-property state push fans out to several entities and each ignores keys it doesn't recognise. Co-Authored-By: Claude Opus 4.7 (1M context) --- drivers/z2m/internal/state/merge.go | 118 ++++++++++++++++++ drivers/z2m/internal/state/merge_test.go | 151 +++++++++++++++++++++++ 2 files changed, 269 insertions(+) create mode 100644 drivers/z2m/internal/state/merge.go create mode 100644 drivers/z2m/internal/state/merge_test.go diff --git a/drivers/z2m/internal/state/merge.go b/drivers/z2m/internal/state/merge.go new file mode 100644 index 0000000..f2ae543 --- /dev/null +++ b/drivers/z2m/internal/state/merge.go @@ -0,0 +1,118 @@ +package state + +import ( + "encoding/json" + "errors" + "fmt" + + "github.com/fdatoo/gohome-driverkit/colorconv" + entityv1 "github.com/fdatoo/gohome/gen/gohome/entity/v1" +) + +// MergeState applies one (property, raw-value) update from a Z2M +// state-topic payload to prev and returns the new Attributes. The +// kind of prev is the contract: a Light entity's caller iterates the +// payload and accumulates updates by calling MergeState repeatedly; +// a sensor entity gets one matching property per call. +// +// Returns prev unchanged (and no error) if the property doesn't apply +// to prev's kind — callers don't need to know which properties go +// where; a state-topic payload from a multi-property device gets fanned +// out by iterating cache.entityByTopic, and each entity sees only the +// payload's keys, ignoring the ones it doesn't care about. +func MergeState(prev *entityv1.Attributes, property string, value json.RawMessage) (*entityv1.Attributes, error) { + if prev == nil { + return nil, errors.New("MergeState: prev is nil") + } + switch k := prev.Kind.(type) { + case *entityv1.Attributes_Light: + return mergeLight(prev, k.Light, property, value) + case *entityv1.Attributes_NumericSensor: + return mergeNumericSensor(prev, k.NumericSensor, property, value) + case *entityv1.Attributes_BinarySensor: + return mergeBinarySensor(prev, k.BinarySensor, property, value) + default: + return nil, fmt.Errorf("MergeState: unsupported kind %T", prev.Kind) + } +} + +func mergeLight(prev *entityv1.Attributes, light *entityv1.Light, property string, value json.RawMessage) (*entityv1.Attributes, error) { + next := &entityv1.Light{ + On: light.GetOn(), + Brightness: light.GetBrightness(), + ColorTemp: light.GetColorTemp(), + ColorRgb: light.GetColorRgb(), + } + switch property { + case "state": + var s string + if err := json.Unmarshal(value, &s); err != nil { + return nil, fmt.Errorf("light state: %w", err) + } + next.On = s == "ON" + case "brightness": + var v uint32 + if err := json.Unmarshal(value, &v); err != nil { + return nil, fmt.Errorf("brightness: %w", err) + } + next.Brightness = v + case "color_temp": + var v uint32 + if err := json.Unmarshal(value, &v); err != nil { + return nil, fmt.Errorf("color_temp: %w", err) + } + next.ColorTemp = v + next.ColorRgb = 0 + case "color": + var xy struct { + X float64 `json:"x"` + Y float64 `json:"y"` + } + if err := json.Unmarshal(value, &xy); err != nil { + return nil, fmt.Errorf("color: %w", err) + } + r, g, b := colorconv.XYToRGB(colorconv.XY{X: xy.X, Y: xy.Y}) + next.ColorRgb = colorconv.PackRGB(r, g, b) + next.ColorTemp = 0 + default: + return prev, nil // unknown property → no-op + } + return &entityv1.Attributes{ + Available: prev.GetAvailable(), + Kind: &entityv1.Attributes_Light{Light: next}, + }, nil +} + +func mergeNumericSensor(prev *entityv1.Attributes, sensor *entityv1.NumericSensor, _ string, value json.RawMessage) (*entityv1.Attributes, error) { + var v float64 + if err := json.Unmarshal(value, &v); err != nil { + return nil, fmt.Errorf("numeric sensor: %w", err) + } + return &entityv1.Attributes{ + Available: prev.GetAvailable(), + Kind: &entityv1.Attributes_NumericSensor{ + NumericSensor: &entityv1.NumericSensor{Unit: sensor.GetUnit(), Value: v}, + }, + }, nil +} + +func mergeBinarySensor(prev *entityv1.Attributes, _ *entityv1.BinarySensor, _ string, value json.RawMessage) (*entityv1.Attributes, error) { + // Z2M binary properties may be reported as bool OR string ("ON"/"OFF"). + // Try bool first; fall back to string. + var b bool + if err := json.Unmarshal(value, &b); err == nil { + return &entityv1.Attributes{ + Available: prev.GetAvailable(), + Kind: &entityv1.Attributes_BinarySensor{BinarySensor: &entityv1.BinarySensor{On: b}}, + }, nil + } + var s string + if err := json.Unmarshal(value, &s); err != nil { + return nil, fmt.Errorf("binary sensor: not bool or string") + } + on := s == "ON" || s == "true" + return &entityv1.Attributes{ + Available: prev.GetAvailable(), + Kind: &entityv1.Attributes_BinarySensor{BinarySensor: &entityv1.BinarySensor{On: on}}, + }, nil +} diff --git a/drivers/z2m/internal/state/merge_test.go b/drivers/z2m/internal/state/merge_test.go new file mode 100644 index 0000000..ca53c08 --- /dev/null +++ b/drivers/z2m/internal/state/merge_test.go @@ -0,0 +1,151 @@ +package state + +import ( + "encoding/json" + "testing" + + entityv1 "github.com/fdatoo/gohome/gen/gohome/entity/v1" +) + +func lightAttrs(on bool, brightness, colorTemp, colorRGB uint32) *entityv1.Attributes { + return &entityv1.Attributes{ + Available: true, + Kind: &entityv1.Attributes_Light{ + Light: &entityv1.Light{ + On: on, Brightness: brightness, ColorTemp: colorTemp, ColorRgb: colorRGB, + }, + }, + } +} + +func raw(t *testing.T, v any) json.RawMessage { + t.Helper() + b, err := json.Marshal(v) + if err != nil { + t.Fatalf("marshal: %v", err) + } + return b +} + +func TestMergeStateLightOn(t *testing.T) { + prev := lightAttrs(false, 0, 0, 0) + got, err := MergeState(prev, "state", raw(t, "ON")) + if err != nil { + t.Fatalf("err = %v", err) + } + if !got.GetLight().GetOn() { + t.Error("expected on=true") + } +} + +func TestMergeStateLightOff(t *testing.T) { + prev := lightAttrs(true, 200, 0, 0) + got, _ := MergeState(prev, "state", raw(t, "OFF")) + if got.GetLight().GetOn() { + t.Error("expected on=false") + } + // Brightness preserved. + if got.GetLight().GetBrightness() != 200 { + t.Error("brightness should be preserved across on/off") + } +} + +func TestMergeStateLightBrightness(t *testing.T) { + prev := lightAttrs(true, 0, 0, 0) + got, _ := MergeState(prev, "brightness", raw(t, 128)) + if got.GetLight().GetBrightness() != 128 { + t.Errorf("brightness = %d, want 128", got.GetLight().GetBrightness()) + } +} + +func TestMergeStateLightColorTemp(t *testing.T) { + prev := lightAttrs(true, 200, 0, 0xFF8800) + got, _ := MergeState(prev, "color_temp", raw(t, 300)) + if got.GetLight().GetColorTemp() != 300 { + t.Errorf("color_temp = %d, want 300", got.GetLight().GetColorTemp()) + } + // Setting color_temp clears color_rgb (mutually exclusive). + if got.GetLight().GetColorRgb() != 0 { + t.Errorf("color_rgb should clear when color_temp set; got %#x", got.GetLight().GetColorRgb()) + } +} + +func TestMergeStateLightColor(t *testing.T) { + prev := lightAttrs(true, 200, 250, 0) + // Z2M color block: {x: ..., y: ...} + got, err := MergeState(prev, "color", raw(t, map[string]float64{"x": 0.6915, "y": 0.3083})) + if err != nil { + t.Fatalf("err = %v", err) + } + // Resulting RGB should be approximately red (high R, low G, low B). + rgb := got.GetLight().GetColorRgb() + r := uint8(rgb >> 16) + if r < 200 { + t.Errorf("expected red-dominant; got %#x", rgb) + } + if got.GetLight().GetColorTemp() != 0 { + t.Errorf("color_temp should clear when color set; got %d", got.GetLight().GetColorTemp()) + } +} + +func TestMergeStateNumericSensor(t *testing.T) { + prev := &entityv1.Attributes{ + Available: true, + Kind: &entityv1.Attributes_NumericSensor{ + NumericSensor: &entityv1.NumericSensor{Unit: "°C"}, + }, + } + got, err := MergeState(prev, "temperature", raw(t, 21.5)) + if err != nil { + t.Fatalf("err = %v", err) + } + if got.GetNumericSensor().GetValue() != 21.5 { + t.Errorf("value = %g, want 21.5", got.GetNumericSensor().GetValue()) + } + if got.GetNumericSensor().GetUnit() != "°C" { + t.Errorf("unit dropped: got %q", got.GetNumericSensor().GetUnit()) + } +} + +func TestMergeStateBinarySensor(t *testing.T) { + prev := &entityv1.Attributes{ + Available: true, + Kind: &entityv1.Attributes_BinarySensor{BinarySensor: &entityv1.BinarySensor{}}, + } + for _, tc := range []struct { + raw any + want bool + }{ + {true, true}, + {false, false}, + } { + got, err := MergeState(prev, "occupancy", raw(t, tc.raw)) + if err != nil { + t.Fatalf("err = %v", err) + } + if got.GetBinarySensor().GetOn() != tc.want { + t.Errorf("on = %v, want %v (raw=%v)", got.GetBinarySensor().GetOn(), tc.want, tc.raw) + } + } +} + +func TestMergeStateNilPrev(t *testing.T) { + // nil prev should error rather than panic — the caller didn't + // initialise InitialState, which is a programmer bug. + if _, err := MergeState(nil, "state", raw(t, "ON")); err == nil { + t.Error("expected error for nil prev") + } +} + +func TestMergeStateUnknownPropertyForLight(t *testing.T) { + // Unknown light property → no-op (returns prev unchanged) without + // error. Caller logs at debug. + prev := lightAttrs(true, 200, 0, 0) + got, err := MergeState(prev, "effect", raw(t, "blink")) + if err != nil { + t.Fatalf("err = %v", err) + } + if got != prev { + t.Error("expected prev returned unchanged on unknown property") + } +} From 196fc2d226ceca2d4a8954c925d7c43f44373edc Mon Sep 17 00:00:00 2001 From: Fynn Datoo Date: Fri, 1 May 2026 00:35:41 -0700 Subject: [PATCH 10/16] feat(z2m): Reconcile diffs device lists into ordered Actions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit AddEntity / UnregisterEntity / UpdateAttrs cover the three mutations the bridge/devices retained topic produces: new pairings, removed devices, and friendly_name renames. Adds come before removes in the ordered list so subscribe happens before registration on a swap (avoids retained-state race), with UpdateAttrs last. Composition changes (device firmware grows a property) are deferred — user restarts the driver to pick them up; documented in README later. Co-Authored-By: Claude Opus 4.7 (1M context) --- drivers/z2m/internal/state/reconcile.go | 141 ++++++++++++++ drivers/z2m/internal/state/reconcile_test.go | 191 +++++++++++++++++++ 2 files changed, 332 insertions(+) create mode 100644 drivers/z2m/internal/state/reconcile.go create mode 100644 drivers/z2m/internal/state/reconcile_test.go diff --git a/drivers/z2m/internal/state/reconcile.go b/drivers/z2m/internal/state/reconcile.go new file mode 100644 index 0000000..7cab604 --- /dev/null +++ b/drivers/z2m/internal/state/reconcile.go @@ -0,0 +1,141 @@ +package state + +import ( + "sort" + + "github.com/fdatoo/gohome-driverkit/driver" + + "github.com/fdatoo/gohome/drivers/z2m/internal/z2m" +) + +// Action is what Reconcile emits. main switches on the concrete type +// to apply each (subscribe + driver.AddEntity, etc.). +type Action interface{ isAction() } + +// AddEntity instructs main to register the entity, install capability +// handlers (if any), and subscribe to the device's state + +// availability topics. FriendlyName is the Z2M friendly_name (used +// to compute /set topic); IEEE is carried so command handlers can +// look up the device address-stable. +type AddEntity struct { + EntityID string + Spec driver.EntitySpec + IEEE string + FriendlyName string + Property string // "" for lights; the Z2M property name for sensors +} + +func (AddEntity) isAction() {} + +// UnregisterEntity instructs main to unsubscribe from the device's +// topics and call driver.UnregisterEntity. +type UnregisterEntity struct { + EntityID string + FriendlyName string // for unsubscribe; main rebuilds topics +} + +func (UnregisterEntity) isAction() {} + +// UpdateAttrs is emitted on friendly_name change so the entity can be +// relabeled without re-registration. Property is "" for lights and the +// Z2M property name for sensors (used to build the new friendly name). +type UpdateAttrs struct { + EntityID string + NewFriendlyName string + Property string +} + +func (UpdateAttrs) isAction() {} + +// Reconcile diffs prev → next at the entity level. The returned slice +// is ordered: all AddEntity first, then UnregisterEntity, then +// UpdateAttrs. This ordering matters for the v0.1 race window between +// retained-state delivery and entity registration (subscribe ahead of +// registration is the safer direction). +func Reconcile(prev, next []z2m.Device) []Action { + prevByIEEE := indexByIEEE(prev) + nextByIEEE := indexByIEEE(next) + + var adds, removes, updates []Action + + // Walk next: anything missing from prev is an add; anything with a + // different friendly_name is an update. + for ieee, ndev := range nextByIEEE { + entries := EntitiesFor(ndev) + pdev, existed := prevByIEEE[ieee] + if !existed { + for _, r := range entries { + adds = append(adds, AddEntity{ + EntityID: r.EntityID, + Spec: r.Spec, + IEEE: ieee, + FriendlyName: ndev.FriendlyName, + Property: r.Property, + }) + } + continue + } + if pdev.FriendlyName != ndev.FriendlyName { + for _, r := range entries { + updates = append(updates, UpdateAttrs{ + EntityID: r.EntityID, + NewFriendlyName: r.Spec.FriendlyName, + Property: r.Property, + }) + } + } + // Composition changes (a device's exposes tree gains/loses a + // property without a rename) are not handled in v0.1: real Z2M + // firmware updates are rare, and the user can restart the driver + // to pick them up. Documented as a caveat in the README. + } + + // Walk prev: anything missing from next is a remove. + for ieee, pdev := range prevByIEEE { + if _, stillThere := nextByIEEE[ieee]; stillThere { + continue + } + for _, r := range EntitiesFor(pdev) { + removes = append(removes, UnregisterEntity{ + EntityID: r.EntityID, + FriendlyName: pdev.FriendlyName, + }) + } + } + + sortByEntityID(adds) + sortByEntityID(removes) + sortByEntityID(updates) + + out := make([]Action, 0, len(adds)+len(removes)+len(updates)) + out = append(out, adds...) + out = append(out, removes...) + out = append(out, updates...) + return out +} + +func indexByIEEE(devices []z2m.Device) map[string]z2m.Device { + out := make(map[string]z2m.Device, len(devices)) + for _, d := range devices { + out[d.IEEEAddress] = d + } + return out +} + +func sortByEntityID(actions []Action) { + sort.SliceStable(actions, func(i, j int) bool { + return entityIDOf(actions[i]) < entityIDOf(actions[j]) + }) +} + +func entityIDOf(a Action) string { + switch v := a.(type) { + case AddEntity: + return v.EntityID + case UnregisterEntity: + return v.EntityID + case UpdateAttrs: + return v.EntityID + } + return "" +} diff --git a/drivers/z2m/internal/state/reconcile_test.go b/drivers/z2m/internal/state/reconcile_test.go new file mode 100644 index 0000000..a68cf30 --- /dev/null +++ b/drivers/z2m/internal/state/reconcile_test.go @@ -0,0 +1,191 @@ +package state + +import ( + "sort" + "testing" + + "github.com/fdatoo/gohome/drivers/z2m/internal/z2m" +) + +func actionTypes(actions []Action) []string { + out := make([]string, len(actions)) + for i, a := range actions { + switch a.(type) { + case AddEntity: + out[i] = "add" + case UnregisterEntity: + out[i] = "remove" + case UpdateAttrs: + out[i] = "update" + default: + out[i] = "unknown" + } + } + sort.Strings(out) + return out +} + +func TestReconcileEmptyToN(t *testing.T) { + devices := loadFixture(t) + actions := Reconcile(nil, devices) + for _, a := range actions { + if _, ok := a.(AddEntity); !ok { + t.Errorf("expected only AddEntity actions, got %T", a) + } + } + // 5 devices in fixture: kitchen_light(1) + hallway_motion(4) + + // front_door(2) + office_plug(1) + Coordinator(0) = 8 entities. + if len(actions) != 8 { + t.Errorf("action count: got %d, want 8", len(actions)) + } +} + +func TestReconcileNoOp(t *testing.T) { + devices := loadFixture(t) + actions := Reconcile(devices, devices) + if len(actions) != 0 { + t.Errorf("no-op should produce zero actions; got %d (%v)", len(actions), actionTypes(actions)) + } +} + +func TestReconcileAdd(t *testing.T) { + all := loadFixture(t) + prev := all[:len(all)-1] // drop coordinator + // Coordinator yields zero entities, so add it back: still zero adds. + actions := Reconcile(prev, all) + if len(actions) != 0 { + t.Errorf("adding a coordinator should add zero entities; got %v", actionTypes(actions)) + } + + // Add a real device (kitchen_light only in next). + prev = []z2m.Device{} + next := []z2m.Device{deviceByName(t, all, "front_door")} + actions = Reconcile(prev, next) + if len(actions) != 2 { // contact + battery + t.Errorf("front_door: got %d adds, want 2", len(actions)) + } + for _, a := range actions { + if _, ok := a.(AddEntity); !ok { + t.Errorf("expected AddEntity, got %T", a) + } + } +} + +func TestReconcileRemove(t *testing.T) { + all := loadFixture(t) + prev := []z2m.Device{deviceByName(t, all, "front_door")} + next := []z2m.Device{} + actions := Reconcile(prev, next) + if len(actions) != 2 { + t.Errorf("front_door removal: got %d, want 2", len(actions)) + } + for _, a := range actions { + if _, ok := a.(UnregisterEntity); !ok { + t.Errorf("expected UnregisterEntity, got %T", a) + } + } +} + +func TestReconcileRename(t *testing.T) { + all := loadFixture(t) + original := deviceByName(t, all, "front_door") + renamed := original + renamed.FriendlyName = "back_door" + prev := []z2m.Device{original} + next := []z2m.Device{renamed} + actions := Reconcile(prev, next) + // 2 entities (contact + battery) → 2 UpdateAttrs. + if len(actions) != 2 { + t.Fatalf("rename: got %d actions, want 2", len(actions)) + } + for _, a := range actions { + ua, ok := a.(UpdateAttrs) + if !ok { + t.Errorf("expected UpdateAttrs, got %T", a) + continue + } + if ua.NewFriendlyName != "back_door "+ua.Property { + t.Errorf("UpdateAttrs.NewFriendlyName: got %q, want %q", ua.NewFriendlyName, "back_door "+ua.Property) + } + } +} + +func TestReconcileMixed(t *testing.T) { + all := loadFixture(t) + prev := []z2m.Device{deviceByName(t, all, "front_door")} + next := []z2m.Device{deviceByName(t, all, "kitchen_light")} + actions := Reconcile(prev, next) + // 2 removes (front_door entities) + 1 add (kitchen_light entity). + want := []string{"add", "remove", "remove"} + if got := actionTypes(actions); !equalStringSlices(got, want) { + t.Errorf("mixed: got %v, want %v", got, want) + } +} + +func TestReconcileAddBeforeRemoveOrder(t *testing.T) { + // Within one cycle, AddEntity actions must precede UnregisterEntity + // actions so retained state delivery for added topics can race-free + // with the registration. UpdateAttrs go last (relabeling). + all := loadFixture(t) + prev := []z2m.Device{deviceByName(t, all, "front_door")} + next := []z2m.Device{deviceByName(t, all, "kitchen_light")} + actions := Reconcile(prev, next) + + var addedAt, removedAt int = -1, -1 + for i, a := range actions { + switch a.(type) { + case AddEntity: + if addedAt == -1 { + addedAt = i + } + case UnregisterEntity: + if removedAt == -1 { + removedAt = i + } + } + } + if addedAt == -1 || removedAt == -1 { + t.Fatalf("expected both add and remove; got %v", actionTypes(actions)) + } + if addedAt > removedAt { + t.Errorf("expected adds before removes; got order %v", actionTypes(actions)) + } +} + +func TestReconcileTopicsCarried(t *testing.T) { + // AddEntity carries the device's per-device topics so main can + // subscribe in lock-step. + all := loadFixture(t) + next := []z2m.Device{deviceByName(t, all, "kitchen_light")} + actions := Reconcile(nil, next) + if len(actions) != 1 { + t.Fatalf("got %d actions, want 1", len(actions)) + } + add, ok := actions[0].(AddEntity) + if !ok { + t.Fatalf("expected AddEntity, got %T", actions[0]) + } + wantState := "zigbee2mqtt/kitchen_light" + // Reconcile is base-agnostic; it stores friendly_name and lets main + // build topics. Verify FriendlyName is what we expect. + if add.FriendlyName != "kitchen_light" { + t.Errorf("FriendlyName: got %q, want %q", add.FriendlyName, "kitchen_light") + } + // EntityID should match what EntityID() produces. + if add.EntityID != "light.z2m_01234abc" { + t.Errorf("EntityID: got %q, want %q", add.EntityID, "light.z2m_01234abc") + } + _ = wantState +} + +func equalStringSlices(a, b []string) bool { + if len(a) != len(b) { + return false + } + for i := range a { + if a[i] != b[i] { + return false + } + } + return true +} From ee1d6135660b636e1a2a5cd6436bfc67c28d8c37 Mon Sep 17 00:00:00 2001 From: Fynn Datoo Date: Fri, 1 May 2026 00:38:26 -0700 Subject: [PATCH 11/16] feat(z2m): MQTT client wrapper around paho.mqtt.golang Thin facade exposing Connect/Subscribe/Unsubscribe/Publish/Close plus OnConnect/OnDisconnect callbacks. paho's auto-reconnect is left on; the OnConnect callback re-asserts subscriptions so reconnects don't silently drop them. Tested against an in-process mochi-mqtt broker. Co-Authored-By: Claude Opus 4.7 (1M context) --- drivers/z2m/internal/mqtt/client.go | 200 +++++++++++++++++++++++ drivers/z2m/internal/mqtt/client_test.go | 132 +++++++++++++++ 2 files changed, 332 insertions(+) create mode 100644 drivers/z2m/internal/mqtt/client.go create mode 100644 drivers/z2m/internal/mqtt/client_test.go diff --git a/drivers/z2m/internal/mqtt/client.go b/drivers/z2m/internal/mqtt/client.go new file mode 100644 index 0000000..05bc125 --- /dev/null +++ b/drivers/z2m/internal/mqtt/client.go @@ -0,0 +1,200 @@ +package mqtt + +import ( + "context" + "crypto/tls" + "errors" + "fmt" + "sync" + "time" + + paho "github.com/eclipse/paho.mqtt.golang" +) + +// Config carries the parameters needed to construct a Client. All +// fields except BrokerURL and ClientID are optional. +type Config struct { + BrokerURL string + ClientID string + Username string + Password string + TLSSkipVerify bool +} + +// Handler is the per-message callback registered with Subscribe. +type Handler func(topic string, payload []byte) + +// Client is the subset of paho.Client the Z2M driver uses. The thin +// wrapper exists so tests can hold a concrete type and the driver +// doesn't grow a transitive paho dependency through every layer. +type Client struct { + cfg Config + + mu sync.Mutex + c paho.Client + handlers map[string]Handler + onConnect func() + onDisconnect func(error) +} + +// New constructs a Client. BrokerURL and ClientID are required. +func New(cfg Config) (*Client, error) { + if cfg.BrokerURL == "" { + return nil, errors.New("mqtt: BrokerURL required") + } + if cfg.ClientID == "" { + return nil, errors.New("mqtt: ClientID required") + } + return &Client{ + cfg: cfg, + handlers: make(map[string]Handler), + }, nil +} + +// OnConnect registers a callback that fires on every successful +// (re)connect. Use this to re-assert subscriptions after broker churn. +func (c *Client) OnConnect(cb func()) { + c.mu.Lock() + c.onConnect = cb + c.mu.Unlock() +} + +// OnDisconnect registers a callback that fires when the connection +// drops. paho's auto-reconnect runs in the background; this is purely +// informational. +func (c *Client) OnDisconnect(cb func(error)) { + c.mu.Lock() + c.onDisconnect = cb + c.mu.Unlock() +} + +// Connect dials the broker and blocks until the first connect +// succeeds or ctx is cancelled. +func (c *Client) Connect(ctx context.Context) error { + opts := paho.NewClientOptions(). + AddBroker(c.cfg.BrokerURL). + SetClientID(c.cfg.ClientID). + SetAutoReconnect(true). + SetCleanSession(false). + SetConnectRetry(true). + SetConnectRetryInterval(2 * time.Second). + SetMaxReconnectInterval(30 * time.Second). + SetKeepAlive(60 * time.Second). + SetPingTimeout(10 * time.Second) + + if c.cfg.Username != "" { + opts.SetUsername(c.cfg.Username) + } + if c.cfg.Password != "" { + opts.SetPassword(c.cfg.Password) + } + if c.cfg.TLSSkipVerify { + opts.SetTLSConfig(&tls.Config{InsecureSkipVerify: true}) // #nosec G402 — opt-in via config + } + + opts.SetOnConnectHandler(func(_ paho.Client) { + c.mu.Lock() + cb := c.onConnect + // Re-assert subscriptions on every connect so reconnects + // don't silently lose them. + for topic, h := range c.handlers { + topic, h := topic, h + c.c.Subscribe(topic, 0, func(_ paho.Client, msg paho.Message) { + h(msg.Topic(), msg.Payload()) + }) + } + c.mu.Unlock() + if cb != nil { + cb() + } + }) + opts.SetConnectionLostHandler(func(_ paho.Client, err error) { + c.mu.Lock() + cb := c.onDisconnect + c.mu.Unlock() + if cb != nil { + cb(err) + } + }) + + c.mu.Lock() + c.c = paho.NewClient(opts) + c.mu.Unlock() + + tok := c.c.Connect() + select { + case <-ctx.Done(): + return ctx.Err() + case <-tokenDone(tok): + } + if err := tok.Error(); err != nil { + return fmt.Errorf("mqtt connect: %w", err) + } + return nil +} + +// Subscribe registers h for topic. Idempotent across reconnects: the +// OnConnect handler re-applies every entry in c.handlers. +func (c *Client) Subscribe(topic string, h Handler) error { + c.mu.Lock() + c.handlers[topic] = h + cli := c.c + c.mu.Unlock() + if cli == nil || !cli.IsConnected() { + return nil // re-applied on next OnConnect + } + tok := cli.Subscribe(topic, 0, func(_ paho.Client, msg paho.Message) { + h(msg.Topic(), msg.Payload()) + }) + tok.Wait() + return tok.Error() +} + +// Unsubscribe drops the handler for topic and tells the broker. +func (c *Client) Unsubscribe(topic string) error { + c.mu.Lock() + delete(c.handlers, topic) + cli := c.c + c.mu.Unlock() + if cli == nil || !cli.IsConnected() { + return nil + } + tok := cli.Unsubscribe(topic) + tok.Wait() + return tok.Error() +} + +// Publish writes payload to topic. retained controls whether the +// broker stores it for future subscribers (Z2M's bridge/devices +// uses retained; /set commands do not). +func (c *Client) Publish(topic string, payload []byte, retained bool) error { + c.mu.Lock() + cli := c.c + c.mu.Unlock() + if cli == nil { + return errors.New("mqtt: not connected") + } + tok := cli.Publish(topic, 0, retained, payload) + tok.Wait() + return tok.Error() +} + +// Close disconnects cleanly. Idempotent. +func (c *Client) Close() { + c.mu.Lock() + cli := c.c + c.c = nil + c.mu.Unlock() + if cli != nil && cli.IsConnected() { + cli.Disconnect(250) + } +} + +func tokenDone(t paho.Token) <-chan struct{} { + ch := make(chan struct{}) + go func() { + t.Wait() + close(ch) + }() + return ch +} diff --git a/drivers/z2m/internal/mqtt/client_test.go b/drivers/z2m/internal/mqtt/client_test.go new file mode 100644 index 0000000..135a923 --- /dev/null +++ b/drivers/z2m/internal/mqtt/client_test.go @@ -0,0 +1,132 @@ +package mqtt + +import ( + "context" + "net" + "testing" + "time" + + mqttserver "github.com/mochi-mqtt/server/v2" + "github.com/mochi-mqtt/server/v2/hooks/auth" + "github.com/mochi-mqtt/server/v2/listeners" +) + +// startBroker starts an in-memory mochi broker on a free TCP port and +// returns its address (host:port). Cleanup is registered via t. +func startBroker(t *testing.T) string { + t.Helper() + server := mqttserver.New(nil) + if err := server.AddHook(new(auth.AllowHook), nil); err != nil { + t.Fatalf("AddHook: %v", err) + } + l, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatalf("listen: %v", err) + } + addr := l.Addr().String() + _ = l.Close() + tcp := listeners.NewTCP(listeners.Config{ID: "t1", Address: addr}) + if err := server.AddListener(tcp); err != nil { + t.Fatalf("AddListener: %v", err) + } + go func() { _ = server.Serve() }() + t.Cleanup(func() { _ = server.Close() }) + // Give the listener a moment to bind. + time.Sleep(50 * time.Millisecond) + return addr +} + +func TestClientConnectPublishSubscribe(t *testing.T) { + addr := startBroker(t) + + c, err := New(Config{ + BrokerURL: "tcp://" + addr, + ClientID: "test-client", + }) + if err != nil { + t.Fatalf("New: %v", err) + } + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + if err := c.Connect(ctx); err != nil { + t.Fatalf("Connect: %v", err) + } + defer c.Close() + + got := make(chan []byte, 1) + if err := c.Subscribe("test/topic", func(_ string, payload []byte) { + got <- append([]byte(nil), payload...) + }); err != nil { + t.Fatalf("Subscribe: %v", err) + } + + if err := c.Publish("test/topic", []byte("hello"), false); err != nil { + t.Fatalf("Publish: %v", err) + } + + select { + case payload := <-got: + if string(payload) != "hello" { + t.Errorf("payload = %q, want %q", payload, "hello") + } + case <-time.After(2 * time.Second): + t.Error("subscriber never received payload") + } +} + +func TestClientUnsubscribe(t *testing.T) { + addr := startBroker(t) + c, err := New(Config{BrokerURL: "tcp://" + addr, ClientID: "u-test"}) + if err != nil { + t.Fatalf("New: %v", err) + } + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + if err := c.Connect(ctx); err != nil { + t.Fatalf("Connect: %v", err) + } + defer c.Close() + + got := make(chan struct{}, 1) + _ = c.Subscribe("u/topic", func(_ string, _ []byte) { got <- struct{}{} }) + if err := c.Unsubscribe("u/topic"); err != nil { + t.Fatalf("Unsubscribe: %v", err) + } + _ = c.Publish("u/topic", []byte("x"), false) + select { + case <-got: + t.Error("received payload after unsubscribe") + case <-time.After(300 * time.Millisecond): + } +} + +func TestClientOnConnect(t *testing.T) { + addr := startBroker(t) + c, err := New(Config{BrokerURL: "tcp://" + addr, ClientID: "cb"}) + if err != nil { + t.Fatalf("New: %v", err) + } + called := make(chan struct{}, 1) + c.OnConnect(func() { called <- struct{}{} }) + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + if err := c.Connect(ctx); err != nil { + t.Fatalf("Connect: %v", err) + } + defer c.Close() + + select { + case <-called: + case <-time.After(2 * time.Second): + t.Error("OnConnect callback never fired") + } +} + +func TestClientConfigRequired(t *testing.T) { + if _, err := New(Config{ClientID: "x"}); err == nil { + t.Error("expected error for missing BrokerURL") + } +} From 7518102018c343a5a167ad1650dc0ebb6e8037c5 Mon Sep 17 00:00:00 2001 From: Fynn Datoo Date: Fri, 1 May 2026 00:41:27 -0700 Subject: [PATCH 12/16] feat(z2m): main.go config loading and driverkit wiring scaffold Reads Z2M_BROKER_URL / Z2M_USERNAME / Z2M_PASSWORD / Z2M_BASE_TOPIC / Z2M_CLIENT_ID / Z2M_TLS_SKIP_VERIFY from env; dials the broker via the internal/mqtt wrapper; constructs the driver and a stateCache; forwards broker connect/disconnect to driver events. The reconciliation handlers (subscribeBridgeTopics) land next. Co-Authored-By: Claude Opus 4.7 (1M context) --- drivers/z2m/cmd/z2m-driver/main.go | 180 +++++++++++++++++++++++++++++ 1 file changed, 180 insertions(+) create mode 100644 drivers/z2m/cmd/z2m-driver/main.go diff --git a/drivers/z2m/cmd/z2m-driver/main.go b/drivers/z2m/cmd/z2m-driver/main.go new file mode 100644 index 0000000..de04359 --- /dev/null +++ b/drivers/z2m/cmd/z2m-driver/main.go @@ -0,0 +1,180 @@ +package main + +import ( + "context" + "crypto/rand" + "encoding/hex" + "errors" + "fmt" + "log/slog" + "os" + "os/signal" + "strconv" + "sync" + "syscall" + + "github.com/fdatoo/gohome-driverkit/driver" + entityv1 "github.com/fdatoo/gohome/gen/gohome/entity/v1" + + "github.com/fdatoo/gohome/drivers/z2m/internal/mqtt" + _ "github.com/fdatoo/gohome/drivers/z2m/internal/state" // used in Task 12 + "github.com/fdatoo/gohome/drivers/z2m/internal/z2m" +) + +const driverName, driverVersion = "driver.z2m", "0.1.0" + +func main() { + cfg, err := loadConfig() + if err != nil { + fmt.Fprintf(os.Stderr, "z2m-driver: config: %v\n", err) + os.Exit(1) + } + + logger := slog.New(slog.NewJSONHandler(os.Stderr, &slog.HandlerOptions{ + Level: parseLogLevel(os.Getenv("Z2M_LOG_LEVEL")), + })).With( + "instance_id", os.Getenv("GOHOME_CARPORT_INSTANCE_ID"), + "broker_url", cfg.BrokerURL, + "base_topic", cfg.BaseTopic, + ) + slog.SetDefault(logger) + + ctx, cancel := signal.NotifyContext(context.Background(), syscall.SIGTERM, syscall.SIGINT) + defer cancel() + + mq, err := mqtt.New(mqtt.Config{ + BrokerURL: cfg.BrokerURL, + ClientID: cfg.ClientID, + Username: cfg.Username, + Password: cfg.Password, + TLSSkipVerify: cfg.TLSSkipVerify, + }) + if err != nil { + fmt.Fprintf(os.Stderr, "z2m-driver: mqtt new: %v\n", err) + os.Exit(1) + } + if err := mq.Connect(ctx); err != nil { + fmt.Fprintf(os.Stderr, "z2m-driver: mqtt connect: %v\n", err) + os.Exit(1) + } + defer mq.Close() + + d := driver.New(driverName, driverVersion) + cache := newStateCache() + app := &app{cfg: cfg, mq: mq, d: d, cache: cache} + + mq.OnDisconnect(func(err error) { + slog.Warn("mqtt disconnected", "error", err) + _ = d.EmitDriverEvent("broker_disconnected", err.Error()) + }) + mq.OnConnect(func() { + _ = d.EmitDriverEvent("broker_reconnected", "") + }) + + app.subscribeBridgeTopics() + + runErr := d.Run(ctx) + if runErr != nil && !errors.Is(runErr, context.Canceled) { + slog.Error("driver run exited", "error", runErr) + os.Exit(1) + } +} + +// config holds parsed environment variables. +type config struct { + BrokerURL string + Username string + Password string + BaseTopic string + ClientID string + TLSSkipVerify bool +} + +func loadConfig() (config, error) { + c := config{ + BrokerURL: os.Getenv("Z2M_BROKER_URL"), + Username: os.Getenv("Z2M_USERNAME"), + Password: os.Getenv("Z2M_PASSWORD"), + BaseTopic: os.Getenv("Z2M_BASE_TOPIC"), + ClientID: os.Getenv("Z2M_CLIENT_ID"), + } + if c.BrokerURL == "" { + return config{}, errors.New("Z2M_BROKER_URL is required") + } + if c.BaseTopic == "" { + c.BaseTopic = "zigbee2mqtt" + } + if c.ClientID == "" { + var b [4]byte + _, _ = rand.Read(b[:]) + c.ClientID = "gohome-z2m-" + hex.EncodeToString(b[:]) + } + if v := os.Getenv("Z2M_TLS_SKIP_VERIFY"); v != "" { + b, err := strconv.ParseBool(v) + if err != nil { + return config{}, errors.New("Z2M_TLS_SKIP_VERIFY must be a boolean") + } + c.TLSSkipVerify = b + } + return c, nil +} + +func parseLogLevel(s string) slog.Level { + switch s { + case "debug": + return slog.LevelDebug + case "warn": + return slog.LevelWarn + case "error": + return slog.LevelError + default: + return slog.LevelInfo + } +} + +// stateCache holds the driver's runtime view: which entities exist +// and what the last published state was, so MergeState has a base to +// merge against; which Z2M IEEE addresses we know about, fed to the +// next Reconcile; which entity IDs are downstream of a given state +// topic (a multi-property device's state topic fans out to N entities). +type stateCache struct { + mu sync.Mutex + entities map[string]*entityv1.Attributes // entityID → last attrs + devices map[string]z2m.Device // ieee → last-seen device + entityByTopic map[string][]entityListener // state topic → which entities consume it + friendlyByEnt map[string]string // entityID → friendly_name (for /set) + ieeeByEnt map[string]string // entityID → ieee (for log context) +} + +// entityListener is one entity's binding inside a state topic: which +// Z2M property it cares about (empty string means a light, which +// consumes every recognised property in the payload). +type entityListener struct { + EntityID string + Property string +} + +func newStateCache() *stateCache { + return &stateCache{ + entities: map[string]*entityv1.Attributes{}, + devices: map[string]z2m.Device{}, + entityByTopic: map[string][]entityListener{}, + friendlyByEnt: map[string]string{}, + ieeeByEnt: map[string]string{}, + } +} + +// app bundles the long-lived dependencies that handlers need so we +// can pass one pointer rather than five. +type app struct { + cfg config + mq *mqtt.Client + d *driver.Driver + cache *stateCache +} + +// subscribeBridgeTopics is filled in in Task 12. Stubbed here so +// main.go compiles. +func (a *app) subscribeBridgeTopics() { + // Implementation lands in Task 12. +} From ca735c31ee3bd4b47a2366c6fbf0c6590228d9a9 Mon Sep 17 00:00:00 2001 From: Fynn Datoo Date: Fri, 1 May 2026 00:50:37 -0700 Subject: [PATCH 13/16] feat(z2m): bridge handlers + integration tests Wires up the four bridge subscriptions (devices, state, event, plus per-device state/availability) into the Reconcile output: AddEntity registers entity + capability handlers + subscribes state/availability; UnregisterEntity unsubscribes + drops the entity; UpdateAttrs re-emits last attrs with the new label. State-topic payloads fan out to every entity that listens on the topic (a multi-property device hands the same payload to several entities, each consuming its own property). Integration-tested against an in-process mochi-mqtt broker: initial reconciliation, turn_on round-trip via /set, hot add/remove on bridge/devices republish, bridge-offline propagation to per-entity Available=false. Co-Authored-By: Claude Opus 4.7 (1M context) --- drivers/z2m/cmd/z2m-driver/main.go | 285 +++++++++++++++++++++++- drivers/z2m/cmd/z2m-driver/main_test.go | 279 +++++++++++++++++++++++ go.work.sum | 27 ++- 3 files changed, 583 insertions(+), 8 deletions(-) create mode 100644 drivers/z2m/cmd/z2m-driver/main_test.go diff --git a/drivers/z2m/cmd/z2m-driver/main.go b/drivers/z2m/cmd/z2m-driver/main.go index de04359..a6793e5 100644 --- a/drivers/z2m/cmd/z2m-driver/main.go +++ b/drivers/z2m/cmd/z2m-driver/main.go @@ -4,20 +4,24 @@ import ( "context" "crypto/rand" "encoding/hex" + "encoding/json" "errors" "fmt" "log/slog" "os" "os/signal" "strconv" + "strings" "sync" "syscall" + "google.golang.org/protobuf/proto" + "github.com/fdatoo/gohome-driverkit/driver" entityv1 "github.com/fdatoo/gohome/gen/gohome/entity/v1" "github.com/fdatoo/gohome/drivers/z2m/internal/mqtt" - _ "github.com/fdatoo/gohome/drivers/z2m/internal/state" // used in Task 12 + "github.com/fdatoo/gohome/drivers/z2m/internal/state" "github.com/fdatoo/gohome/drivers/z2m/internal/z2m" ) @@ -173,8 +177,281 @@ type app struct { cache *stateCache } -// subscribeBridgeTopics is filled in in Task 12. Stubbed here so -// main.go compiles. +// subscribeBridgeTopics installs the four bridge-level subscriptions +// that drive reconciliation, plus a dummy retained-payload handler +// for bridge/event (logged only). func (a *app) subscribeBridgeTopics() { - // Implementation lands in Task 12. + _ = a.mq.Subscribe(z2m.BridgeDevices(a.cfg.BaseTopic), a.onBridgeDevices) + _ = a.mq.Subscribe(z2m.BridgeState(a.cfg.BaseTopic), a.onBridgeState) + _ = a.mq.Subscribe(z2m.BridgeEvent(a.cfg.BaseTopic), a.onBridgeEvent) +} + +// onBridgeDevices is the reconciliation entry point. Z2M republishes +// the full device list (retained) on every network change, so this is +// the single source of truth for AddEntity/UnregisterEntity/UpdateAttrs. +func (a *app) onBridgeDevices(_ string, payload []byte) { + var devices []z2m.Device + if err := json.Unmarshal(payload, &devices); err != nil { + // Z2M's republish is idempotent and the next valid payload + // heals; do not wipe registry on parse error. + slog.Error("bridge/devices parse failed; skipping reconciliation cycle", + "error", err, "bytes", len(payload)) + return + } + + a.cache.mu.Lock() + prev := make([]z2m.Device, 0, len(a.cache.devices)) + for _, d := range a.cache.devices { + prev = append(prev, d) + } + a.cache.mu.Unlock() + + actions := state.Reconcile(prev, devices) + + for _, action := range actions { + switch act := action.(type) { + case state.AddEntity: + a.applyAdd(act) + case state.UnregisterEntity: + a.applyRemove(act) + case state.UpdateAttrs: + a.applyUpdate(act) + } + } + + a.cache.mu.Lock() + a.cache.devices = make(map[string]z2m.Device, len(devices)) + for _, d := range devices { + a.cache.devices[d.IEEEAddress] = d + } + a.cache.mu.Unlock() +} + +func (a *app) applyAdd(act state.AddEntity) { + if err := a.d.AddEntity(act.EntityID, act.Spec); err != nil { + if errors.Is(err, driver.ErrEntityAlreadyRegistered) { + return // race-safe: bridge/devices replays produce duplicates + } + slog.Error("AddEntity failed", "entity_id", act.EntityID, "error", err) + return + } + + topics := z2m.DeviceTopics(a.cfg.BaseTopic, act.FriendlyName) + + a.cache.mu.Lock() + a.cache.friendlyByEnt[act.EntityID] = act.FriendlyName + a.cache.ieeeByEnt[act.EntityID] = act.IEEE + if act.Spec.InitialState != nil { + a.cache.entities[act.EntityID] = act.Spec.InitialState + } + a.cache.entityByTopic[topics.State] = append( + a.cache.entityByTopic[topics.State], + entityListener{EntityID: act.EntityID, Property: act.Property}, + ) + a.cache.mu.Unlock() + + // Capability handlers — only lights have any. + if act.Spec.EntityType == "light" { + ent := act.EntityID + friendly := act.FriendlyName + for _, capName := range act.Spec.Capabilities { + cap := capName + a.d.OnCapability(ent, cap, func(_ context.Context, _ string, args map[string]string) (*entityv1.Attributes, error) { + payload, err := state.CommandToPayload(cap, args) + if err != nil { + return nil, err + } + setTopic := z2m.DeviceTopics(a.cfg.BaseTopic, friendly).Set + if err := a.mq.Publish(setTopic, payload, false); err != nil { + return nil, fmt.Errorf("publish to %s: %w", setTopic, err) + } + // Don't update state here — the echo on the state topic + // arrives within ~100ms and goes through MergeState. + return nil, nil + }) + } + } + + // Subscribe ahead of any retained-state delivery so we don't race. + _ = a.mq.Subscribe(topics.State, a.onDeviceState) + _ = a.mq.Subscribe(topics.Availability, a.onDeviceAvailability) +} + +func (a *app) applyRemove(act state.UnregisterEntity) { + topics := z2m.DeviceTopics(a.cfg.BaseTopic, act.FriendlyName) + + a.cache.mu.Lock() + delete(a.cache.entities, act.EntityID) + delete(a.cache.friendlyByEnt, act.EntityID) + delete(a.cache.ieeeByEnt, act.EntityID) + listeners := a.cache.entityByTopic[topics.State] + pruned := listeners[:0] + for _, l := range listeners { + if l.EntityID != act.EntityID { + pruned = append(pruned, l) + } + } + if len(pruned) == 0 { + delete(a.cache.entityByTopic, topics.State) + _ = a.mq.Unsubscribe(topics.State) + _ = a.mq.Unsubscribe(topics.Availability) + } else { + a.cache.entityByTopic[topics.State] = pruned + } + a.cache.mu.Unlock() + + if err := a.d.UnregisterEntity(act.EntityID); err != nil && !errors.Is(err, driver.ErrEntityUnknown) { + slog.Warn("UnregisterEntity failed", "entity_id", act.EntityID, "error", err) + } +} + +func (a *app) applyUpdate(act state.UpdateAttrs) { + a.cache.mu.Lock() + prev := a.cache.entities[act.EntityID] + a.cache.mu.Unlock() + if prev == nil { + return + } + // Re-emit the same attrs; the EntityRegistered event is register-once, + // so renames don't propagate via the event log in v0.1. Documented. + _ = a.d.EmitState(act.EntityID, prev) +} + +// onDeviceState handles a per-device state-push payload by fanning it +// out to every entity that subscribes to the topic. +func (a *app) onDeviceState(topic string, payload []byte) { + var sp z2m.StatePayload + if err := json.Unmarshal(payload, &sp); err != nil { + slog.Warn("device state parse failed", "topic", topic, "error", err) + return + } + + a.cache.mu.Lock() + listeners := append([]entityListener(nil), a.cache.entityByTopic[topic]...) + a.cache.mu.Unlock() + + for _, l := range listeners { + a.applyStateUpdate(l, sp) + } +} + +func (a *app) applyStateUpdate(l entityListener, sp z2m.StatePayload) { + a.cache.mu.Lock() + prev := a.cache.entities[l.EntityID] + a.cache.mu.Unlock() + if prev == nil { + return + } + + next := prev + if l.Property == "" { + // Light: iterate all known properties in the payload. + for prop, raw := range sp { + n, err := state.MergeState(next, prop, raw) + if err != nil { + slog.Debug("MergeState skipped", "entity_id", l.EntityID, "property", prop, "error", err) + continue + } + next = n + } + } else { + raw, ok := sp[l.Property] + if !ok { + return // property not in this payload; ignore + } + n, err := state.MergeState(next, l.Property, raw) + if err != nil { + slog.Debug("MergeState failed", "entity_id", l.EntityID, "property", l.Property, "error", err) + return + } + next = n + } + if next == prev { + return // no change + } + + a.cache.mu.Lock() + a.cache.entities[l.EntityID] = next + a.cache.mu.Unlock() + if err := a.d.EmitState(l.EntityID, next); err != nil && !errors.Is(err, driver.ErrNotConnected) { + slog.Warn("EmitState failed", "entity_id", l.EntityID, "error", err) + } +} + +// onDeviceAvailability sets Available=true/false on every entity +// downstream of the device's state topic. +func (a *app) onDeviceAvailability(topic string, payload []byte) { + // Topic is ...//availability — strip the suffix to get the state topic. + stateTopic := strings.TrimSuffix(topic, "/availability") + + var av z2m.AvailabilityState + available := true + // Z2M can publish either {"state":"online"} or the bare string "online". + if err := json.Unmarshal(payload, &av); err == nil && av.State != "" { + available = av.State == "online" + } else { + s := strings.Trim(string(payload), `" `) + available = s == "online" + } + + a.cache.mu.Lock() + listeners := append([]entityListener(nil), a.cache.entityByTopic[stateTopic]...) + a.cache.mu.Unlock() + + for _, l := range listeners { + a.cache.mu.Lock() + prev := a.cache.entities[l.EntityID] + if prev == nil { + a.cache.mu.Unlock() + continue + } + next := proto.Clone(prev).(*entityv1.Attributes) + next.Available = available + a.cache.entities[l.EntityID] = next + a.cache.mu.Unlock() + _ = a.d.EmitState(l.EntityID, next) + } +} + +// onBridgeState marks every entity unavailable when the Z2M bridge +// itself goes offline. On return-to-online the next bridge/devices +// retained replay will restore correct per-entity availability. +func (a *app) onBridgeState(_ string, payload []byte) { + var bs z2m.BridgeStatePayload + if err := json.Unmarshal(payload, &bs); err != nil { + // Tolerate bare-string variant. + bs.State = strings.Trim(string(payload), `" `) + } + if bs.State == "online" { + _ = a.d.EmitDriverEvent("bridge_online", "") + return + } + _ = a.d.EmitDriverEvent("bridge_offline", "") + + a.cache.mu.Lock() + ids := make([]string, 0, len(a.cache.entities)) + for id := range a.cache.entities { + ids = append(ids, id) + } + a.cache.mu.Unlock() + + for _, id := range ids { + a.cache.mu.Lock() + prev := a.cache.entities[id] + if prev == nil { + a.cache.mu.Unlock() + continue + } + next := proto.Clone(prev).(*entityv1.Attributes) + next.Available = false + a.cache.entities[id] = next + a.cache.mu.Unlock() + _ = a.d.EmitState(id, next) + } +} + +// onBridgeEvent is informational — pairing / removal lifecycle is +// already covered by the bridge/devices retained payload. +func (a *app) onBridgeEvent(_ string, payload []byte) { + slog.Debug("bridge/event", "payload", string(payload)) } diff --git a/drivers/z2m/cmd/z2m-driver/main_test.go b/drivers/z2m/cmd/z2m-driver/main_test.go new file mode 100644 index 0000000..98fc79a --- /dev/null +++ b/drivers/z2m/cmd/z2m-driver/main_test.go @@ -0,0 +1,279 @@ +package main + +import ( + "context" + "encoding/json" + "net" + "os" + "path/filepath" + "sync" + "sync/atomic" + "testing" + "time" + + mqttserver "github.com/mochi-mqtt/server/v2" + "github.com/mochi-mqtt/server/v2/hooks/auth" + mqttlisteners "github.com/mochi-mqtt/server/v2/listeners" + "github.com/mochi-mqtt/server/v2/packets" + + "github.com/fdatoo/gohome-driverkit/driver" + "github.com/fdatoo/gohome-driverkit/drivertest" + + "github.com/fdatoo/gohome/drivers/z2m/internal/mqtt" +) + +const baseTopic = "zigbee2mqtt" + +// capturedPublish records one PUBLISH packet observed by the broker. +type capturedPublish struct { + topic string + payload []byte +} + +// startBroker brings up an in-process MQTT broker and returns +// (addr, server) so the test can publish from the broker side. +func startBroker(t *testing.T) (string, *mqttserver.Server) { + t.Helper() + server := mqttserver.New(&mqttserver.Options{InlineClient: true}) + if err := server.AddHook(new(auth.AllowHook), nil); err != nil { + t.Fatalf("AddHook: %v", err) + } + l, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatalf("listen: %v", err) + } + addr := l.Addr().String() + _ = l.Close() + tcp := mqttlisteners.NewTCP(mqttlisteners.Config{ID: "t1", Address: addr}) + if err := server.AddListener(tcp); err != nil { + t.Fatalf("AddListener: %v", err) + } + go func() { _ = server.Serve() }() + t.Cleanup(func() { _ = server.Close() }) + time.Sleep(50 * time.Millisecond) + return addr, server +} + +// publish helper: from-broker direction (simulates Z2M). +func publish(t *testing.T, server *mqttserver.Server, topic string, payload []byte, retained bool) { + t.Helper() + if err := server.Publish(topic, payload, retained, 0); err != nil { + t.Fatalf("server.Publish %s: %v", topic, err) + } +} + +// buildTestApp wires up an *app pointing at the test broker and a +// stand-in driverkit Driver. Returns the app, the driver, and a +// drivertest harness already connected. +func buildTestApp(t *testing.T, brokerAddr string) (*app, *driver.Driver, *drivertest.Harness) { + t.Helper() + mq, err := mqtt.New(mqtt.Config{ + BrokerURL: "tcp://" + brokerAddr, + ClientID: "test-driver", + }) + if err != nil { + t.Fatalf("mqtt.New: %v", err) + } + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + if err := mq.Connect(ctx); err != nil { + t.Fatalf("mq.Connect: %v", err) + } + t.Cleanup(mq.Close) + + d := driver.New(driverName, driverVersion) + a := &app{ + cfg: config{BaseTopic: baseTopic}, + mq: mq, + d: d, + cache: newStateCache(), + } + a.subscribeBridgeTopics() + + h := drivertest.New(t, d) + t.Cleanup(h.Close) + return a, d, h +} + +func loadFixturePayload(t *testing.T, name string) []byte { + t.Helper() + raw, err := os.ReadFile(filepath.Join("..", "..", "internal", "z2m", "testdata", name)) + if err != nil { + t.Fatalf("read %s: %v", name, err) + } + return raw +} + +// suppressUnused stops the linter complaining about unused imports +// when individual tests don't reach for every helper. +var _ = packets.Properties{} +var _ atomic.Bool +var _ sync.Mutex + +// ---- tests ---- + +func TestZ2M_InitialReconcile(t *testing.T) { + addr, server := startBroker(t) + a, _, h := buildTestApp(t, addr) + _ = a + + // Publish the fixture as retained on bridge/devices. + publish(t, server, baseTopic+"/bridge/devices", + loadFixturePayload(t, "bridge_devices.json"), true) + + // drivertest's harness only sees entities at handshake time. Open + // a fresh harness AFTER reconciliation has run so we observe the + // post-add state. + deadline := time.Now().Add(5 * time.Second) + for time.Now().Before(deadline) { + a.cache.mu.Lock() + n := len(a.cache.entities) + a.cache.mu.Unlock() + if n >= 8 { // 1 light + 4 motion + 2 contact + 1 plug-power + break + } + time.Sleep(20 * time.Millisecond) + } + a.cache.mu.Lock() + got := len(a.cache.entities) + a.cache.mu.Unlock() + if got != 8 { + t.Errorf("entity count after initial reconcile: got %d, want 8", got) + } + _ = h +} + +func TestZ2M_TurnOnRoundtrip(t *testing.T) { + addr, server := startBroker(t) + a, _, _ := buildTestApp(t, addr) + + // Capture publishes from the driver to the broker — Z2M-side observer. + pub := make(chan capturedPublish, 16) + if err := server.AddHook(&capturingHook{out: pub}, nil); err != nil { + t.Fatalf("AddHook: %v", err) + } + + publish(t, server, baseTopic+"/bridge/devices", + loadFixturePayload(t, "bridge_devices.json"), true) + // Wait for entities to register. + waitFor(t, func() bool { + a.cache.mu.Lock() + _, ok := a.cache.friendlyByEnt["light.z2m_01234abc"] + a.cache.mu.Unlock() + return ok + }) + + // Reconnect drivertest so it sees the registered entity. + h := drivertest.New(t, a.d) + defer h.Close() + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + res, err := h.SendCommand(ctx, "light.z2m_01234abc", "turn_on", nil) + if err != nil { + t.Fatalf("SendCommand: %v", err) + } + if !res.GetOk() { + t.Errorf("turn_on returned ok=false: %s", res.GetErrorMessage()) + } + + // Drain observed publishes; expect one /set publish containing state:ON. + deadline := time.Now().Add(5 * time.Second) + var seenSet bool + for time.Now().Before(deadline) && !seenSet { + select { + case p := <-pub: + if p.topic == baseTopic+"/kitchen_light/set" { + var decoded map[string]any + _ = json.Unmarshal(p.payload, &decoded) + if decoded["state"] == "ON" { + seenSet = true + } + } + case <-time.After(50 * time.Millisecond): + } + } + if !seenSet { + t.Error("did not observe /set publish with state:ON") + } +} + +func TestZ2M_HotAddRemove(t *testing.T) { + addr, server := startBroker(t) + a, _, _ := buildTestApp(t, addr) + + // Publish a single-device list. + one := []byte(`[{"ieee_address":"0x00158d0001234abc","friendly_name":"kitchen_light","type":"Router","definition":{"vendor":"","model":"","description":"","exposes":[]}}]`) + publish(t, server, baseTopic+"/bridge/devices", one, true) + waitFor(t, func() bool { + a.cache.mu.Lock() + defer a.cache.mu.Unlock() + return len(a.cache.devices) == 1 + }) + + // Republish with a different device — old should disappear, new should appear. + two := []byte(`[{"ieee_address":"0x00158d0009876543","friendly_name":"hallway_motion","type":"EndDevice","definition":{"vendor":"","model":"","description":"","exposes":[{"type":"binary","name":"occupancy","property":"occupancy","access":1}]}}]`) + publish(t, server, baseTopic+"/bridge/devices", two, true) + waitFor(t, func() bool { + a.cache.mu.Lock() + defer a.cache.mu.Unlock() + _, oldGone := a.cache.devices["0x00158d0001234abc"] + _, newPresent := a.cache.devices["0x00158d0009876543"] + return !oldGone && newPresent + }) +} + +func TestZ2M_BridgeOfflineMarksEntitiesUnavailable(t *testing.T) { + addr, server := startBroker(t) + a, _, _ := buildTestApp(t, addr) + + publish(t, server, baseTopic+"/bridge/devices", + loadFixturePayload(t, "bridge_devices.json"), true) + waitFor(t, func() bool { + a.cache.mu.Lock() + defer a.cache.mu.Unlock() + return len(a.cache.entities) >= 8 + }) + + publish(t, server, baseTopic+"/bridge/state", []byte(`{"state":"offline"}`), true) + waitFor(t, func() bool { + a.cache.mu.Lock() + defer a.cache.mu.Unlock() + for _, attrs := range a.cache.entities { + if attrs.GetAvailable() { + return false + } + } + return true + }) +} + +// ---- helpers ---- + +// capturingHook is a mochi hook that records every PUBLISH the driver +// sends to the broker (i.e. every /set publish in the integration tests). +type capturingHook struct { + mqttserver.HookBase + out chan<- capturedPublish +} + +func (h *capturingHook) ID() string { return "capturing" } +func (h *capturingHook) Provides(b byte) bool { return b == mqttserver.OnPublished } +func (h *capturingHook) OnPublished(_ *mqttserver.Client, pk packets.Packet) { + select { + case h.out <- capturedPublish{topic: pk.TopicName, payload: append([]byte(nil), pk.Payload...)}: + default: + } +} + +func waitFor(t *testing.T, cond func() bool) { + t.Helper() + deadline := time.Now().Add(5 * time.Second) + for time.Now().Before(deadline) { + if cond() { + return + } + time.Sleep(20 * time.Millisecond) + } + t.Fatal("waitFor: condition never became true") +} diff --git a/go.work.sum b/go.work.sum index b93bde1..93f7882 100644 --- a/go.work.sum +++ b/go.work.sum @@ -3,21 +3,31 @@ cloud.google.com/go/compute/metadata v0.9.0/go.mod h1:E0bWwX5wTnLPedCKqk3pJmVgCB filippo.io/edwards25519 v1.2.0/go.mod h1:xzAOLCNug/yB62zG1bQ8uziwrIqIuxhctzJT18Q77mc= github.com/ClickHouse/ch-go v0.71.0/go.mod h1:NwbNc+7jaqfY58dmdDUbG4Jl22vThgx1cYjBw0vtgXw= github.com/ClickHouse/clickhouse-go/v2 v2.43.0/go.mod h1:o6jf7JM/zveWC/PP277BLxjHy5KjnGX/jfljhM4s34g= +github.com/DataDog/zstd v1.4.5/go.mod h1:1jcaCB/ufaK+sKp1NBhlGmpz41jOoPQ35bpF36t7BBo= github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.30.0/go.mod h1:P4WPRUkOhJC13W//jWpyfJNDAIpvRbAUIYLX/4jtlE0= github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= github.com/alecthomas/kingpin/v2 v2.4.0/go.mod h1:0gyi0zQnjuFk8xrkNKamJoyUo382HRL7ATRpFZCw6tE= github.com/alecthomas/units v0.0.0-20211218093645-b94a6e3cc137/go.mod h1:OMCwj8VM1Kc9e19TLln2VL61YJF0x1XFtfdL4JdbSyE= +github.com/alicebob/gopher-json v0.0.0-20200520072559-a9ecdc9d1d3a/go.mod h1:SGnFV6hVsYE877CKEZ6tDNTjaSXYUk6QqoIK6PrAtcc= +github.com/alicebob/miniredis/v2 v2.23.0/go.mod h1:XNqvJdQJv5mSuVMc0ynneafpnL/zv52acZ6kqeS0t88= github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY= github.com/antlr4-go/antlr/v4 v4.13.1/go.mod h1:GKmUxMtwp6ZgGwZSva4eWPC5mS6vUAmOABFgjdkM7Nw= github.com/chzyer/readline v1.5.1/go.mod h1:Eh+b79XXUwfKfcPLepksvw2tcLE/Ct21YObkaSkeBlk= github.com/cncf/xds/go v0.0.0-20251210132809-ee656c7534f5/go.mod h1:KdCmV+x/BuvyMxRnYBlmVaq4OLiKW6iRQfvC62cvdkI= +github.com/cockroachdb/errors v1.11.1/go.mod h1:8MUxA3Gi6b25tYlFEBGLf+D8aISL+M4MIpiWMSNRfxw= +github.com/cockroachdb/logtags v0.0.0-20230118201751-21c54148d20b/go.mod h1:Vz9DsVWQQhf3vs21MhPMZpMGSht7O/2vFW2xusFUVOs= +github.com/cockroachdb/pebble v1.1.0/go.mod h1:sEHm5NOXxyiAoKWhoFxT8xMgd/f3RA6qUqQ1BXKrh2E= +github.com/cockroachdb/redact v1.1.5/go.mod h1:BVNblN9mBWFyMyqK1k3AAiSxhvhfK2oOZZ2lK+dpvRg= +github.com/cockroachdb/tokenbucket v0.0.0-20230807174530-cc333fc44b06/go.mod h1:7nc4anLGjupUW/PeY5qiNYsdNXj7zopG+eqsS7To5IQ= github.com/coder/websocket v1.8.14/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg= github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M= github.com/containerd/errdefs/pkg v0.3.0/go.mod h1:NJw6s9HwNuRhnjJhM7pylWwMyAkmCQvQ4GpJHEqRLVk= +github.com/dgraph-io/badger/v4 v4.2.0/go.mod h1:qfCqhPoWDFJRx1gp5QwwyGo8xk1lbHUxvK9nK0OGAak= +github.com/dgraph-io/ristretto v0.1.1/go.mod h1:S1GPSBCYCIhmVNfcth17y2zZtQT6wzkzgwUve0VDWWA= +github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc= github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E= github.com/docker/go-connections v0.6.0/go.mod h1:AahvXYshr6JgfUJGdDCs2b5EZG/vmaMAntpSFH5BFKE= github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk= -github.com/eclipse/paho.mqtt.golang v1.5.1/go.mod h1:1/yJCneuyOoCOzKSsOTUc0AJfpsItBGWvYpBLimhArU= github.com/elastic/go-sysinfo v1.15.4/go.mod h1:ZBVXmqS368dOn/jvijV/zHLfakWTYHBZPk3G244lHrU= github.com/elastic/go-windows v1.0.2/go.mod h1:bGcDpBzXgYSqM0Gx3DM4+UxFj300SZLixie9u9ixLM8= github.com/envoyproxy/go-control-plane v0.14.0/go.mod h1:NcS5X47pLl/hfqxU70yPwL9ZMkUlwlKxtAohpi2wBEU= @@ -25,30 +35,36 @@ github.com/envoyproxy/go-control-plane/envoy v1.36.0/go.mod h1:ty89S1YCCVruQAm9O github.com/envoyproxy/go-control-plane/ratelimit v0.1.0/go.mod h1:Wk+tMFAFbCXaJPzVVHnPgRKdUdwW/KdbRt94AzgRee4= github.com/envoyproxy/protoc-gen-validate v1.3.0/go.mod h1:HvYl7zwPa5mffgyeTUHA9zHIH36nmrm7oCbo4YKoSWA= github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= +github.com/getsentry/sentry-go v0.18.0/go.mod h1:Kgon4Mby+FJ7ZWHFUAZgVaIa8sxHtnRJRLTXZr51aKQ= github.com/go-faster/city v1.0.1/go.mod h1:jKcUJId49qdW3L1qKHH/3wPeUstCVpVSXTM6vO3VcTw= github.com/go-faster/errors v0.7.1/go.mod h1:5ySTjWFiphBs07IKuiL69nxdfd5+fzh1u7FPGZP2quo= github.com/go-jose/go-jose/v4 v4.1.3/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08= +github.com/go-redis/redis/v8 v8.11.5/go.mod h1:gREzHqY1hg6oD9ngVRbLStwAWKhA0FEgq8Jd4h5lpwo= github.com/go-sql-driver/mysql v1.9.3/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI64Gl8i5p1WMU= +github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= github.com/golang-jwt/jwt/v4 v4.5.2/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0= github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9/go.mod h1:8vg3r2VgvsThLBIFL93Qb5yWzgyZWhEmBwUJWevAkK0= github.com/golang-sql/sqlexp v0.1.0/go.mod h1:J4ad9Vo8ZCWQ2GMrC4UCQy1JpCbwU9m3EOqtpKwwwHI= github.com/golang/glog v1.2.5/go.mod h1:6AhwSGph0fcJtXVM/PEHPqZlFeoLxhs7/t5UDAwmO+w= -github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= +github.com/golang/groupcache v0.0.0-20200121045136-8c9f03a8e57e/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= +github.com/golang/snappy v0.0.4/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= +github.com/google/flatbuffers v1.12.1/go.mod h1:1AeVuKshWv4vARoZatz6mlQ0JxURH0Kv5+zNeJKJCa8= github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= github.com/jackc/pgx/v5 v5.8.0/go.mod h1:QVeDInX2m9VyzvNeiCJVjCkNFqzsNb43204HshNSZKw= github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= +github.com/jinzhu/copier v0.3.5/go.mod h1:DfbEm0FYsaqBcKcFuvmOZb218JkPGtvSHsKg8S8hyyg= github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= github.com/jonboulle/clockwork v0.5.0/go.mod h1:3mZlmanh0g2NDKO5TWZVJAfofYk64M7XN3SzBPjZF60= github.com/jpillora/backoff v1.0.0/go.mod h1:J/6gKK9jxlEcS3zixgDgUAsiuZ7yrSoa/FX5e0EB2j4= github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= github.com/julienschmidt/httprouter v1.3.0/go.mod h1:JR6WtHb+2LUe8TCKY3cZOxFyyO8IZAc4RVcycCCAKdM= +github.com/matttproud/golang_protobuf_extensions v1.0.2-0.20181231171920-c182affec369/go.mod h1:BSXmuO+STAnVfrANrmjBb36TMTDstsz7MSK+HVaYKv4= github.com/mfridman/xflag v0.1.0/go.mod h1:/483ywM5ZO5SuMVjrIGquYNE5CzLrj5Ux/LxWWnjRaE= github.com/microsoft/go-mssqldb v1.9.6/go.mod h1:yYMPDufyoF2vVuVCUGtZARr06DKFIhMrluTcgWlXpr4= github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo= github.com/moby/moby/api v1.53.0/go.mod h1:8mb+ReTlisw4pS6BRzCMts5M49W5M7bKt1cJy/YbAqc= github.com/moby/moby/client v0.2.2/go.mod h1:2EkIPVNCqR05CMIzL1mfA07t0HvVUUOl85pasRz/GmQ= -github.com/mochi-mqtt/server/v2 v2.7.9/go.mod h1:lZD3j35AVNqJL5cezlnSkuG05c0FCHSsfAKSPBOSbqc= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= github.com/mwitkow/go-conntrack v0.0.0-20190716064945-2f068394615f/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U= @@ -56,8 +72,8 @@ github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3I github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M= github.com/paulmach/orb v0.12.0/go.mod h1:5mULz1xQfs3bmQm63QEJA6lNGujuRafwA5S/EnuLaLU= github.com/pierrec/lz4/v4 v4.1.25/go.mod h1:EoQMVJgeeEOMsCqCzqFm2O0cJvljX2nGZjcRIPL34O4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10/go.mod h1:t/avpk3KcrXxUnYOhZhMXJlSEyie6gQbtLq5NM3loB8= -github.com/rs/xid v1.4.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg= github.com/shopspring/decimal v1.4.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME= github.com/spiffe/go-spiffe/v2 v2.6.0/go.mod h1:gm2SeUoMZEtpnzPNs2Csc0D/gX33k1xIx7lEzqblHEs= github.com/tursodatabase/libsql-client-go v0.0.0-20251219100830-236aa1ff8acc/go.mod h1:08inkKyguB6CGGssc/JzhmQWwBgFQBgjlYFjxjRh7nU= @@ -65,7 +81,10 @@ github.com/vertica/vertica-sql-go v1.3.5/go.mod h1:jnn2GFuv+O2Jcjktb7zyc4Utlbu9Y github.com/xhit/go-str2duration/v2 v2.1.0/go.mod h1:ohY8p+0f07DiV6Em5LKB0s2YpLtXVyJfNt1+BlmyAsU= github.com/ydb-platform/ydb-go-genproto v0.0.0-20260128080146-c4ed16b24b37/go.mod h1:Er+FePu1dNUieD+XTMDduGpQuCPssK5Q4BjF+IIXJ3I= github.com/ydb-platform/ydb-go-sdk/v3 v3.127.0/go.mod h1:stS1mQYjbJvwwYaYzKyFY9eMiuVXWWXQA6T+SpOLg9c= +github.com/yuin/gopher-lua v0.0.0-20210529063254-f4c35e4016d9/go.mod h1:E1AXubJBdNmFERAOucpDIxNzeGfLzg0mYh+UfMWdChA= github.com/ziutek/mymysql v1.5.4/go.mod h1:LMSpPZ6DbqWFxNCHW77HeMg9I646SAhApZ/wKdgO/C0= +go.etcd.io/bbolt v1.3.5/go.mod h1:G5EMThwa9y8QZGBClrRx5EY+Yw9kAhnjy3bSjsnlVTQ= +go.opencensus.io v0.22.5/go.mod h1:5pWMHQbX5EPX2/62yrJeAkowc+lfs/XD7Uxpq3pI6kk= go.opentelemetry.io/contrib/detectors/gcp v1.39.0/go.mod h1:t/OGqzHBa5v6RHZwrDBJ2OirWc+4q/w2fTbLZwAKjTk= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.65.0/go.mod h1:c7hN3ddxs/z6q9xwvfLPk+UHlWRQyaeR1LdgfL/66l0= golang.org/x/term v0.42.0/go.mod h1:Dq/D+snpsbazcBG5+F9Q1n2rXV8Ma+71xEjTRufARgY= From d092672144a9baf86eb4d6e815ff0514bffa04a8 Mon Sep 17 00:00:00 2001 From: Fynn Datoo Date: Fri, 1 May 2026 00:55:08 -0700 Subject: [PATCH 14/16] docs(z2m): driver README and first-party catalog entry Adds drivers/z2m/README.md with quick-start + caveats and updates the existing zigbee2mqtt entry in docs/docs/drivers/first-party.md to reflect the actual driver (driver.z2m, env-var config, three device classes, known limitations). Co-Authored-By: Claude Opus 4.7 (1M context) --- docs/docs/drivers/first-party.md | 29 +++++++++-------- drivers/z2m/README.md | 56 ++++++++++++++++++++++++++++++++ 2 files changed, 71 insertions(+), 14 deletions(-) create mode 100644 drivers/z2m/README.md diff --git a/docs/docs/drivers/first-party.md b/docs/docs/drivers/first-party.md index 6a59d1a..884df39 100644 --- a/docs/docs/drivers/first-party.md +++ b/docs/docs/drivers/first-party.md @@ -34,30 +34,31 @@ A general-purpose MQTT publish/subscribe driver. Register any topic as an entity --- -### Zigbee2MQTT (`driver.zigbee2mqtt`) +### Zigbee2MQTT (`driver.z2m`) !!! status-alpha "Alpha — shipped, interface evolving" -Integrates with a running [Zigbee2MQTT](https://www.zigbee2mqtt.io/) bridge. Devices paired to the bridge are automatically discovered and registered as gohome entities. State changes and commands flow through the bridge's MQTT topics using the Zigbee2MQTT API. +Mirrors a [Zigbee2MQTT](https://www.zigbee2mqtt.io/) deployment into gohome over the MQTT broker that Z2M publishes to. Discovers all paired devices on startup, then reconciles live via the retained `bridge/devices` topic. v0.1 surfaces three device classes: lights (`light.*`), numeric sensors (`numeric_sensor.*`), and binary sensors (`binary_sensor.*`). -**Config fields** +**Config fields (env)** -| Field | Type | Required | Description | +| Variable | Required | Default | Purpose | |---|---|---|---| -| `mqtt_broker_url` | `string` | yes | URL of the MQTT broker used by Zigbee2MQTT | -| `base_topic` | `string` | no | Zigbee2MQTT base topic. Defaults to `zigbee2mqtt` | -| `username` | `string` | no | MQTT username | -| `password_env` | `string` | no | Env var containing the MQTT password | -| `friendly_name_prefix` | `string` | no | Prefix stripped from Zigbee2MQTT friendly names when forming entity IDs | -| `permit_join` | `bool` | no | If `true`, the driver can send permit-join commands via CLI | +| `Z2M_BROKER_URL` | yes | — | `tcp://host:1883` or `ssl://host:8883` | +| `Z2M_USERNAME` | no | — | MQTT broker username | +| `Z2M_PASSWORD` | no | — | MQTT broker password | +| `Z2M_BASE_TOPIC` | no | `zigbee2mqtt` | Z2M's `mqtt.base_topic` setting | +| `Z2M_CLIENT_ID` | no | `gohome-z2m-` | MQTT client identifier | +| `Z2M_TLS_SKIP_VERIFY` | no | `false` | Skip TLS verification | **Known caveats** -- Requires Zigbee2MQTT to be running independently; the driver does not manage the bridge process. -- Entity discovery happens on startup and when new devices are paired — no hot-reload without driver restart. -- Device availability is polled via the `zigbee2mqtt/bridge/devices` topic; brief disconnects may leave entities in a stale state until the next availability update. +- New devices paired in Z2M are picked up automatically; no driver restart needed. +- Smart-plug actuators (writable `state`) are out of scope in v0.1 — read-only sub-properties (`power`, `energy`) still surface. +- Per-device availability depends on Z2M's availability feature being enabled server-side; otherwise entities default to `Available=true`. +- `/set` publishes are best-effort: a successful publish is reported as `ok=true` even if Z2M silently ignores the command (no MQTT 5 request/response in v0.1). -[Source repo](https://github.com/fynn-labs/driver-zigbee2mqtt) +[Source repo](https://github.com/fdatoo/gohome/tree/main/drivers/z2m) --- diff --git a/drivers/z2m/README.md b/drivers/z2m/README.md new file mode 100644 index 0000000..0283c29 --- /dev/null +++ b/drivers/z2m/README.md @@ -0,0 +1,56 @@ +# driver-z2m + +GoHome Carport driver for [Zigbee2MQTT](https://www.zigbee2mqtt.io/). + +- One driver instance = one Z2M deployment (= one MQTT broker hosting it). +- Discovers all paired devices automatically; hot add/remove is live. +- Surfaces three device classes in v0.1: lights, numeric sensors, binary sensors. + +## Quick start + +### 1. Reach your Z2M's MQTT broker + +The driver does not talk to Z2M directly — it talks to whatever MQTT broker Z2M publishes to (Mosquitto, EMQX, NanoMQ, etc.). You need: + +- Broker URL (`tcp://host:1883` or `ssl://host:8883`). +- Optional username/password. +- The base topic Z2M is configured with — almost always `zigbee2mqtt`, configurable in Z2M's `configuration.yaml` under `mqtt.base_topic`. + +### 2. Configure the driver + +The driver reads the following environment variables, set by `gohomed`: + +| Variable | Required | Default | Purpose | +|---|---|---|---| +| `Z2M_BROKER_URL` | yes | — | `tcp://host:1883` or `ssl://host:8883` | +| `Z2M_USERNAME` | no | — | MQTT broker username | +| `Z2M_PASSWORD` | no | — | MQTT broker password | +| `Z2M_BASE_TOPIC` | no | `zigbee2mqtt` | Z2M's `mqtt.base_topic` setting | +| `Z2M_CLIENT_ID` | no | `gohome-z2m-` | MQTT client identifier | +| `Z2M_TLS_SKIP_VERIFY` | no | `false` | Skip TLS verification (self-signed brokers) | + +## What gets surfaced + +- **Lights** become a single `light.*` entity per device, with `turn_on`, `turn_off`, `set_brightness` (if dimmable), `set_color_temp` (if tunable-white), and `set_color` (if RGB-capable). +- **Numeric properties** (`temperature`, `humidity`, `illuminance`, `battery`, `pressure`, `power`, `energy`, `current`) become read-only `numeric_sensor.*` entities. +- **Binary properties** (`occupancy`, `contact`, `water_leak`, `smoke`, `tamper`, `vibration`) become read-only `binary_sensor.*` entities. +- **Multi-property devices fan out**: a motion sensor exposing occupancy + temperature + humidity + battery yields four entities. + +## Out of scope (v0.1) + +- Z2M network management (pairing, removal, OTA updates, name changes from gohome). Use the Z2M dashboard or its own MQTT API directly. +- Action sensors (`action: "single"`, etc.) — these are events, not state. +- Climate, cover, lock, fan device classes (no proto support yet). +- Switch / smart-plug actuators (writable `state` properties). Smart plugs that also expose `power`/`energy` will surface those read-only entities; the writable `state` is logged once at INFO and skipped. + +## Known caveats + +- New devices paired in Z2M show up automatically — no driver restart needed. +- If Z2M is configured to publish state non-retained (recent default), entity state stays at the mapper-assigned defaults until the device's next state change. Toggling the device once seeds it; subsequent state is live. +- Per-device `availability` requires Z2M's availability feature to be enabled server-side. Without it, entities default to `Available=true` and can drift if a battery device dies — Z2M's `bridge/devices` topic doesn't carry liveness on its own. +- A successful publish to `//set` is reported as `ok=true`. If Z2M silently ignores the command (invalid friendly_name, device unreachable, etc.), gohome won't know — there's no MQTT 5 request/response in v0.1. +- A device that adds a property after pairing (firmware update) is not picked up until the driver restarts. + +## Source + +[`drivers/z2m/`](.) in the gohome monorepo. From 73ab24870b5c5a431e9c517e8b220178271c4a15 Mon Sep 17 00:00:00 2001 From: Fynn Datoo Date: Fri, 1 May 2026 01:34:21 -0700 Subject: [PATCH 15/16] refactor(hue,z2m): consume GOHOME_CARPORT_INSTANCE_CONFIG JSON MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both drivers previously read driver-specific env vars (HUE_BRIDGE_ADDRESS, Z2M_BROKER_URL, ...) directly. The supervisor only sets the four GOHOME_CARPORT_* env vars when launching a driver, so those reads worked only via accidental shell inheritance — without per-instance scoping. Switch both drivers to the canonical mechanism: parse the JSON blob in GOHOME_CARPORT_INSTANCE_CONFIG into a typed config struct. Secrets stay out of the config blob — they live in named env vars referenced by *_env fields (password_env, api_key_env), matching the existing convention in the first-party catalog page and the MQTT driver entry. Operational toggles (HUE_LOG_LEVEL, Z2M_LOG_LEVEL) remain env vars since they're per-process, not per-instance. READMEs and the first-party catalog page updated to document the JSON shape with example TOML config_json blocks. Co-Authored-By: Claude Opus 4.7 (1M context) --- docs/docs/drivers/first-party.md | 16 ++++----- drivers/hue/README.md | 32 ++++++++++++++---- drivers/hue/cmd/hue-driver/main.go | 52 ++++++++++++++++++------------ drivers/z2m/README.md | 41 +++++++++++++++++------ drivers/z2m/cmd/z2m-driver/main.go | 45 ++++++++++++-------------- 5 files changed, 118 insertions(+), 68 deletions(-) diff --git a/docs/docs/drivers/first-party.md b/docs/docs/drivers/first-party.md index 884df39..7e44d48 100644 --- a/docs/docs/drivers/first-party.md +++ b/docs/docs/drivers/first-party.md @@ -40,16 +40,16 @@ A general-purpose MQTT publish/subscribe driver. Register any topic as an entity Mirrors a [Zigbee2MQTT](https://www.zigbee2mqtt.io/) deployment into gohome over the MQTT broker that Z2M publishes to. Discovers all paired devices on startup, then reconciles live via the retained `bridge/devices` topic. v0.1 surfaces three device classes: lights (`light.*`), numeric sensors (`numeric_sensor.*`), and binary sensors (`binary_sensor.*`). -**Config fields (env)** +**Config fields** -| Variable | Required | Default | Purpose | +| Field | Type | Required | Description | |---|---|---|---| -| `Z2M_BROKER_URL` | yes | — | `tcp://host:1883` or `ssl://host:8883` | -| `Z2M_USERNAME` | no | — | MQTT broker username | -| `Z2M_PASSWORD` | no | — | MQTT broker password | -| `Z2M_BASE_TOPIC` | no | `zigbee2mqtt` | Z2M's `mqtt.base_topic` setting | -| `Z2M_CLIENT_ID` | no | `gohome-z2m-` | MQTT client identifier | -| `Z2M_TLS_SKIP_VERIFY` | no | `false` | Skip TLS verification | +| `broker_url` | `string` | yes | `tcp://host:1883` or `ssl://host:8883` | +| `username` | `string` | no | MQTT broker username | +| `password_env` | `string` | no | Env var containing the MQTT password | +| `base_topic` | `string` | no | Z2M's `mqtt.base_topic` setting (default `zigbee2mqtt`) | +| `client_id` | `string` | no | MQTT client identifier (default `gohome-z2m-`) | +| `tls_skip_verify` | `bool` | no | Skip TLS verification (default `false`) | **Known caveats** diff --git a/drivers/hue/README.md b/drivers/hue/README.md index 11b1e66..814d74c 100644 --- a/drivers/hue/README.md +++ b/drivers/hue/README.md @@ -34,13 +34,33 @@ The `username` field is your API key. Store it in your secret manager and refere ### 3. Configure the driver -The driver reads three environment variables, set by `gohomed`: +The driver receives its instance config as a JSON blob in the `GOHOME_CARPORT_INSTANCE_CONFIG` environment variable, which `gohomed` populates from the carport instance's `config_json` TOML field. + +| Field | Type | Required | Default | Purpose | +|---|---|---|---|---| +| `bridge_address` | string | yes | — | IP or hostname of the bridge | +| `api_key_env` | string | yes | — | Name of an env var holding the API key (referenced indirectly so secrets stay out of config files) | +| `tls_skip_verify` | bool | no | `true` | The bridge ships a self-signed cert | + +Example carport instance TOML: + +```toml +[[carport.instances]] +id = "hue-living-room" +binary = "/usr/local/bin/hue-driver" +config_json = ''' +{ + "bridge_address": "192.168.1.10", + "api_key_env": "HUE_API_KEY" +} +''' +``` + +Operational env vars (independent of the JSON config): -| Variable | Required | Default | Purpose | -|---|---|---|---| -| `HUE_BRIDGE_ADDRESS` | yes | — | IP or hostname of the bridge. | -| `HUE_API_KEY` | yes | — | Application key from step 2. | -| `HUE_TLS_SKIP_VERIFY` | no | `true` | The bridge ships a self-signed cert. | +| Variable | Default | Purpose | +|---|---|---| +| `HUE_LOG_LEVEL` | `info` | `debug` / `info` / `warn` / `error` | ## Known caveats diff --git a/drivers/hue/cmd/hue-driver/main.go b/drivers/hue/cmd/hue-driver/main.go index c402ba4..ad8b7a3 100644 --- a/drivers/hue/cmd/hue-driver/main.go +++ b/drivers/hue/cmd/hue-driver/main.go @@ -5,12 +5,12 @@ package main import ( "context" + "encoding/json" "errors" "fmt" "log/slog" "os" "os/signal" - "strconv" "strings" "sync" "syscall" @@ -32,7 +32,7 @@ func main() { os.Exit(1) } - client, err := bridge.New(cfg.Address, cfg.APIKey, cfg.TLSSkipVerify) + client, err := bridge.New(cfg.Address, cfg.APIKey, *cfg.TLSSkipVerify) if err != nil { fmt.Fprintf(os.Stderr, "hue-driver: bridge: %v\n", err) os.Exit(1) @@ -92,31 +92,43 @@ func parseLogLevel(s string) slog.Level { } } -// config holds parsed environment variables. +// config is the JSON shape carried in GOHOME_CARPORT_INSTANCE_CONFIG. +// APIKey is resolved at load time from the env var named by APIKeyEnv; +// it is never serialized. TLSSkipVerify uses a pointer so the JSON-omitted +// case is distinguishable from an explicit false (default is true — the +// bridge ships a self-signed cert). type config struct { - Address string - APIKey string - TLSSkipVerify bool + Address string `json:"bridge_address"` + APIKeyEnv string `json:"api_key_env"` + TLSSkipVerify *bool `json:"tls_skip_verify,omitempty"` + + APIKey string `json:"-"` } func loadConfig() (config, error) { - addr := os.Getenv("HUE_BRIDGE_ADDRESS") - if addr == "" { - return config{}, errors.New("HUE_BRIDGE_ADDRESS is required") + raw := os.Getenv("GOHOME_CARPORT_INSTANCE_CONFIG") + if raw == "" { + return config{}, errors.New("GOHOME_CARPORT_INSTANCE_CONFIG is required") } - key := os.Getenv("HUE_API_KEY") - if key == "" { - return config{}, errors.New("HUE_API_KEY is required") + var c config + if err := json.Unmarshal([]byte(raw), &c); err != nil { + return config{}, fmt.Errorf("parse instance config: %w", err) } - skip := true - if v := os.Getenv("HUE_TLS_SKIP_VERIFY"); v != "" { - b, err := strconv.ParseBool(v) - if err != nil { - return config{}, errors.New("HUE_TLS_SKIP_VERIFY must be a boolean") - } - skip = b + if c.Address == "" { + return config{}, errors.New("bridge_address is required") + } + if c.APIKeyEnv == "" { + return config{}, errors.New("api_key_env is required") + } + c.APIKey = os.Getenv(c.APIKeyEnv) + if c.APIKey == "" { + return config{}, fmt.Errorf("api_key_env %q is unset or empty", c.APIKeyEnv) + } + if c.TLSSkipVerify == nil { + t := true + c.TLSSkipVerify = &t } - return config{Address: addr, APIKey: key, TLSSkipVerify: skip}, nil + return c, nil } // reachabilityTracker debounces bridge_unreachable / bridge_recovered diff --git a/drivers/z2m/README.md b/drivers/z2m/README.md index 0283c29..417d6a9 100644 --- a/drivers/z2m/README.md +++ b/drivers/z2m/README.md @@ -18,16 +18,37 @@ The driver does not talk to Z2M directly — it talks to whatever MQTT broker Z2 ### 2. Configure the driver -The driver reads the following environment variables, set by `gohomed`: - -| Variable | Required | Default | Purpose | -|---|---|---|---| -| `Z2M_BROKER_URL` | yes | — | `tcp://host:1883` or `ssl://host:8883` | -| `Z2M_USERNAME` | no | — | MQTT broker username | -| `Z2M_PASSWORD` | no | — | MQTT broker password | -| `Z2M_BASE_TOPIC` | no | `zigbee2mqtt` | Z2M's `mqtt.base_topic` setting | -| `Z2M_CLIENT_ID` | no | `gohome-z2m-` | MQTT client identifier | -| `Z2M_TLS_SKIP_VERIFY` | no | `false` | Skip TLS verification (self-signed brokers) | +The driver receives its instance config as a JSON blob in the `GOHOME_CARPORT_INSTANCE_CONFIG` environment variable, which `gohomed` populates from the carport instance's `config_json` TOML field. + +| Field | Type | Required | Default | Purpose | +|---|---|---|---|---| +| `broker_url` | string | yes | — | `tcp://host:1883` or `ssl://host:8883` | +| `username` | string | no | — | MQTT broker username | +| `password_env` | string | no | — | Name of an env var holding the MQTT password (referenced indirectly so secrets stay out of config files) | +| `base_topic` | string | no | `zigbee2mqtt` | Z2M's `mqtt.base_topic` setting | +| `client_id` | string | no | `gohome-z2m-` | MQTT client identifier | +| `tls_skip_verify` | bool | no | `false` | Skip TLS verification (self-signed brokers) | + +Example carport instance TOML: + +```toml +[[carport.instances]] +id = "z2m-home" +binary = "/usr/local/bin/z2m-driver" +config_json = ''' +{ + "broker_url": "tcp://10.0.0.5:1883", + "username": "gohome", + "password_env": "Z2M_PASSWORD" +} +''' +``` + +Operational env vars (independent of the JSON config): + +| Variable | Default | Purpose | +|---|---|---| +| `Z2M_LOG_LEVEL` | `info` | `debug` / `info` / `warn` / `error` | ## What gets surfaced diff --git a/drivers/z2m/cmd/z2m-driver/main.go b/drivers/z2m/cmd/z2m-driver/main.go index a6793e5..efef626 100644 --- a/drivers/z2m/cmd/z2m-driver/main.go +++ b/drivers/z2m/cmd/z2m-driver/main.go @@ -10,7 +10,6 @@ import ( "log/slog" "os" "os/signal" - "strconv" "strings" "sync" "syscall" @@ -84,42 +83,40 @@ func main() { } } -// config holds parsed environment variables. +// config is the JSON shape carried in GOHOME_CARPORT_INSTANCE_CONFIG. +// Password is resolved at load time from the env var named by PasswordEnv; +// it is never serialized. type config struct { - BrokerURL string - Username string - Password string - BaseTopic string - ClientID string - TLSSkipVerify bool + BrokerURL string `json:"broker_url"` + Username string `json:"username,omitempty"` + PasswordEnv string `json:"password_env,omitempty"` + BaseTopic string `json:"base_topic,omitempty"` + ClientID string `json:"client_id,omitempty"` + TLSSkipVerify bool `json:"tls_skip_verify,omitempty"` + + Password string `json:"-"` } func loadConfig() (config, error) { - c := config{ - BrokerURL: os.Getenv("Z2M_BROKER_URL"), - Username: os.Getenv("Z2M_USERNAME"), - Password: os.Getenv("Z2M_PASSWORD"), - BaseTopic: os.Getenv("Z2M_BASE_TOPIC"), - ClientID: os.Getenv("Z2M_CLIENT_ID"), + raw := os.Getenv("GOHOME_CARPORT_INSTANCE_CONFIG") + if raw == "" { + return config{}, errors.New("GOHOME_CARPORT_INSTANCE_CONFIG is required") + } + c := config{BaseTopic: "zigbee2mqtt"} + if err := json.Unmarshal([]byte(raw), &c); err != nil { + return config{}, fmt.Errorf("parse instance config: %w", err) } if c.BrokerURL == "" { - return config{}, errors.New("Z2M_BROKER_URL is required") + return config{}, errors.New("broker_url is required") } - if c.BaseTopic == "" { - c.BaseTopic = "zigbee2mqtt" + if c.PasswordEnv != "" { + c.Password = os.Getenv(c.PasswordEnv) } if c.ClientID == "" { var b [4]byte _, _ = rand.Read(b[:]) c.ClientID = "gohome-z2m-" + hex.EncodeToString(b[:]) } - if v := os.Getenv("Z2M_TLS_SKIP_VERIFY"); v != "" { - b, err := strconv.ParseBool(v) - if err != nil { - return config{}, errors.New("Z2M_TLS_SKIP_VERIFY must be a boolean") - } - c.TLSSkipVerify = b - } return c, nil } From 80870676b71240b081d0fc023f152e69a0cf0025 Mon Sep 17 00:00:00 2001 From: Fynn Datoo Date: Fri, 1 May 2026 01:40:44 -0700 Subject: [PATCH 16/16] fix(ci): clear lint and tidy diff for z2m + colorconv - go.mod / go.sum: paho.mqtt.golang and mochi-mqtt/server/v2 promoted to direct deps (they're imported by drivers/z2m); kr/text and creack/pty pruned, jinzhu/copier checksum added. - drivers/z2m/cmd/z2m-driver/main.go: split main into run() returning an error so os.Exit no longer skips deferred cleanup (gocritic); reformat stateCache field alignment (goimports); drop ineffectual available=true initializer in onDeviceAvailability (ineffassign). - drivers/z2m/internal/state/mapping.go: preallocate EntitiesFor's out slice (prealloc). - drivers/z2m/internal/state/reconcile_test.go: drop redundant int type from var declaration (staticcheck QF1011). - gohome-driverkit/colorconv/colorconv_test.go: gofmt cleanup of TestHSVToRGBKnownPoints case alignment. Co-Authored-By: Claude Opus 4.7 (1M context) --- drivers/z2m/cmd/z2m-driver/main.go | 47 +++++++++++--------- drivers/z2m/internal/state/mapping.go | 2 +- drivers/z2m/internal/state/reconcile_test.go | 2 +- go.mod | 5 +-- go.sum | 3 +- gohome-driverkit/colorconv/colorconv_test.go | 10 ++--- 6 files changed, 36 insertions(+), 33 deletions(-) diff --git a/drivers/z2m/cmd/z2m-driver/main.go b/drivers/z2m/cmd/z2m-driver/main.go index efef626..373c473 100644 --- a/drivers/z2m/cmd/z2m-driver/main.go +++ b/drivers/z2m/cmd/z2m-driver/main.go @@ -27,10 +27,16 @@ import ( const driverName, driverVersion = "driver.z2m", "0.1.0" func main() { + if err := run(); err != nil { + fmt.Fprintf(os.Stderr, "z2m-driver: %v\n", err) + os.Exit(1) + } +} + +func run() error { cfg, err := loadConfig() if err != nil { - fmt.Fprintf(os.Stderr, "z2m-driver: config: %v\n", err) - os.Exit(1) + return fmt.Errorf("config: %w", err) } logger := slog.New(slog.NewJSONHandler(os.Stderr, &slog.HandlerOptions{ @@ -53,12 +59,10 @@ func main() { TLSSkipVerify: cfg.TLSSkipVerify, }) if err != nil { - fmt.Fprintf(os.Stderr, "z2m-driver: mqtt new: %v\n", err) - os.Exit(1) + return fmt.Errorf("mqtt new: %w", err) } if err := mq.Connect(ctx); err != nil { - fmt.Fprintf(os.Stderr, "z2m-driver: mqtt connect: %v\n", err) - os.Exit(1) + return fmt.Errorf("mqtt connect: %w", err) } defer mq.Close() @@ -76,11 +80,10 @@ func main() { app.subscribeBridgeTopics() - runErr := d.Run(ctx) - if runErr != nil && !errors.Is(runErr, context.Canceled) { - slog.Error("driver run exited", "error", runErr) - os.Exit(1) + if err := d.Run(ctx); err != nil && !errors.Is(err, context.Canceled) { + return fmt.Errorf("driver run: %w", err) } + return nil } // config is the JSON shape carried in GOHOME_CARPORT_INSTANCE_CONFIG. @@ -139,12 +142,12 @@ func parseLogLevel(s string) slog.Level { // next Reconcile; which entity IDs are downstream of a given state // topic (a multi-property device's state topic fans out to N entities). type stateCache struct { - mu sync.Mutex - entities map[string]*entityv1.Attributes // entityID → last attrs - devices map[string]z2m.Device // ieee → last-seen device - entityByTopic map[string][]entityListener // state topic → which entities consume it - friendlyByEnt map[string]string // entityID → friendly_name (for /set) - ieeeByEnt map[string]string // entityID → ieee (for log context) + mu sync.Mutex + entities map[string]*entityv1.Attributes // entityID → last attrs + devices map[string]z2m.Device // ieee → last-seen device + entityByTopic map[string][]entityListener // state topic → which entities consume it + friendlyByEnt map[string]string // entityID → friendly_name (for /set) + ieeeByEnt map[string]string // entityID → ieee (for log context) } // entityListener is one entity's binding inside a state topic: which @@ -157,11 +160,11 @@ type entityListener struct { func newStateCache() *stateCache { return &stateCache{ - entities: map[string]*entityv1.Attributes{}, - devices: map[string]z2m.Device{}, - entityByTopic: map[string][]entityListener{}, - friendlyByEnt: map[string]string{}, - ieeeByEnt: map[string]string{}, + entities: map[string]*entityv1.Attributes{}, + devices: map[string]z2m.Device{}, + entityByTopic: map[string][]entityListener{}, + friendlyByEnt: map[string]string{}, + ieeeByEnt: map[string]string{}, } } @@ -382,7 +385,7 @@ func (a *app) onDeviceAvailability(topic string, payload []byte) { stateTopic := strings.TrimSuffix(topic, "/availability") var av z2m.AvailabilityState - available := true + var available bool // Z2M can publish either {"state":"online"} or the bare string "online". if err := json.Unmarshal(payload, &av); err == nil && av.State != "" { available = av.State == "online" diff --git a/drivers/z2m/internal/state/mapping.go b/drivers/z2m/internal/state/mapping.go index 42b587e..3438506 100644 --- a/drivers/z2m/internal/state/mapping.go +++ b/drivers/z2m/internal/state/mapping.go @@ -59,7 +59,7 @@ type EntityResult struct { // non-light properties (smart plug state) are skipped with one INFO // log line so users can see what they're missing. func EntitiesFor(dev z2m.Device) []EntityResult { - var out []EntityResult + out := make([]EntityResult, 0, len(dev.Definition.Exposes)) for _, e := range dev.Definition.Exposes { out = append(out, mapExpose(dev, e)...) } diff --git a/drivers/z2m/internal/state/reconcile_test.go b/drivers/z2m/internal/state/reconcile_test.go index a68cf30..c5f0d68 100644 --- a/drivers/z2m/internal/state/reconcile_test.go +++ b/drivers/z2m/internal/state/reconcile_test.go @@ -131,7 +131,7 @@ func TestReconcileAddBeforeRemoveOrder(t *testing.T) { next := []z2m.Device{deviceByName(t, all, "kitchen_light")} actions := Reconcile(prev, next) - var addedAt, removedAt int = -1, -1 + addedAt, removedAt := -1, -1 for i, a := range actions { switch a.(type) { case AddEntity: diff --git a/go.mod b/go.mod index 70b8caa..d4cb2c2 100644 --- a/go.mod +++ b/go.mod @@ -9,11 +9,13 @@ require ( github.com/benbjohnson/immutable v0.4.3 github.com/charmbracelet/lipgloss v1.1.0 github.com/charmbracelet/log v1.0.0 + github.com/eclipse/paho.mqtt.golang v1.5.1 github.com/fdatoo/gohome-driverkit v0.0.0-00010101000000-000000000000 github.com/fxamacker/cbor/v2 v2.9.1 github.com/go-webauthn/webauthn v0.17.0 github.com/google/uuid v1.6.0 github.com/klauspost/compress v1.18.5 + github.com/mochi-mqtt/server/v2 v2.7.9 github.com/modelcontextprotocol/go-sdk v1.5.0 github.com/oklog/ulid/v2 v2.1.1 github.com/pressly/goose/v3 v3.27.0 @@ -45,7 +47,6 @@ require ( github.com/danieljoos/wincred v1.2.3 // indirect github.com/davecgh/go-spew v1.1.1 // indirect github.com/dustin/go-humanize v1.0.1 // indirect - github.com/eclipse/paho.mqtt.golang v1.5.1 // indirect github.com/go-logfmt/logfmt v0.6.1 // indirect github.com/go-viper/mapstructure/v2 v2.5.0 // indirect github.com/go-webauthn/x v0.2.3 // indirect @@ -55,13 +56,11 @@ require ( github.com/google/jsonschema-go v0.4.2 // indirect github.com/gorilla/websocket v1.5.3 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect - github.com/kr/text v0.2.0 // indirect github.com/kylelemons/godebug v1.1.0 // indirect github.com/lucasb-eyer/go-colorful v1.2.0 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-runewidth v0.0.16 // indirect github.com/mfridman/interpolate v0.0.2 // indirect - github.com/mochi-mqtt/server/v2 v2.7.9 // indirect github.com/muesli/termenv v0.16.0 // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect github.com/ncruces/go-strftime v1.0.0 // indirect diff --git a/go.sum b/go.sum index 01a3cde..775eeb3 100644 --- a/go.sum +++ b/go.sum @@ -29,7 +29,6 @@ github.com/charmbracelet/x/exp/golden v0.0.0-20240806155701-69247e0abc2a/go.mod github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ= github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= -github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/danieljoos/wincred v1.2.3 h1:v7dZC2x32Ut3nEfRH+vhoZGvN72+dQ/snVXo/vMFLdQ= github.com/danieljoos/wincred v1.2.3/go.mod h1:6qqX0WNrS4RzPZ1tnroDzq9kY3fu1KwE7MRLQK4X0bs= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= @@ -76,6 +75,8 @@ github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/jinzhu/copier v0.3.5 h1:GlvfUwHk62RokgqVNvYsku0TATCF7bAHVwEXoBh3iJg= +github.com/jinzhu/copier v0.3.5/go.mod h1:DfbEm0FYsaqBcKcFuvmOZb218JkPGtvSHsKg8S8hyyg= github.com/klauspost/compress v1.18.5 h1:/h1gH5Ce+VWNLSWqPzOVn6XBO+vJbCNGvjoaGBFW2IE= github.com/klauspost/compress v1.18.5/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= diff --git a/gohome-driverkit/colorconv/colorconv_test.go b/gohome-driverkit/colorconv/colorconv_test.go index 6d8acd0..27c6a4d 100644 --- a/gohome-driverkit/colorconv/colorconv_test.go +++ b/gohome-driverkit/colorconv/colorconv_test.go @@ -116,11 +116,11 @@ func TestHSVToRGBKnownPoints(t *testing.T) { h, s, v float64 r, g, b uint8 }{ - {0, 0, 0, 0, 0, 0}, // black + {0, 0, 0, 0, 0, 0}, // black {0, 0, 1, 255, 255, 255}, // white - {0, 1, 1, 255, 0, 0}, // red - {120, 1, 1, 0, 255, 0}, // green - {240, 1, 1, 0, 0, 255}, // blue + {0, 1, 1, 255, 0, 0}, // red + {120, 1, 1, 0, 255, 0}, // green + {240, 1, 1, 0, 0, 255}, // blue } for _, tc := range cases { r, g, b := HSVToRGB(tc.h, tc.s, tc.v) @@ -131,7 +131,7 @@ func TestHSVToRGBKnownPoints(t *testing.T) { } func TestHSVOutOfDomainClamped(t *testing.T) { - r, g, b := HSVToRGB(720, 2, 5) // hue wraps; sat/val clamp to 1 + r, g, b := HSVToRGB(720, 2, 5) // hue wraps; sat/val clamp to 1 r2, g2, b2 := HSVToRGB(0, 1, 1) if r != r2 || g != g2 || b != b2 { t.Errorf("clamped HSV mismatch: got (%d,%d,%d), want (%d,%d,%d)", r, g, b, r2, g2, b2)