Behaviour for serial proxy adapters.
Implement this module to expose one or more serial ports to ESPHome clients through the Native API's serial proxy feature. Home Assistant can then talk to the port over the network as though it were local — useful for Zigbee coordinators, CLI debug ports, and similar "tunnel a UART through the device" use cases.
Espex itself owns no port state. It calls your callbacks from the per-connection handler process when a client configures, writes to, or closes an instance.
Callbacks
| Callback | Required | Purpose |
|---|---|---|
list_instances/0 | yes | Advertise the available ports |
open/3 | yes | Open an instance with the client's requested UART params |
write/2 | yes | Write bytes to an opened instance |
close/1 | yes | Close and release an instance |
set_modem_pins/3 | no | Toggle RTS/DTR |
get_modem_pins/1 | no | Read RTS/DTR |
request/2 | no | Handle subscribe / unsubscribe / flush |
default_open_opts/1 | no | Options for an espex-initiated lazy open |
Omitted optional callbacks degrade gracefully: request/2
operations are answered with a NOT_SUPPORTED status,
get_modem_pins/1 reports all lines low, set_modem_pins/3
becomes a no-op, and default_open_opts/1 falls back to
default_open_opts/0 (9600-8-N-1) — your adapter can safely omit
whatever the hardware can't do.
Data flow
After a successful open/3, data arriving on the port must be sent
to the subscriber pid (the per-connection handler) as:
{:espex_serial_data, handle, binary}handle is the opaque term you returned from open/3 — typically
a pid, reference, or small tuple. The handler uses it to correlate
the data back to an instance id.
The client can later change port parameters by issuing another
configure request; the connection handler will close/1 the existing
handle and call open/3 again with the new options. Your adapter
doesn't need to handle reconfigure-in-place.
open_opts reference
open/3 receives a keyword list shaped like open_opts/0:
| Key | Values | Default when the client sends 0 |
|---|---|---|
:speed | baud rate | 9600 |
:data_bits | 5, 6, 7, 8 | 8 |
:stop_bits | 1, 2 | 1 |
:parity | :none, :even, :odd | :none |
:flow_control | :none, :hardware | :none |
The defaults follow common "9600-8-N-1" convention and match what
ESPHome uses when fields are omitted from a SerialProxyConfigureRequest.
Use configure_request_to_open_opts/1 if you're building your own
message-routing test harness; the connection handler calls it for
you in normal operation.
SerialProxy.Info for advertisements
list_instances/0 returns a list of Espex.SerialProxy.Info
structs, one per port. Each needs:
instance— stablenon_neg_integer()id; the client refers to ports by this id in all subsequent requestsname— display name shown in the Home Assistant UIport_type—:ttl,:rs232, or:rs485
The list is snapshotted at connection-accept time and cached by the client; see the "Architecture" guide for why changes require a reconnect.
Example: a port wrapping Circuits.UART
The following sketch wires a single port (/dev/ttyUSB0) to a
Zigbee-style advertisement. It assumes a real Circuits.UART-like
library is available; adapt to whichever serial library you use.
defmodule MyApp.SerialAdapter do
@behaviour Espex.SerialProxy
@impl true
def list_instances do
[Espex.SerialProxy.Info.new(instance: 0, name: "zigbee", port_type: :ttl)]
end
@impl true
def open(0, opts, subscriber) do
{:ok, pid} = MyApp.SerialPort.start_link(
device: "/dev/ttyUSB0",
subscriber: subscriber,
speed: opts[:speed],
data_bits: opts[:data_bits],
stop_bits: opts[:stop_bits],
parity: opts[:parity],
flow_control: opts[:flow_control]
)
{:ok, pid}
end
def open(_unknown, _opts, _subscriber), do: {:error, :no_such_instance}
@impl true
def write(pid, data), do: MyApp.SerialPort.write(pid, data)
@impl true
def close(pid) do
_ = MyApp.SerialPort.stop(pid)
:ok
end
@impl true
def set_modem_pins(pid, rts, dtr), do: MyApp.SerialPort.set_pins(pid, rts: rts, dtr: dtr)
@impl true
def get_modem_pins(pid), do: MyApp.SerialPort.get_pins(pid)
@impl true
def request(pid, :flush), do: MyApp.SerialPort.flush(pid)
def request(_pid, _), do: {:ok, :not_supported}
endAnd inside the wrapper GenServer (MyApp.SerialPort), any time your
read loop gets a chunk from the OS, forward it to the subscriber:
send(state.subscriber, {:espex_serial_data, self(), chunk})request/2 semantics
The optional request/2 callback handles the three operations the
ESPHome wire protocol exposes:
:subscribe/:unsubscribe— espex already wires data delivery atopen/3time via thesubscriberpid. Most adapters can return{:ok, :not_supported}or{:ok, :ok}here. Implement them only if your adapter keeps a separate stream-enabled flag you want the client to toggle explicitly.:flush— block until all queued TX data has been drained. Return{:ok, :ok}if you confirmed the drain,{:ok, :assumed_success}if the platform can't report completion,{:ok, :timeout}if you gave up waiting, or{:error, reason}on failure.
When you don't define request/2 at all, espex responds with
:not_supported to every request type automatically.
Upstream ESPHome has no open/close lifecycle for the serial proxy — the
UART is always live at its YAML-configured settings, and
SerialProxyConfigureRequest is optional re-tuning, not a prerequisite.
Espex mirrors that: a connection's first serial-proxy operation of any
kind (write, subscribe, modem pins, flush) against an advertised
instance lazily opens it via open/3, using default_open_opts/1
when you export it (or the 9600-8-N-1 fallback otherwise). This lets a
client resume traffic after a reconnect — e.g. Home Assistant writing
to a Zigbee coordinator without re-sending CONFIGURE — instead of
getting every write silently dropped.
SUBSCRIBE/UNSUBSCRIBE track a per-connection subscribe intent rather
than a one-shot stash: after every successful open/3 (whether
triggered by a lazy open or by CONFIGURE), espex re-issues request/2
with :subscribe if the intent is set, so a subscription survives
reconfiguration instead of being consumed by the first open. Your
adapter still never receives request/2 before open/3 — the
handle argument is always one you just returned. A :subscribe or
:unsubscribe that can't reach the adapter yet (the lazy/configure
open failed) is still acknowledged OK; the intent is remembered and
reattached on the next successful open. :flush keeps its original
semantics; if the lazy open itself fails the client receives an ERROR
response (error_message: ":not_open").
A failed lazy open is retried at most once per second per instance; an explicit CONFIGURE always attempts an open, and a successful open resets the backoff.
Wiring
Pass your adapter module to Espex.start_link/1:
Espex.start_link(
device_config: [name: "serial-gateway"],
serial_proxy: MyApp.SerialAdapter
)
Summary
Types
Options for opening a serial port. Keys follow the fields on
SerialProxyConfigureRequest but normalised to atoms.
Internal atom form of the SerialProxyStatus enum.
Internal atom form of the SerialProxyRequestType enum.
Callbacks
Close an opened instance and release any associated resources.
Return the options espex should use when it opens instance lazily —
i.e. when a client sends a write/subscribe/modem-pins/flush request
without a prior SerialProxyConfigureRequest on this connection.
Read the current state of the RTS and DTR modem control lines.
Optional — return {:error, :not_supported} if the adapter doesn't
support modem pin control.
Return the list of available serial proxy instances.
Open the given instance with the supplied options. Data received on the
port must be forwarded to subscriber as {:espex_serial_data, handle, binary}.
Handle one of the SerialProxyRequest operations (subscribe,
unsubscribe, flush) and return a status for the client. Optional —
when undefined, espex responds with :not_supported.
Set the RTS and DTR modem control lines. Optional — return
{:error, :not_supported} if the adapter doesn't support modem
pin control.
Write bytes to an opened instance.
Functions
Translate a SerialProxyConfigureRequest protobuf into the keyword
list passed to open/3. Zero-valued protobuf fields fall back to
sensible defaults (9600-8-N-1, no flow control).
The fallback options used when espex lazily opens an instance and the
adapter does not export default_open_opts/1: 9600-8-N-1, no flow
control (the same defaults used for zero-valued fields in a
SerialProxyConfigureRequest).
Types
@type handle() :: term()
Opaque handle returned by the adapter from open/3.
@type open_opts() :: [ speed: non_neg_integer(), data_bits: 5..8, stop_bits: 1..2, parity: :none | :even | :odd, flow_control: :none | :hardware ]
Options for opening a serial port. Keys follow the fields on
SerialProxyConfigureRequest but normalised to atoms.
@type request_status() :: :ok | :assumed_success | :error | :timeout | :not_supported
Internal atom form of the SerialProxyStatus enum.
@type request_type() :: :subscribe | :unsubscribe | :flush
Internal atom form of the SerialProxyRequestType enum.
Callbacks
@callback close(handle()) :: :ok
Close an opened instance and release any associated resources.
@callback default_open_opts(instance :: non_neg_integer()) :: open_opts()
Return the options espex should use when it opens instance lazily —
i.e. when a client sends a write/subscribe/modem-pins/flush request
without a prior SerialProxyConfigureRequest on this connection.
On real ESPHome hardware the UART is always live with its
YAML-configured settings, so clients legitimately resume traffic after a
reconnect without re-configuring. Implement this to supply your port's
natural settings (e.g. the baud rate the attached device actually uses).
When not exported, espex falls back to default_open_opts/0
(9600-8-N-1).
Read the current state of the RTS and DTR modem control lines.
Optional — return {:error, :not_supported} if the adapter doesn't
support modem pin control.
@callback list_instances() :: [Espex.SerialProxy.Info.t()]
Return the list of available serial proxy instances.
@callback open(instance :: non_neg_integer(), open_opts(), subscriber :: pid()) :: {:ok, handle()} | {:error, term()}
Open the given instance with the supplied options. Data received on the
port must be forwarded to subscriber as {:espex_serial_data, handle, binary}.
@callback request(handle(), request_type()) :: {:ok, request_status()} | {:error, term()}
Handle one of the SerialProxyRequest operations (subscribe,
unsubscribe, flush) and return a status for the client. Optional —
when undefined, espex responds with :not_supported.
Subscribe/unsubscribe are currently no-ops in the default espex flow
(data delivery is wired at open/3 time); adapters that care about
explicit stream control can implement the toggle here.
Set the RTS and DTR modem control lines. Optional — return
{:error, :not_supported} if the adapter doesn't support modem
pin control.
Write bytes to an opened instance.
Functions
@spec configure_request_to_open_opts(Espex.Proto.SerialProxyConfigureRequest.t()) :: open_opts()
Translate a SerialProxyConfigureRequest protobuf into the keyword
list passed to open/3. Zero-valued protobuf fields fall back to
sensible defaults (9600-8-N-1, no flow control).
@spec default_open_opts() :: open_opts()
The fallback options used when espex lazily opens an instance and the
adapter does not export default_open_opts/1: 9600-8-N-1, no flow
control (the same defaults used for zero-valued fields in a
SerialProxyConfigureRequest).