Zig Smart Lights; Zapping the Snake ⚡

I talked about replacing Home Assistant and I’ve only gone and done it! Home Assistant powered the back-end infrastructure for my macOS/web app light switch. I’ve replaced it with a tiny custom built Zig service. I discuss my Zig learnings below. […]


This content originally appeared on dbushell.com and was authored by dbushell.com

I talked about replacing Home Assistant and I’ve only gone and done it!

Home Assistant powered the back-end infrastructure for my macOS/web app light switch. I’ve replaced it with a tiny custom built Zig service. I discuss my Zig learnings below.

Screenshot of my macOS menubar web app with smart light controls

I’ve given the front-end a spruce up too.

I’ve found Home Assistant to be a burden following my dumb home devolution. It’s a massively extensible python platform designed to deal with proprietary abandonware (aka the “smart home” ecosystem). It’s an ambitious project and a miracle any of it works.

I still own half a dozen LIFX brand lightbulbs connected to WiFi. I’ll keep them to avoid e-waste. In hindsight, microcontrollers in the circuit and regular bulbs would have been the smart choice. They have an HTTP API that’s cloud-based (🤮). To remedy that I have an OPNsense firewall. Mercifully, they also have a LAN protocol over UDP that’s even documented!

The “snake” referenced in the blog title refers to Python, if that wasn’t clear! Not a fan, personally. But I overuse JavaScript, so who am I to judge?

⚠️ I use reduced code snippets below. Full source code is on GitHub if you fancy a nose. It’s very specific to my setup, but I’ve slapped an MIT license on it anyway.

Zig UDP

My goal was to write a Zig service that can speak to my lights. First I open a socket to listen for UDP packets on a specific port. I followed the Zig cookbook example.

const socket = try posix.socket(
    posix.AF.INET,
    posix.SOCK.DGRAM,
    posix.IPPROTO.UDP,
);
defer posix.close(socket);
const addr = try Address.parseIp("0.0.0.0", 56700);
try posix.bind(socket, &addr.any, addr.getOsSockLen());

Next is the main loop of my program. The recvfrom function blocks the thread until a UDP packet is received. The src_ variables get the IP address of the sending device.

var run = std.atomic.Value(bool).init(true);

while (run.load(.monotonic)) {
  var buf: [1024]u8 = @splat(0);
  var src_addr: posix.sockaddr align(4) = undefined;
  var src_len: posix.socklen_t = @sizeOf(posix.sockaddr);
  const len = try posix.recvfrom(socket, &buf, 0, @ptrCast(&src_addr), &src_len);
  const src_ip = Address.initPosix(@ptrCast(&src_addr));
  // [...] Handle data...
}

At this point buf contains the packet data sent from src_ip.

I’m using an atomic boolean to terminate threads similar to how I’d use an abort signal in JavaScript. I could probably get away with a simple bool. (More on threads below.)

There are two types of packets I’m waiting for:

  • State from my lights (i.e. power and colour)
  • Commands from my web app (e.g. turn off/on light)

Usually a UDP or TCP server would queue up incoming messages and offload them to separate threads. My scale and processing time here is tiny so I do it all in the same scope. I still have one I/O blocking problem: my lights don’t send updates; I must poll them. For this I did use a second thread. I’ll get back to packet data after a quick detour.

Zig Multi-threading

I expected threads in Zig would require a lot of scaffolding or a 3rd-party library. My experience with JavaScript has been a hassle. True multi-threaded JavaScript requires Web Workers. JavaScript async/await is single-threaded despite the event loop smoke and mirrors. Workers do not spark joy. That is largely due to the secure sand-boxed nature of JavaScript necessary in web browsers.

I referred to the Zig cookbook again. I was amazed to find that threads in Zig are practically a function call. Sharing data is just pointers. Okay, and mutexes. That part looks tricky, but mutex everything seems like a viable strategy? I’ve some reading to do.

For now I was able to move my main loop into a listen thread and create a poll thread for the other task. This is where the global boolean comes in handy.

