ESPHome Native API server library.
Espex implements the ESPHome Native API protocol over TCP, letting an Elixir application expose itself as an ESPHome device to clients such as Home Assistant. The wire protocol, connection lifecycle, and optional Noise-encrypted transport all live here; hardware is plugged in through behaviours.
Documentation map
- Architecture — supervision tree, the
connection/dispatch split, wire protocol, encryption, and the
push_state/2broadcast path - Entity types — per-type cookbook for the common ESPHome entities (Switch, BinarySensor, Sensor, Button, Light, Cover, Climate) with proto structs and example snippets
Espex.SerialProxy,Espex.ZWaveProxy,Espex.InfraredProxy,Espex.BluetoothScanner,Espex.BluetoothProxy,Espex.EntityProvider,Espex.Mdns— the seven behaviours, each with callback reference and a complete example adapter
Quick start
Start under your own supervision tree:
children = [
{Espex,
device_config: [name: "my-device", friendly_name: "My Device"],
serial_proxy: MyApp.MySerialAdapter,
zwave_proxy: MyApp.MyZWaveAdapter,
infrared_proxy: MyApp.MyInfraredAdapter,
entity_provider: MyApp.MyEntities}
]
Supervisor.start_link(children, strategy: :one_for_one)Any adapter key you omit disables that feature. For encrypted
transport, set :psk on :device_config:
device_config: [
name: "my-device",
psk: "foIclFXDcBlfzi9oQNegJz/uRG/sgdIc956pX+GrC+A="
]For the full start option list see Espex.Supervisor.
Pushing state
Call push_state/2 from anywhere in your application to broadcast
an entity state update to every currently-connected client:
Espex.push_state(%Espex.Proto.SensorStateResponse{
key: 1003,
state: 21.3,
missing_state: false
})Connected clients
Enumerate the live native-API connections with connected_clients/1,
and subscribe to changes by configuring an Espex.ConnectionListener:
Espex.connected_clients(MyApp.EspexServer)
#=> [%Espex.ClientInfo{peer: "192.168.1.5:54312", encrypted?: true, ...}]
Summary
Functions
child_spec/1 — makes {Espex, opts} usable as a child spec.
List the currently-connected native-API clients as
Espex.ClientInfo structs.
Return the running server's %DeviceConfig{}. Accepts an optional
server name for non-default supervisor setups.
Broadcast an entity-state struct to every currently-connected client.
Broadcast a Z-Wave home-ID change to every connected client.
Start the full Espex supervision tree with the given options.
Functions
@spec child_spec(keyword()) :: Supervisor.child_spec()
child_spec/1 — makes {Espex, opts} usable as a child spec.
@spec connected_clients(atom()) :: [Espex.ClientInfo.t()]
List the currently-connected native-API clients as
Espex.ClientInfo structs.
This is a non-blocking read of the connection Registry — the source of
truth for the live set. Pass a custom :server_name if you started the
supervisor with one; it defaults to Espex.Server.
Entries appear from TCP accept (so a client that hasn't finished its
HelloRequest yet shows client_info: nil / api_version: nil) and
vanish when the connection closes. To be told when the set changes
without polling, configure an Espex.ConnectionListener.
Espex.connected_clients(MyApp.EspexServer)
#=> [%Espex.ClientInfo{client_info: "Home Assistant 2026.1.0", ...}]
@spec device_config(GenServer.server()) :: Espex.DeviceConfig.t()
Return the running server's %DeviceConfig{}. Accepts an optional
server name for non-default supervisor setups.
Broadcast an entity-state struct to every currently-connected client.
Pass any %Espex.Proto.*StateResponse{} (e.g.
%Espex.Proto.SensorStateResponse{key: k, state: 21.3}). Clients that
subscribed via SubscribeStatesRequest will receive the frame over
their socket.
server_name defaults to Espex.Server — pass your custom name if
you started the supervisor with :server_name.
@spec push_zwave_home_id(atom(), <<_::32>>) :: :ok
Broadcast a Z-Wave home-ID change to every connected client.
Mirrors ESPHome's APIServer::on_zwave_proxy_request, which sends
HOME_ID_CHANGE to all active clients rather than only the subscribed
one. Home Assistant learns the network identity from this message: the
zwave_js integration starts (or updates) its config-flow the moment
it arrives, even on a connection that never issued
ZWAVE_PROXY_REQUEST_TYPE_SUBSCRIBE. Broadcasting to all is what lets
a controller hot-plugged after a client connected be discovered
without a reconnect.
A Z-Wave adapter should call this from its home-ID change path (see
Espex.ZWaveProxy) instead of messaging the single subscriber. Pass
the 4-byte big-endian home ID; a zeroed value is a valid "network
gone" signal and is delivered as-is.
server_name defaults to Espex.Server.
@spec start_link(keyword()) :: Supervisor.on_start()
Start the full Espex supervision tree with the given options.