πŸ’‘

Lessons from wiring my Govee lamps into my own smart home

I run a small home-automation system I built myself β€” I call it ZiWoAs. It already talks to my solar inverter, a handful of smart plugs, the weather. The last gap was lighting: I have a bunch of Govee lamps scattered around the flat, and I wanted them in the same dashboard as everything else. No app-hopping β€” just my own UI.

I figured it was a weekend job. It was not. But the detours taught me more about building software with an AI assistant than any clean, on-the-rails feature ever has. Here's what stuck.

Lesson 1: With AI, trying an approach is almost free

There are two ways to talk to a Govee lamp. There's a LAN protocol that works locally over UDP β€” fast, no internet required, but deliberately limited to on/off, brightness, and colour. And there's a cloud HTTP API that exposes more, including scenes and per-segment colour. Both are documented; neither is much fun to implement from scratch. So my first instinct was the sensible one: don't build it, borrow it.

There's an excellent open-source bridge called govee2mqtt (written in Rust) that already speaks both and publishes everything over MQTT β€” the same message bus the rest of my system already runs on. I wired it in as a container. An afternoon later I had lamps turning on and off from my own dashboard.

The point isn't that I picked a library. The point is how cheap the experiment was. I described what I wanted, Claude scaffolded the integration, and I had a working answer to "is borrowing better than building here?" in hours, not days. A few years ago that evaluation alone β€” read the docs, learn enough Rust to trust the thing, glue it in β€” would have eaten the whole weekend.

And when borrowing turned out not to fit (more on that in a second), the same cheapness applied in reverse: I could lift just the parts I needed into my own small Ruby bridge instead of bending a general-purpose tool to my will. Build-versus-buy stopped being a big upfront bet and became something I could try in both directions and see.

Lesson 2: A 200 OK doesn't mean it happened β€” and nothing finds that out for you

This is the one I keep coming back to. The cloud API is well-behaved in the worst way: it says yes to almost everything.

My uplighter has 15 addressable colour segments, and the cloud API documents a capability to colour them individually. So I sent one:

ruby

api.control(device: lamp, instance: "segmentedColorRgb", value: red)
# => { "code" => 200, "message" => "success" }

Success β€” except the lamp sat there, unchanged. I sent fifteen different colours to the fifteen segments. The API cheerfully accepted all fifteen. The lamp stayed a uniform blue. This particular model just doesn't render segments over the API, and nothing in the response says so. It reports success and does nothing.

Same story with a ceiling light that advertises a "main light" and a "ring" you can toggle separately. The API accepts the toggle, returns 200, and then reads the state back as an empty string. Physically, both stay on. There's no version of that command that works through any interface I have.

Here's the part that matters for how you work: no tool, no library, and no AI can discover this for you. Claude can read the docs and write a command that is correct per the documentation. It cannot see that my lamp didn't change. The cloud can't see it either β€” when you nudge a Govee lamp from the phone app over Bluetooth, the cloud isn't even in the loop.

The only instrument that tells the truth is your own eyes in the room. So that's what it came down to: me, standing in front of a lamp, sending a command, watching whether anything actually lit up. I kept a running list of what's real and what's a polite lie. There's no shortcut. If you integrate with physical hardware, budget for standing in the room.

Lesson 3: Fast iteration is great β€” right up until the quality bill arrives

This is the flip side of Lesson 1. When trying things is cheap, you try a lot of things, and the leftovers pile up quietly. AI is wonderful at producing a working version fast. It's much less good at noticing, on its own, that the third working version left the first two lying around in a state that will bite you later. That part is still on me.

A few examples from this build, none of them exotic.

When I moved from the borrowed bridge to my own, I had several little bridges running as background threads β€” one for the lamps, one for the smart plugs:

ruby

bridge = GoveeBridge.new
Thread.new { bridge.run }   # start the lamp bridge

bridge = FritzBridge.new    # reuse the same variable…
Thread.new { bridge.run }   # start the plug bridge

Looks fine. It isn't. The block { bridge.run } closes over the variable bridge, not the value it held when the thread was spawned β€” and a thread doesn't necessarily run the instant you create it. It's a race, and it's one you lose: the main thread reassigns bridge to the FritzBridge on the very next line, so by the time the "lamp" thread is finally scheduled, bridge.run calls into the plug bridge. My lamp integration "wasn't running," for a reason that had nothing to do with lamps. The fix binds the value at spawn time:

ruby

Thread.new(bridge) { |b| b.run }   # pass the value in, bound now

Then there was shutdown. Each bridge blocks on an MQTT read, waiting for the next command. Telling the MQTT client to disconnect doesn't wake that blocking read β€” so on shutdown the process just hung, forever, waiting for a message that would never arrive. Since the thread is being torn down anyway, the pragmatic fix is to stop being polite and kill it outright. Obvious in hindsight; invisible until I actually tried to stop the thing cleanly.

And my favourite, because it's the most humbling. The headline feature of the new bridge was local control β€” talking to the lamps over the LAN, without the cloud. During review, Claude caught that the local discovery had never actually been wired in. It would have silently fallen back to the cloud and "worked" β€” slower, rate-limited, and not in the way I'd just spent days building. The code was happy. The behaviour was wrong. (I'll also own up to one git add -A that briefly staged a config file with a password in it β€” never pushed, scrubbed, lesson relearned. Speed has sharp edges.)

None of these β€” the thread bug, the hang, the unwired feature β€” showed up as an error. Everything was green. They surfaced only because I went looking. AI accelerates the building; it doesn't replace the reviewing.

What I ended up with

A small Ruby bridge β€” I called it govees, in the same spirit as the other little bridges in my system β€” that does exactly what I need and nothing I don't. It controls the lamps locally when it can, falls back to the cloud for the things only the cloud can do (scenes, capabilities), and exposes one clean contract to the rest of the house:

govees/<lamp>/set      # send a command
govees/<lamp>/state    # read what's going on

All the quirks and the polite lies are sealed inside that bridge, so the rest of the system never has to know which lamp is honest about its segments.

The one lesson under all the others

Looking back, the three lessons are really one lesson wearing different hats. AI made me dramatically faster β€” at weighing build versus buy, at scaffolding a bridge, at writing the boring parts. What it didn't do, and couldn't, was verify reality: whether the lamp actually lit up, whether the thread was running the right code, whether the feature I shipped was the feature I built.

The speed is real, and I'm not giving it back. But the faster the building gets, the more the bottleneck β€” and the value β€” moves to the one part that's still entirely mine: being the person who checks.

June 29, 2026