A Home Assistant WebSocket client library for Zig, speaking the whole API over one connection.
  • Zig 65.3%
  • Nix 27.1%
  • Python 7.6%
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
Jeffrey C. Ollie 5066a1e314
All checks were successful
test / test (push) Successful in 4m35s
test / integration (push) Successful in 5m43s
test / docs (push) Successful in 5m16s
Wait for Home Assistant to finish starting before measuring it
The test gated itself on `input_boolean.test_switch` existing, which is not
the same thing as Home Assistant being up. `input_boolean` is set up early in
stage 2 and `demo` late in that same stage, and `demo` then registers its
houseful asynchronously on top of that -- so the helper can exist while a
hundred other entities do not.

Landing on the wrong side of that gap seeded nine entities rather than a
hundred and twenty-seven, and the run failed on "only 9 entities were seeded",
which reads as a fault in the library and is nothing of the kind. It is a
race, so it failed only sometimes, and which side it fell on moved with
anything that changed the machine -- rebuilding the binary under test was
enough.

Gated on the instance reporting more than thirty entities instead, once,
before any subtest talks to the library. It costs about a second on a run that
was already going to wait, and every subtest below now measures a Home
Assistant that has finished starting rather than one caught partway.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-19 02:49:48 -05:00
.forgejo/workflows Test the library against a real Home Assistant in a virtual machine 2026-09-18 11:56:27 -05:00
LICENSES Rebuild as the Home Assistant client library greendeck will use 2026-09-18 10:25:50 -05:00
src Say why Home Assistant refused something 2026-09-19 02:41:06 -05:00
tests/nixos Wait for Home Assistant to finish starting before measuring it 2026-09-19 02:49:48 -05:00
tools Furnish the test machine, and test events the library did not cause 2026-09-18 13:14:30 -05:00
.gitignore commit old work 2026-09-18 09:16:44 -05:00
build.zig Test the library against a real Home Assistant in a virtual machine 2026-09-18 11:56:27 -05:00
build.zig.zon Rename the project to zig-homeassistant 2026-09-18 10:57:29 -05:00
build.zig.zon.nix Test the library against a real Home Assistant in a virtual machine 2026-09-18 11:56:27 -05:00
flake.lock Test the library against a real Home Assistant in a virtual machine 2026-09-18 11:56:27 -05:00
flake.nix Test the library against a real Home Assistant in a virtual machine 2026-09-18 11:56:27 -05:00
package.nix Test the library against a real Home Assistant in a virtual machine 2026-09-18 11:56:27 -05:00
README.md Say in the README what a refusal looks like 2026-09-19 02:41:54 -05:00
REUSE.toml Test the library against a real Home Assistant in a virtual machine 2026-09-18 11:56:27 -05:00

zig-homeassistant

A Home Assistant client for Zig: one WebSocket, for everything.

The API documentation is generated from the doc comments in the source, which is where the explanation of each piece lives.

Why only the WebSocket

Home Assistant has two APIs. The REST one needs a request, a connection and a TLS handshake per entity to learn what a single get_states says, so a program watching fifteen entities makes fifteen HTTPS requests at startup to learn what one message would have told it. The WebSocket API does all of it, so that is all this library speaks:

  1. the server sends auth_required
  2. the client sends auth with a long-lived access token, and expects auth_ok
  3. the client sends subscribe_events for whichever events it wants
  4. the client sends get_states, once, which seeds every entity
  5. service calls go out as call_service messages

There is no HTTP client here as a result, which is why zig-http — a server library with no client half — is not a dependency.

Using it

Add it to build.zig.zon:

$ zig fetch --save=homeassistant git+https://git.jcollie.dev/jeff/zig-homeassistant.git

and to build.zig:

const homeassistant = b.dependency("homeassistant", .{
    .target = target,
    .optimize = optimize,
});
mod.addImport("homeassistant", homeassistant.module("homeassistant"));

A program using it is four things in order: say which Home Assistant to talk to, find the token, connect, and then run the two loops.

const ha = @import("homeassistant");

const options: ha.Options = .{ .hostname = "hass.example" };

const token = try ha.token.read(io, gpa, &env, .{
    .file = configured_token_file,
    .default_file = token_beside_the_config,
    .env_var = "MYPROGRAM_HA_TOKEN",
});
defer gpa.free(token);

// Not a local: `Io.Reader`, `Io.Writer` and `tls.Connection` all recover
// themselves with `@fieldParentPtr`, so a session may not move once its
// buffers are in use.
const session = try gpa.create(ha.Session);
defer gpa.destroy(session);

try ha.Session.connect(io, gpa, session, options, token, root_ca, random, .{});
defer session.close(io);

try session.readLoop(.{ .context = self, .onState = onState });

connect returns once the connection is authenticated, subscribed and seeded, so a program that gets past it has a working connection and the current state of every entity on the way.

The two halves of a connection