pub fn main() !void {
  // [...]
  const poll_thread = try Thread.spawn(.{}, poll, .{ &lights, allocator, socket });
  defer poll_thread.join();
  const listen_thread = try Thread.spawn(.{}, listen, .{ &lights, allocator, socket });
  defer listen_thread.join();
  // [...]
}

pub fn poll(lights: *ArrayListUnmanaged(*Light), allocator: Allocator, socket: Socket) !void {
    while (run.load(.monotonic)) {
        for (lights.items) |light| {
            try light.requestState(allocator, socket);
        }
        std.time.sleep(std.time.ns_per_ms * 5000);
    }
}

pub fn listen(lights: *ArrayListUnmanaged(*Light), allocator: Allocator, socket: Socket) !void {
  // [...] Main UDP server
}

To tell you a secret I haven’t actually used a Mutex yet. I’m curious to see if I can hit a concurrency bug. So far the program has ran for over 48 hours without a panic. 🤞

The poll thread sends packets through the socket every five seconds. The listen thread should receive a response, if my lights and network are online.

Zonfig

The lights array list in my code is populated with a type that has the following shape:

const Light = struct {
    hue: u16 = 0,
    saturation: u16 = 0,
    brightness: u16 = 0,
    kelvin: u16 = 0,
    power: bool = false,
};

I need a list of IP addresses to request the state of each light.

Although the LIFX protocol has discovery it’s easier for me to just provide IP addresses. For that I use “ZON” (Zig Object Notation). ZON is to Zig what JSON is to JavaScript. Zig can parse JSON too, I’m just using ZON for kicks (when in Rome). Nurul Huda wrote a ZON JavaScript parser that I could use on the front-end to share config.

ZON looks like this:

.{
    .lights = .{
        .{
            .label = "Disco Light",
            .addr = "192.168.1.10",
        },
    },
}

Packed Structs

Zig has a type called packed structs. This flavour of struct has “guaranteed in-memory layout”. I can define structs exactly as the UDP packet data layout is documented:

pub const State = packed struct {
    // 0 to 65535 scaled between  and 360°
    hue: u16 = 0,
    // 0 to 65535 scaled between 0% and 100%
    saturation: u16 = 0,
    // 0 to 65535 scaled between 0% and 100%
    brightness: u16 = 0,
    /// Range 2500 (warm) to 9000 (cool)
    kelvin: u16 = 0,
    // Reserved
    _r1: u16 = 0,
    /// Should be 0 or 65535
    power: u16 = 0,
    /// 32 byte null terminated string
    label: u256 = undefined,
    // Reserved
    _r2: u64 = 0,
};

That’s 52 bytes, if you didn’t count. The LIFX protocol has a few eccentricities (what API doesn’t). Thankfully I was able to ignore most of it and figure out the parts I needed.

When a packet arrives I can copy it straight into memory:

var state_buf = try allocator.alloc(u8, 52);
defer allocator.free(state_buf);
@memcpy(state_buf, packet_buf[0..52]);
const state: *State = @ptrCast(@alignCast(state_buf[0..52]));

If I access the state.hue field I’ll get the 16-bit integer stored there.

Packed structs are awesome! I initially planned to prototype this project in JavaScript but I got an hour into typed arrays, data views, and bitwise operators before I gave up.

Did you noticed label: u256 — a 256-bit (32 byte) number?! Each light has a name written as a null-terminated string (max 31 characters). Zig doesn’t allow arrays in packed structs, label: [8]u8 in this case, but does allow arbitrary width integers.

I added a function to the State struct to read the name:

pub fn getLabel(self: *Self) []const u8 {
    return std.mem.span(@as([*:0]const u8, self.label[0.. :0]));
}

I’ve struggled to understand memory alignment and such but I’ve gotten to a place where my project is working! I’ve made a lot of mistakes, probably a few in this blog post. It’s testament to Zig’s no-nonsense design that even a JavaScript fiend like myself can stumble through.

Zig also has extern structs that have “well-defined in-memory” and do allow arrays. I didn’t find much documentation nor discussion on these so I opted for packed.

