Espex
ESPHome Native API server library for Elixir.
Espex implements the ESPHome Native API protocol over TCP, letting an Elixir application expose itself as an ESPHome device to clients like Home Assistant. The protocol layer and connection lifecycle live here; hardware is plugged in through behaviours.
Status
Early extraction from universal_proxy.
Still being tested and iterated on, this is not a final API.
Documentation
Start here once you're ready to go beyond the quickstart below:
- Architecture guide — supervision tree,
the connection/dispatch split, wire protocol, encryption, and how
Espex.push_state/2reaches connected clients. - Entity types guide — cookbook for the common ESPHome entities (Switch, BinarySensor, Sensor, Button, Light, Cover, Climate) with proto structs and examples.
Each adapter behaviour's module doc contains a callback reference and a complete example:
Espex.SerialProxyEspex.ZWaveProxyEspex.InfraredProxyEspex.BluetoothScannerEspex.BluetoothProxyEspex.EntityProviderEspex.Mdns
Features
- ESPHome Native API frame encoding/decoding — plaintext and
Noise_NNpsk0_25519_ChaChaPoly_SHA256encrypted transports - Runtime Noise PSK provisioning and rotation — Home Assistant can
bootstrap a keyless node's key over plaintext (opt-in) or rotate it
over the encrypted channel, with host-app persistence via
Espex.PskStore - TCP server with one process per client connection
- Sub-device support — advertise multiple logical devices under one node
- Built-in message handling for the Serial Proxy, Z-Wave Proxy, Infrared
Proxy, and Bluetooth Proxy feature sets — the BLE side covers passive
raw-advertisement scanning, active connections with cross-connection
ownership locking, GATT read / write / notify, pairing, and
connections_freereporting - Server-side state push via
Espex.push_state/2so adapters andEntityProviderimplementations can update live values - Connected-client introspection —
Espex.connected_clients/1enumerates live native-API connections (peer, client_info, API version, encrypted?, timestamps), with anEspex.ConnectionListenercallback for change notifications - Opt-in mDNS advertising (
_esphomelib._tcp) viaEspex.Mdns— ships anEspex.Mdns.MdnsLiteadapter over themdns_litelibrary for the Nerves case, and a behaviour for custom backends - Pluggable hardware via behaviours:
Espex.SerialProxyEspex.ZWaveProxyEspex.InfraredProxyEspex.BluetoothScanner(passive raw advertisements)Espex.BluetoothProxy(active connect/disconnect, GATT, pairing)Espex.EntityProviderEspex.PskStore(persist a runtime-provisioned Noise PSK)Espex.ConnectionListener(observe the connected-client set)
Installation
Add to your mix.exs:
def deps do
[
{:espex, "~> 0.1"}
]
end
Usage
Plaintext (no encryption):
Espex.start_link(
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
)
Encrypted (Noise_NNpsk0) — set :psk to either a 32-byte raw binary or a
base64-encoded string matching the format used in ESPHome YAML:
Espex.start_link(
device_config: [
name: "my-device",
friendly_name: "My Device",
psk: "foIclFXDcBlfzi9oQNegJz/uRG/sgdIc956pX+GrC+A="
],
entity_provider: MyApp.MyEntities
)
When a PSK is configured, plaintext clients are rejected with the standard "encryption required" signal so Home Assistant's ESPHome integration prompts the user for the key. Any adapter key you omit disables that feature.
Runtime key provisioning and rotation
Home Assistant can also set the PSK at runtime via
NoiseEncryptionSetKeyRequest. Two flows are supported:
- Bootstrap (plaintext). Set
accepts_key_provisioning: trueon a keyless node. It then advertises encryption support — HA's trigger to offer provisioning — and accepts a key over the plaintext channel. The first key crosses the wire in plaintext by protocol design, so only enable this on a trusted LAN. The flag is opt-in and defaults tofalse(no behaviour change for existing users). - Rotation (encrypted). A node that already has a PSK always accepts a new key over its authenticated, encrypted channel — no flag needed.
A provisioned key takes effect on the next connection (each
connection copies the PSK at accept time; HA reconnects automatically).
Pass a Espex.PskStore module as :psk_store to
persist the key so it survives a restart — without one, espex applies
the key to the running server only and logs a warning:
Espex.start_link(
device_config: [name: "my-device", accepts_key_provisioning: true],
psk_store: MyApp.FilePskStore
)
Connected clients
Enumerate the live native-API connections at any time — handy for a "connected clients" dashboard:
Espex.connected_clients(MyApp.EspexServer)
#=> [%Espex.ClientInfo{
#=> peer: "192.168.1.5:54312",
#=> client_info: "Home Assistant 2026.1.0",
#=> api_version: {1, 10},
#=> encrypted?: true,
#=> connected_at: 1_750_000_000,
#=> last_activity_at: 1_750_000_042
#=> }]
To be notified when the set changes (instead of polling), pass a
connection_listener module implementing Espex.ConnectionListener. Its
connections_changed/0 fires once per connect and once per disconnect;
on receipt, re-read connected_clients/1. Start the listener before the
Espex child (:rest_for_one) and reconcile on boot — the callback is
best-effort, while connected_clients/1 is always the source of truth:
children = [
{MyApp.ClientTracker, []},
{Espex,
server_name: MyApp.EspexServer,
device_config: [name: "my-device"],
connection_listener: MyApp.ClientTracker}
]
Supervisor.start_link(children, strategy: :rest_for_one)
mDNS advertising
Advertise the server as a _esphomelib._tcp service so ESPHome clients
auto-discover it. Add :mdns_lite to your application's deps (it's not
a runtime dep of espex) and wire the shipped adapter:
# in your mix.exs
{:mdns_lite, "~> 0.8"}
# at start
Espex.start_link(
device_config: [name: "my-device", ...],
mdns: Espex.Mdns.MdnsLite
)
For non-Nerves setups (e.g. a host running Avahi), implement your own
adapter against the Espex.Mdns behaviour — just advertise(service)
and withdraw(service_id):
defmodule MyApp.AvahiAdapter do
@behaviour Espex.Mdns
@impl true
def advertise(service), do: MyApp.Avahi.publish(service)
@impl true
def withdraw(id), do: MyApp.Avahi.unpublish(id)
end
Espex.start_link(mdns: MyApp.AvahiAdapter, ...)
Development
mix deps.get
mix compile
mix test
mix credo --strict
mix dialyzer
An interactive demo that starts a server advertising a switch, button and sensor and walks you through an HA connection:
mix run test/manual/live_demo.exs # plaintext
ESPEX_ENCRYPT=1 mix run test/manual/live_demo.exs # Noise-encrypted
Roadmap
- Client-side library (connect to ESPHome devices instead of being one)
License
MIT — see LICENSE.