Behaviour for Z-Wave proxy adapters.
Implement this module to proxy a Z-Wave Serial API controller (e.g.
a 700/800-series Silicon Labs stick) over the ESPHome Native API.
Home Assistant's Z-Wave JS integration drives a proxied controller
identically to a locally-attached one — you just point it at the
espex server instead of a /dev/tty* path.
Espex acts as a framing shim only. Home Assistant and the controller exchange raw Z-Wave Serial API bytes; your adapter's job is to wrap the hardware link and fan frames out to subscribed connection handlers.
Callbacks
| Callback | Purpose |
|---|---|
available?/0 | Is the controller reachable right now? |
home_id/0 | Current 32-bit home ID (0 if no network) |
feature_flags/0 | DeviceInfoResponse.zwave_proxy_feature_flags bitfield |
subscribe/1 | Register a connection handler for inbound frames |
unsubscribe/1 | Deregister a handler (idempotent) |
send_frame/1 | Send a client-originated frame to the controller |
All six are required; there are no optional callbacks.
Data flow
Subscribers receive two kinds of messages from your adapter:
{:espex_zwave_frame, binary} # raw Z-Wave Serial API frame
{:espex_zwave_home_id_changed, <<_::32>>} # new home ID (4-byte binary)Home ID changes matter because Home Assistant learns the network id at subscribe time; if the controller is reset or the network rekeyed while a client is connected, send the change event so HA's stored state stays in sync.
subscribe/1 returns {:ok, home_id_bytes} where home_id_bytes
is the current home ID as a 4-byte binary. Espex uses the value to
decide whether to emit an initial change notification to the new
subscriber.
feature_flags/0 and home_id/0
These two callbacks are called when a client issues
DeviceInfoRequest. Set feature_flags/0 to at least 0x01 (bit 0)
to signal "Z-Wave proxy available". Return 0 from home_id/0 when
no controller is attached — clients will treat that as "no network".
Example
This sketch assumes a single controller and uses a simple Registry for multi-client fan-out. A production adapter would also handle controller reconnects and buffer frames during transient outages.
defmodule MyApp.ZWaveAdapter do
@behaviour Espex.ZWaveProxy
use GenServer
@registry MyApp.ZWaveSubscribers
def start_link(opts), do: GenServer.start_link(__MODULE__, opts, name: __MODULE__)
@impl true
def available?, do: GenServer.call(__MODULE__, :available?)
@impl true
def home_id, do: GenServer.call(__MODULE__, :home_id)
@impl true
def feature_flags, do: 0x01
@impl true
def subscribe(pid) do
{:ok, _} = Registry.register(@registry, :subscribers, nil)
# caller is `pid`, not self() — redirect if your Registry usage differs
{:ok, home_id_bytes()}
end
@impl true
def unsubscribe(pid) do
Registry.unregister(@registry, :subscribers)
:ok
end
@impl true
def send_frame(data), do: MyApp.ZWaveController.write(data)
# Called from the hardware read loop whenever a frame arrives:
def broadcast_frame(data) do
Registry.dispatch(@registry, :subscribers, fn entries ->
Enum.each(entries, fn {pid, _} -> send(pid, {:espex_zwave_frame, data}) end)
end)
end
defp home_id_bytes do
<<home_id()::32>>
end
# ... init/1, handle_call/3, reconnect logic, etc.
endWiring
Espex.start_link(
device_config: [name: "zwave-gateway"],
zwave_proxy: MyApp.ZWaveAdapter
)
Summary
Callbacks
Is the Z-Wave controller currently reachable?
Feature flags reported to ESPHome clients. See
DeviceInfoResponse.zwave_proxy_feature_flags.
Current Z-Wave home ID as a 32-bit integer (0 if none).
Send a raw Z-Wave Serial API frame to the controller.
Subscribe the given pid to Z-Wave frames.
Unsubscribe a previously subscribed pid. Idempotent.
Callbacks
@callback available?() :: boolean()
Is the Z-Wave controller currently reachable?
@callback feature_flags() :: non_neg_integer()
Feature flags reported to ESPHome clients. See
DeviceInfoResponse.zwave_proxy_feature_flags.
@callback home_id() :: non_neg_integer()
Current Z-Wave home ID as a 32-bit integer (0 if none).
Send a raw Z-Wave Serial API frame to the controller.
Subscribe the given pid to Z-Wave frames.
Returns the current home ID as a 4-byte binary so the subscriber can decide whether to emit an initial change event.
@callback unsubscribe(subscriber :: pid()) :: :ok
Unsubscribe a previously subscribed pid. Idempotent.