- Zig 65.3%
- Nix 27.1%
- Python 7.6%
| Filename | Latest commit message | Latest commit date |
|---|---|---|
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> |
||
| .forgejo/workflows | ||
| LICENSES | ||
| src | ||
| tests/nixos | ||
| tools | ||
| .gitignore | ||
| build.zig | ||
| build.zig.zon | ||
| build.zig.zon.nix | ||
| flake.lock | ||
| flake.nix | ||
| package.nix | ||
| README.md | ||
| REUSE.toml | ||
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:
- the server sends
auth_required - the client sends
authwith a long-lived access token, and expectsauth_ok - the client sends
subscribe_eventsfor whichever events it wants - the client sends
get_states, once, which seeds every entity - service calls go out as
call_servicemessages
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_required →
auth → auth_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
- Home Assistant. WebSocket API. Home Assistant Developer Documentation. https://developers.home-assistant.io/docs/api/websocket.
- Fette, I., and A. Melnikov. The WebSocket Protocol. RFC 6455. Internet Engineering Task Force, December 2011. https://www.rfc-editor.org/info/rfc6455.
- Jones, M., J. Bradley, and N. Sakimura. JSON Web Token (JWT). RFC 7519. Internet Engineering Task Force, May 2015. https://www.rfc-editor.org/info/rfc7519.
- International Organization for Standardization. Date and Time — Representations for Information Interchange — Part 1: Basic Rules. ISO 8601-1:2019. First edition. Geneva: ISO, February 2019. https://www.iso.org/standard/70907.html.