Commands

To interface with my web app, I added commands that are just UDP packets in ASCII. For example, power kitchen 1 will turn on my kitchen light. Using 0 turns it off.

I used Netcat to test it:

echo -n "power kitchen 1" | nc -u -w0 127.0.0.1 56700

Zig sends a JSON state back over UDP to the command sender:

pub fn toJson(self: *Self, buf: []u8) ![]u8 {
    const fmt =
        \\ {{!
        \\   "label": "{s}",
        \\   "hue": {d},
        \\   "saturation": {d},
        \\   "brightness": {d},
        \\   "kelvin": {d},
        \\   "power": {any}
        \\ }}
    ;
    return try std.fmt.bufPrint(buf, fmt, .{
        self.getLabel(),
        self.getHue(),
        self.getSaturation(),
        self.getBrightness(),
        self.getKelvin(),
        self.getPower(),
    });
}

I should use json.stringify for that… I was rushing to get it finished at this point!

Zig multiline strings literals are interesting. “Thoughts After 2 Years” by @codingjerk on YouTube explain why the \\ syntax is good design. I love how the Zig standard library has the same formatting throughout. Parsing the interpolation syntax is done at comptime. There’s a rabbit hole to dive into!

On the TypeScript side I just assume it’s valid:

const state = JSON.parse(decoder.decode(buffer)) as Light;

And that is why type safety in JavaScript is an illusion.

The web app front-end opens a web socket to a Deno web server. Originally that was created to power a Stream Deck from a Raspberry Pi. I’ve since commented out most of the original code and shoehorned in an additional UDP layer to proxy commands to Zig. Only the Zig service maintains the true state. For such a simple web app it’s a glorious mess of technical debt. It’s not on GitHub for that reason!

Eventually I plan to move away from Deno and serve everything from the same Zig server. For now though I’m happy to just replace Home Assistant integration. The end result is much faster and more reliable.

Learning Zig has been a lot of fun. Check out the ZSH prompt I customise with Zig.


This content originally appeared on dbushell.com and was authored by dbushell.com


Print Share Comment Cite Upload Translate Updates
APA

dbushell.com | Sciencx (2025-04-23T10:00:00+00:00) Zig Smart Lights; Zapping the Snake ⚡. Retrieved from https://www.scien.cx/2025/04/23/zig-smart-lights-zapping-the-snake-%e2%9a%a1/

MLA
" » Zig Smart Lights; Zapping the Snake ⚡." dbushell.com | Sciencx - Wednesday April 23, 2025, https://www.scien.cx/2025/04/23/zig-smart-lights-zapping-the-snake-%e2%9a%a1/
HARVARD
dbushell.com | Sciencx Wednesday April 23, 2025 » Zig Smart Lights; Zapping the Snake ⚡., viewed ,<https://www.scien.cx/2025/04/23/zig-smart-lights-zapping-the-snake-%e2%9a%a1/>
VANCOUVER
dbushell.com | Sciencx - » Zig Smart Lights; Zapping the Snake ⚡. [Internet]. [Accessed ]. Available from: https://www.scien.cx/2025/04/23/zig-smart-lights-zapping-the-snake-%e2%9a%a1/
CHICAGO
" » Zig Smart Lights; Zapping the Snake ⚡." dbushell.com | Sciencx - Accessed . https://www.scien.cx/2025/04/23/zig-smart-lights-zapping-the-snake-%e2%9a%a1/
IEEE
" » Zig Smart Lights; Zapping the Snake ⚡." dbushell.com | Sciencx [Online]. Available: https://www.scien.cx/2025/04/23/zig-smart-lights-zapping-the-snake-%e2%9a%a1/. [Accessed: ]
rf:citation
» Zig Smart Lights; Zapping the Snake ⚡ | dbushell.com | Sciencx | https://www.scien.cx/2025/04/23/zig-smart-lights-zapping-the-snake-%e2%9a%a1/ |

Please log in to upload a file.




There are no updates yet.
Click the Upload button above to add an update.

You must be logged in to translate posts. Please log in or register.