Hassock
Home Assistant WebSocket client for Elixir.
Hassock connects to a Home Assistant instance over its WebSocket API, authenticates, and lets your application subscribe to entity state changes, call services, and (optionally) keep an in-memory ETS cache of the world.
It follows the controlling-process pattern from :gen_tcp /
Circuits.UART: the process that calls start_link/1 becomes the recipient
of async messages, and ownership can be handed off explicitly with
controlling_process/2.
Requires Home Assistant ≥ 2022.4 if you use Hassock.Cache
(subscribe_entities was added in that release).
Installation
def deps do
[
{:hassock, "~> 0.1.0"}
]
endUsage
Bare connection — for targeted subscriptions
Subscribe only to the entities (or events) you care about; do everything else manually.
config = %Hassock.Config{
url: "http://homeassistant.local:8123",
token: System.fetch_env!("HASSOCK_TOKEN")
}
{:ok, conn} = Hassock.connect(config: config)
receive do
{:hassock, ^conn, :connected} -> :ok
end
{:ok, _sub_id} = Hassock.subscribe_entities(conn, ["light.kitchen"])
# In your handle_info/2 (or a receive loop):
#
# {:hassock, ^conn, {:event, {:entities, %{added: a, changed: c, removed: r}}}} -> ...
# {:hassock, ^conn, {:disconnected, reason}} -> ...Note: these
changedentries are diffs, not full entity states — each one describes only the fields that changed (state, specific attribute additions, specific attribute removals). It's up to your code to apply them against prior state if you need the complete picture. Theaddedmap (initial snapshot and any newly-created entities) does carry full%Hassock.EntityState{}structs. If you'd rather get fully-materialized states and old-vs-new comparisons out of the box, useHassock.Cache.
To call a service:
Hassock.call_service(conn, %Hassock.ServiceCall{
domain: "light",
service: "toggle",
target: %{entity_id: "light.kitchen"}
})With an entity cache — for "show me everything" use cases
Hassock.Cache subscribes to every entity, holds the world in ETS, and emits
high-level change messages. Reads are direct ETS lookups — no GenServer
roundtrip.
{:ok, conn} = Hassock.connect(config: config)
{:ok, cache} = Hassock.Cache.start_link(connection: conn)
receive do
{:hassock_cache, ^cache, :ready} -> :ok
end
Hassock.cached_state(cache, "light.kitchen")
Hassock.cached_domain(cache, "light")Cache messages:
{:hassock_cache, cache, :ready}— once, after the initial snapshot loads{:hassock_cache, cache, {:changes, %{added: _, changed: _, removed: _}}}— per delta{:hassock_cache, cache, :disconnected}— on socket loss (ETS retained)
Note on ownership:
Hassock.Cache.start_link/1transfers ownership of the connection to the cache. After it returns, the cache receives all{:hassock, conn, …}messages — your code talks to the cache, not the connection, for async events. (Synchronous calls likecall_service/2andget_states/1still work directly onconn.)
Hand off message reception
:ok = Hassock.controlling_process(conn, other_pid)
:ok = Hassock.Cache.controlling_process(cache, other_pid)Only the current controller may transfer ownership.
Convenience supervisor
If you want a single child spec for your application's supervision tree:
{Hassock.Supervisor,
config: config,
cache: true,
controller: my_handler_pid}
This wires Hassock.Connection + Hassock.Cache under a one_for_all
supervisor and delivers cache events to controller (default: caller).
Architecture
With Hassock.Supervisor (cache enabled), the supervision tree looks like:
flowchart TD
App["your_app's supervisor"]
App --> Sup
subgraph Sup["Hassock.Supervisor (one_for_all)"]
direction TB
Conn["Hassock.Connection"]
Cache["Hassock.Cache"]
end
HA[("Home Assistant")]
Ctrl["your :controller pid"]
Conn <-. "WebSocket" .-> HA
Cache -. "controlling_process" .-> Conn
Cache -. "{:hassock_cache, cache, …}" .-> CtrlSolid edges are supervision links; dashed edges are message / ownership flow.
one_for_all— if either child crashes, both restart from a clean slate. This avoids the restart-time edge cases of reclaiming the connection's controlling process and orphaning the old HA-sidesubscribe_entitiessubscription; on restart, a fresh auth handshake and fresh subscription give a pristine starting point.- Ownership flow —
CachecallsConnection.controlling_process(conn, self())during its own init, so every{:hassock, conn, …}event lands in the cache, not the caller. The cache then emits higher-level{:hassock_cache, cache, …}messages to its own controller (the:controllerpid, default: caller). - Without the cache — the Connection is a direct child of your
supervisor (or unsupervised), and the caller receives
{:hassock, conn, …}events directly. - Synchronous commands (
call_service/2,get_states/1,subscribe_entities/2, …) always go directly to the Connection pid — they don't flow through the controlling-process channel.
Development
mix deps.get
mix test
Integration tests are tagged :integration and skipped by default. To run
them against a live Home Assistant:
HASSOCK_URL=http://homeassistant.local:8123 \
HASSOCK_TOKEN=... \
HASSOCK_LIGHT_ENTITY=light.your_light \
mix test --include integrationHASSOCK_LIGHT_ENTITY is optional — without it, light-toggle assertions
auto-discover the first available light.* entity or skip.