A WebSocket is read by a task that spends its life blocked in receive, and written by whoever has something to say. Those are different tasks, so the loops come in two halves: Session.readLoop owns the read half and calls back into a Sink as entities change, and Session.sendLoop owns the write half and drains a std.Io.Queue of messages.

They run concurrently on one connection, which is safe only because both write through framelock.FrameLock. §5.5.2 of RFC 6455 makes answering a Ping with a Pong a MUST, and the WebSocket library does it inside receive rather than troubling the caller — so the reader writes too, and a Pong landing in the middle of a call_service frame's payload would corrupt the stream past recovery. FrameLock takes a lock at the first byte written after a flush and releases it at the next flush, which makes one frame one critical section.

The token

token.read looks in up to three places, in order: a file named outright, a second file a program puts beside its own configuration, and an environment variable. Which places those are is the caller's to decide, because only the caller knows where its configuration lives.

There is deliberately no way to put a token in Options. This repository's own history is the argument: it carried a live long-lived JWT in src/main.zig for three years.

Building

nix develop gets a shell with Zig 0.16 and the rest of the tooling in it.

Command What it does
zig build test Runs the tests
zig build check Compiles everything without running it
zig build docs Builds the API documentation into zig-out/docs
zig build docs-serve Serves that documentation on port 8000
nix flake check Builds the package and runs the integration test in a VM

The documentation has to be served rather than opened: the viewer fetches sources.tar and main.wasm at run time and a browser refuses either from a file:// page. -Ddocs-port=N moves the port.

Testing

zig build test runs the unit tests: the shape of every message in both directions, the arithmetic of the token search, and an allocation-failure pass over everything that allocates, which is what found token.read treating a failed allocation as "this file holds no token".

None of that proves the library can hold a conversation with the thing the protocol is for. nix flake check does, in a virtual machine with a real Home Assistant on it:

$ nix flake check -L

The guest runs Home Assistant behind nginx with a certificate signed by an authority made for the test, onboards itself, mints a long-lived token through Home Assistant's own API, and then tools/integration.zig connects the way a program using this library would.

It is a furnished house rather than an empty one. The demo integration brings a hundred-odd fake entities across two dozen domains — lights, sensors, media players, climate, covers, locks, vacuums — with the attribute dictionaries real ones carry, which is what makes the get_states seed worth reading and what exercises the fields this library deliberately does not parse. Alongside them are helpers the test drives by name, and two template entities derived from one of those, so that a single change becomes three events.

Two modes ask two questions. integration call causes a change and observes it: the TLS handshake, the WebSocket opening handshake, auth_requiredauthauth_ok, a get_states that seeds 127 entities, a call_service that reaches a real service, and the state_changed event that comes back on the same connection the call went out on.

integration watch asks the harder question, because a client that only ever reported the consequences of its own writes would pass all of that. It streams events while the test drives Home Assistant from outside the library entirely, over the REST API, and asserts that it hears about every one: a helper it was never told about, both template entities Home Assistant recomputed from that helper, a state that is not ASCII (Küche 温度 ✓, to prove the frame, the JSON string and the slice handed to the sink all carry UTF-8 unchanged), and a burst of ten counter increments arriving in order with none dropped.

Then it asserts the two failures, because a client that refused everything would pass every positive test: a bad token is refused with AuthFailed and an untrusted certificate with CertificateIssuerNotFound, and neither leaves any trace on the instance. The bad token also has to be refused in Home Assistant's own words rather than only in the library's, which is what keeps the reason from being dropped again.

When Home Assistant says no

A command can be refused after the connection is up -- an entity that no longer exists, a service that was renamed, a token without the rights -- and Home Assistant says why, as an error object with a code and a message beside "success": false. auth_invalid says why too, as a bare message rather than an error.

Incoming.failure() answers with a Failure whichever shape it arrived in, and it formats:

warning(homeassistant): home assistant refused command 4: not_found: Entity switch.living_room_tv not found
error(homeassistant): home assistant rejected the access token: Invalid access token or password

Both fields are optional, because nothing obliges Home Assistant to send either; a Failure with neither formats as no reason given rather than as nothing at all.

Dependencies

Package What it is for
zig-websocket The WebSocket protocol, client and server
tls.zig TLS 1.2 and 1.3, since Home Assistant is behind HTTPS
zig-datetime Reading the ISO 8601 timestamps on events and states

All three are fetched by the Zig package manager from the versions pinned in build.zig.zon; nothing here needs a system library.

Where this lives

The repository has three homes, and they hold the same history.

Where How to get it
Forgejo git clone https://git.jcollie.dev/jeff/zig-homeassistant.git
Tangled git clone https://tangled.org/jcollie.dev/zig-homeassistant
Radicle rad clone rad:z29KZouNiuEW4YtouMZaXyH4sHx1D

A Radicle repository is findable only by its ID, so that one is written out in full: rad:z29KZouNiuEW4YtouMZaXyH4sHx1D.

Licensing

MIT, and the repository follows the REUSE standard — reuse lint in the dev shell checks it.

References cited