Espex.ZWaveProxy behaviour (espex v0.1.1)

Copy Markdown View Source

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

CallbackPurpose
available?/0Is the controller reachable right now?
home_id/0Current 32-bit home ID (0 if no network)
feature_flags/0DeviceInfoResponse.zwave_proxy_feature_flags bitfield
subscribe/1Register a connection handler for inbound frames
unsubscribe/1Deregister a handler (idempotent)
send_frame/1Send 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.
end

Wiring

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

available?()

@callback available?() :: boolean()

Is the Z-Wave controller currently reachable?

feature_flags()

@callback feature_flags() :: non_neg_integer()

Feature flags reported to ESPHome clients. See DeviceInfoResponse.zwave_proxy_feature_flags.

home_id()

@callback home_id() :: non_neg_integer()

Current Z-Wave home ID as a 32-bit integer (0 if none).

send_frame(data)

@callback send_frame(data :: binary()) :: :ok | {:error, term()}

Send a raw Z-Wave Serial API frame to the controller.

subscribe(subscriber)

@callback subscribe(subscriber :: pid()) ::
  {:ok, home_id_bytes :: <<_::32>>} | {:error, term()}

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.

unsubscribe(subscriber)

@callback unsubscribe(subscriber :: pid()) :: :ok

Unsubscribe a previously subscribed pid. Idempotent.