Behaviour for entity providers — the pluggable source of custom ESPHome entities (sensors, switches, lights, covers, climate, …) beyond the built-in serial, Z-Wave, and infrared proxies.
See the "Entity types" guide for the specific proto structs and field semantics for each entity type. This module documents the behaviour itself and the two common patterns for implementing it.
Callbacks
| Callback | When called |
|---|---|
list_entities/0 | Once per connection, when the client sends ListEntitiesRequest |
initial_states/0 | Once per connection, when the client sends SubscribeStatesRequest |
handle_command/1 | Each time the client issues a command struct for one of your entities |
handle_command/2 | Optional. Same, but also given the connection's security context — preferred over handle_command/1 when exported |
Return values:
list_entities/0andinitial_states/0return lists ofEspex.Proto.*structs. Use the advertisement structs (ListEntities*Response) in the first, and the state structs (*StateResponse) in the second. See the entity-types guide.handle_command/1returns:okon success or{:error, term}on failure. Espex currently logs errors and continues — it does not send an error back to the client.handle_command/2has the same return contract. Implement it to refuse dangerous commands (reboot, factory reset, firmware install) on an unauthenticated connection — see its docs for why that judgement has to live in the provider rather than in Espex.
Frozen-at-accept-time snapshot
list_entities/0 is called exactly once per connection, at accept
time. The returned list is cached by the connection handler for the
lifetime of the connection. ESPHome clients (including Home
Assistant) also cache the advertisement after the first
ListEntitiesRequest.
Implication: if you dynamically add or remove entities at runtime, existing clients will continue to see the set that was returned when they connected. A reconnect is required to pick up changes.
The stateful provider pattern (GenServer)
Most real providers hold mutable state — current values of each
entity, observer pids, connections to downstream hardware — so a
GenServer is the natural fit. The provider module implements both
the Espex.EntityProvider behaviour and the GenServer behaviour;
the behaviour callbacks typically delegate reads into GenServer.call/2
and commands into GenServer.cast/2 against itself.
defmodule MyApp.Entities do
@behaviour Espex.EntityProvider
use GenServer
alias Espex.Proto
@switch_key 1001
@sensor_key 1002
# --- public API ---
def start_link(opts), do: GenServer.start_link(__MODULE__, opts, name: __MODULE__)
def set_sensor(value), do: GenServer.call(__MODULE__, {:set_sensor, value})
# --- EntityProvider callbacks ---
@impl Espex.EntityProvider
def list_entities do
[
%Proto.ListEntitiesSwitchResponse{
object_id: "demo_switch", key: @switch_key, name: "Demo Switch"
},
%Proto.ListEntitiesSensorResponse{
object_id: "demo_sensor", key: @sensor_key, name: "Demo Sensor",
unit_of_measurement: "°C", accuracy_decimals: 1
}
]
end
@impl Espex.EntityProvider
def initial_states do
GenServer.call(__MODULE__, :initial_states)
end
@impl Espex.EntityProvider
def handle_command(command) do
GenServer.cast(__MODULE__, {:command, command})
end
# --- GenServer callbacks ---
@impl GenServer
def init(opts) do
{:ok, %{switch: false, sensor: 20.0, server: Keyword.fetch!(opts, :server)}}
end
@impl GenServer
def handle_call(:initial_states, _from, state) do
responses = [
%Proto.SwitchStateResponse{key: @switch_key, state: state.switch},
%Proto.SensorStateResponse{key: @sensor_key, state: state.sensor, missing_state: false}
]
{:reply, responses, state}
end
def handle_call({:set_sensor, value}, _from, state) do
Espex.push_state(state.server, %Proto.SensorStateResponse{
key: @sensor_key, state: value, missing_state: false
})
{:reply, :ok, %{state | sensor: value}}
end
@impl GenServer
def handle_cast({:command, %Proto.SwitchCommandRequest{key: @switch_key, state: s}}, state) do
Espex.push_state(state.server, %Proto.SwitchStateResponse{key: @switch_key, state: s})
{:noreply, %{state | switch: s}}
end
def handle_cast({:command, _unknown}, state), do: {:noreply, state}
endNote the two key moves:
handle_command/1casts back to self. Commands arrive on whichever connection handler received them; delegating to the provider GenServer keeps the single owner of the state the serializing point for all updates.push_state/2broadcasts to every connection. Each subscribed connection'shandle_info/2will see the state frame. If only the commanding client is connected, it's still the right behaviour — multiple HA installs or other clients stay in sync.
The stateless provider pattern
When your "state" lives outside espex (an external API, a database, an ETS table owned by another process), you don't need a GenServer. Implement the three callbacks as plain module functions:
defmodule MyApp.Readonly do
@behaviour Espex.EntityProvider
alias Espex.Proto
@impl true
def list_entities do
[
%Proto.ListEntitiesBinarySensorResponse{
object_id: "door_open", key: 1, name: "Door Open", device_class: "door"
}
]
end
@impl true
def initial_states do
[
%Proto.BinarySensorStateResponse{
key: 1, state: MyApp.DoorSensor.current(), missing_state: false
}
]
end
@impl true
def handle_command(_), do: :ok # no commands for read-only sensors
endPushing state updates
Espex.push_state/2 broadcasts a state struct to every currently
connected client that's subscribed via SubscribeStatesRequest.
Pass the server name you configured (defaults to Espex.Server):
Espex.push_state(MyApp.EspexServer, %Proto.SensorStateResponse{
key: 1002,
state: 23.5,
missing_state: false
})With no subscribed clients, the broadcast is a silent no-op. See the "Architecture" guide for the underlying Registry fan-out mechanics.
Wiring
Start the provider under your own supervisor (or the application tree), then point espex at it:
children = [
{MyApp.Entities, server: MyApp.EspexServer},
{Espex,
name: MyApp.EspexSup,
server_name: MyApp.EspexServer,
device_config: [name: "my-device"],
entity_provider: MyApp.Entities}
]
Supervisor.start_link(children, strategy: :rest_for_one)Starting the provider before espex (and using :rest_for_one)
ensures espex can't receive a connection before the provider is
ready to answer list_entities/0.
Summary
Callbacks
Handle a command from an ESPHome client — e.g.
%Espex.Proto.SwitchCommandRequest{}, %Espex.Proto.LightCommandRequest{}.
Same as handle_command/1, but also given the originating
connection's security context. Preferred when exported — Espex calls
this in place of handle_command/1.
Return the initial state for each advertised entity, sent when a
client subscribes. One struct per entity — typically the matching
Espex.Proto.*StateResponse message for the entity type.
Return the list of entities this provider advertises. One struct per
entity — typically a mix of the various Espex.Proto.ListEntities*Response
messages.
Callbacks
Handle a command from an ESPHome client — e.g.
%Espex.Proto.SwitchCommandRequest{}, %Espex.Proto.LightCommandRequest{}.
@callback handle_command(command :: struct(), context :: %{encrypted?: boolean()}) :: :ok | {:error, term()}
Same as handle_command/1, but also given the originating
connection's security context. Preferred when exported — Espex calls
this in place of handle_command/1.
The context is currently:
%{encrypted?: boolean()}encrypted? is true only once a Noise session is established. It is
false whenever the server is running keyless, because the ESPHome
protocol offers no other authentication: AuthenticationRequest carries
a password field, but Espex answers every such request with
invalid_password: false. On a keyless server, any host that can open
a TCP connection can therefore issue entity commands.
That is usually acceptable — the keyless window exists so Home Assistant can adopt the device and provision a PSK, and most entity commands are innocuous. It is not acceptable for commands that reboot, wipe or reflash the device. Espex cannot tell those apart: which entity is dangerous is the provider's knowledge, not the protocol's. Implement this callback to make that judgement:
@impl Espex.EntityProvider
def handle_command(command, %{encrypted?: false}) do
if privileged?(command) do
Logger.warning("refusing privileged command on an unencrypted connection")
:ok
else
handle_command(command)
end
end
def handle_command(command, _context), do: handle_command(command)Providers that don't export this keep receiving handle_command/1
unchanged.
@callback initial_states() :: [struct()]
Return the initial state for each advertised entity, sent when a
client subscribes. One struct per entity — typically the matching
Espex.Proto.*StateResponse message for the entity type.
@callback list_entities() :: [struct()]
Return the list of entities this provider advertises. One struct per
entity — typically a mix of the various Espex.Proto.ListEntities*Response
messages.