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, send any other WebSocket command, read the floor, area, device, entity and label registries, and (optionally) keep in-memory ETS caches of entity states and of the registries.

The connection starts in the background and keeps itself up: it retries with exponential backoff while Home Assistant is down, detects dead links with a keepalive ping, and restores your subscriptions after every reconnect.

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.4.0"}
  ]
end

Usage

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}}                                    -> ...
# {:hassock, ^conn, :connected}                                                 -> ...

connect/1 returns before the connection is up; commands sent before :connected return {:error, :not_connected}. :connected arrives again after every reconnect. Subscriptions are restored automatically and keep their ids, so don't resubscribe on :connected (a restored subscribe_entities starts with a fresh snapshot in added). If HA refuses to restore one, you get {:hassock, conn, {:subscription_lost, id, error}} and the subscription is dropped.

Events go to the controlling process. Pass to: pid to subscribe_events/3, subscribe_entities/3 or subscribe_template/3 to send a subscription's events elsewhere; it is cancelled when that process exits.

Note: these changed entries 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; Hassock.EntityState.apply_diff/2 does that for one entity. The added map (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, use Hassock.Cache.

To call a service:

Hassock.call_service(conn, %Hassock.ServiceCall{
  domain: "light",
  service: "toggle",
  target: %{entity_id: "light.kitchen"}
})

For services that return data, set return_response: true; the data is under "response" in the result:

{:ok, %{"response" => forecasts}} =
  Hassock.call_service(conn, %Hassock.ServiceCall{
    domain: "weather",
    service: "get_forecasts",
    target: %{entity_id: "weather.home"},
    service_data: %{type: "daily"},
    return_response: true
  })

Any other command

Hassock.command/4 sends any WebSocket command and waits for its result:

{:ok, config} = Hassock.command(conn, "get_config")
{:ok, entry} =
  Hassock.command(conn, "config/entity_registry/get", %{entity_id: "light.kitchen"}, timeout: 5_000)

It returns {:ok, result}, {:error, %{"code" => _, "message" => _}} for an HA error, or {:error, :not_connected | :disconnected | :timeout}.

Registries

Hassock.Registries lists the floor, area, device, entity and label registries as structs (Hassock.Floor, Hassock.Area, Hassock.Device, Hassock.EntityEntry, Hassock.Label):

{:ok, areas} = Hassock.Registries.list_areas(conn)
{:ok, entries} = Hassock.Registries.list_entities(conn)

Subscribing to a registry's update event gives typed events:

{:ok, _} = Hassock.subscribe_events(conn, "area_registry_updated")
# {:hassock, ^conn, {:event, {:registry_updated, :area, :create | :update | :remove, area_id}}}

Hassock.RegistryCache keeps all five registries in ETS: it lists them on every :connected, re-lists one (debounced) when its update event arrives, and sends {:hassock_registry, cache, :ready} and {:hassock_registry, cache, {:changed, kind}} to its controller. It doesn't take ownership of the connection, so it runs beside Hassock.Cache:

{:ok, reg} = Hassock.RegistryCache.start_link(connection: conn)

receive do
  {:hassock_registry, ^reg, :ready} -> :ok
end

Hassock.RegistryCache.list(reg, :floor)
Hassock.RegistryCache.get(reg, :area, "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:

Note on ownership: Hassock.Cache.start_link/1 transfers 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 like call_service/2 and get_states/1 still work directly on conn.)

Connection robustness

All of these are fields of %Hassock.Config{}:

Other processes can get the lifecycle messages (:connected, {:disconnected, _}, {:auth_failed, _}) with Hassock.watch(conn), until Hassock.unwatch(conn). inspect/1 of a %Hassock.Config{} leaves out the token and ssl_options.

TLS

For https:// URLs the server certificate is verified against the system CA store (:public_key.cacerts_get/0). If the system has no CA store (common on embedded systems such as Nerves), Hassock logs a warning and connects without verification; set ssl_options to avoid that, for example with the castore package, or with the CA of a self-signed certificate:

%Hassock.Config{
  url: "https://homeassistant.local:8123",
  token: token,
  ssl_options: [
    verify: :verify_peer,
    cacertfile: CAStore.file_path(),  # or "/data/ha-ca.pem"
    customize_hostname_check: [
      match_fun: :public_key.pkix_verify_hostname_match_fun(:https)
    ]
  ]
}

ssl_options replaces the defaults, so include customize_hostname_check if the certificate uses a wildcard name. ssl_options: [verify: :verify_none] turns verification off explicitly. Plain http:// sends the token unencrypted.

Dashboards

Read the dashboard list and a dashboard's views and cards. These commands cover Lovelace dashboards, HA's internal name for its configurable, card-based dashboards: Overview and the ones you add yourself. Built-in panels such as Energy, History, and Logbook are separate frontend pages and are not included.

{:ok, dashboards} = Hassock.lovelace_dashboards(conn)
# [%{"url_path" => "dashboard-kitchen", "title" => "Kitchen", "mode" => "storage", ...}]

{:ok, config} = Hassock.lovelace_config(conn, "dashboard-kitchen")
{:ok, default} = Hassock.lovelace_config(conn, nil)

Recent HA versions list the default dashboard as "lovelace"; older ones and YAML setups may leave it out. Passing nil fetches it either way. An auto-generated dashboard returns %{"strategy" => %{"type" => ...}} instead of views; a dashboard with no config at all gives {:error, %{"code" => "config_not_found", ...}}.

Live templates

subscribe_template/3 has HA render a template (such as a Markdown card's) and render it again whenever something it depends on changes:

{:ok, sub_id} =
  Hassock.subscribe_template(conn, "{{ states('sensor.power') }} W", report_errors: true)

# {:hassock, ^conn, {:event, {:template, ^sub_id, %{result: "120 W", listeners: _}}}}
# {:hassock, ^conn, {:event, {:template, ^sub_id, %{error: message, level: "ERROR"}}}}

result keeps the type HA gives it: output that parses as a number, list or map arrives as one. Options are :variables, :render_timeout (seconds), :strict and :report_errors, plus :to and :timeout (ms) as for the other subscriptions. Like them it is restored after a reconnect, with the same id; cancel it with unsubscribe_events/2. A Hassock.Cache drops template events, so beside a cache subscribe with to: self().

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,
 registries: true,
 controller: my_handler_pid}

This wires Hassock.Connection + Hassock.Cache + Hassock.RegistryCache under a one_for_all supervisor and delivers cache and registry events to controller (default: caller).

Architecture

With Hassock.Supervisor (cache and registries 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"]
      Reg["Hassock.RegistryCache"]
    end

    Sock["Connection.Socket (one per attempt)"]
    HA[("Home Assistant")]
    Ctrl["your :controller pid"]

    Conn --- Sock
    Sock <-. "WebSocket" .-> HA
    Cache -. "controlling_process" .-> Conn
    Reg -. "watch + subscribe to:" .-> Conn
    Cache -. "{:hassock_cache, cache, …}" .-> Ctrl
    Reg -. "{:hassock_registry, reg, …}" .-> Ctrl

Solid edges are supervision links; dashed edges are message / ownership flow.

Development

mix deps.get
mix test

The unit tests run against a scriptable fake Home Assistant (test/support/fake_ha.ex). The registry fixtures in test/fixtures/registries/ were recorded from Home Assistant 2026.10 and scrubbed of names and ids.

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 integration

HASSOCK_LIGHT_ENTITY is optional — without it, light-toggle assertions auto-discover the first available light.* entity or skip.