espex 0.12.0 advertises API 1.18. This page covers what 1.18 added and how espex implements each part. Every feature is optional: configure its adapter or option, or leave it out.

What's new

FeatureMessagesespex
IR/RF transmit completionInfraredRFTransmitCompleteResponseEspex.InfraredProxy
SendspinDeviceCapabilitiesResponse.sendspin, SendspinPairingTokenRequestEspex.Sendspin
Serial proxy identitySubscribeSerialProxyIdentityRequest, SerialProxyIdentityEspex.SerialProxy.identity/1
Device wizardDeviceWizardRequest, WizardInputSetRequestEspex.Wizard
Home Assistant statesSubscribeHomeAssistantStatesRequest, SubscribeHomeAssistantStateResponse, HomeAssistantStateResponseEspex.HomeAssistantStates
Wizard inputs linked to a statethe two above togetherlinks on Espex.Wizard.Input
Dial-backHelloRequest.outgoing_connection_target, DeviceInfoResponse.api_outgoing_connection_supported:outgoing_connection, Espex.OutgoingTargetStore

What espex implements

IR/RF transmit completion

A client on 1.18 sends an IR/RF frame and waits for InfraredRFTransmitCompleteResponse before the next one (or 35 s past the frame's air time). espex answers with the return of Espex.InfraredProxy.transmit_raw/3, which runs in a task, off the connection, so the connection keeps serving other requests meanwhile. The rules follow ESPHome firmware:

SituationReply
transmit_raw/3 returns :oksuccess: true
It returns {:error, _}, raises or exitssuccess: false
No adapter, no timings, or a key list_entities/0 did not advertisesuccess: false at once, adapter not called
A newer request for the same key, from any clientsuccess: false for the older one at once; if it had not started it is dropped, if it is running it finishes
No return 30 s past the frame's air time (air time counted up to 8 min)success: false; the call is killed and the next frame starts
The requester disconnectednone; its frames still go out
The client is below API 1.18none, refusals included; any newer request for the key drops its frame if not yet started

Calls run one at a time per adapter module, in arrival order, and in parallel across modules. A request runs on the adapter its connection started with.

Sendspin

With an Espex.Sendspin adapter, DeviceCapabilitiesResponse carries its client_id/0 and feature flags, read on every request, and SendspinPairingTokenRequest is answered from pairing_token/0. The token goes over Noise only; a plaintext session gets ENCRYPTION_REQUIRED.

Serial proxy identity

SubscribeSerialProxyIdentityRequest is answered with one SerialProxyIdentity per advertised port, from the optional Espex.SerialProxy.identity/1. Later changes go to subscribed clients with Espex.push_serial_proxy_identity/2.

Device wizard

With an Espex.Wizard adapter (OTP 28 or newer), capabilities report a wizard, DeviceWizardRequest is answered with the Espex.Wizard.Document compressed as the protocol requires, and each valid WizardInputSetRequest reaches Espex.Wizard.input_set/2.

Home Assistant states

The device keeps a table of Espex.HomeAssistantStates.Subscription entries (an entity id and an optional attribute). On SubscribeHomeAssistantStatesRequest espex sends one SubscribeHomeAssistantStateResponse per entry; Home Assistant then sends each entry's state as it changes, and espex hands it to Espex.HomeAssistantStates.handle_state/3.

Entries come from Espex.HomeAssistantStates.subscriptions/0, from Espex.subscribe_home_assistant_state/2 at runtime, and from linked wizard inputs. An entry asked for twice is sent once. A client that has already subscribed is sent new entries as they appear; removing one sends nothing, as the protocol has no unsubscribe.

Wizard inputs linked to a state

An Espex.Wizard.Input with links subscribes to the entity the user picks. Setting the input replaces its entries in the table, and every client that subscribed to states is sent the new ones. Unlike firmware, setting the value an input already has sends nothing (firmware resends, which makes Home Assistant track the entity twice). The wizard JSON has no field for links; they stay on the device.

Dial-back

With :outgoing_connection, the node dials Home Assistant's dial-back listener (port 6054) when no client is connected, for networks where Home Assistant cannot reach it. The dialed connection sends its Noise hello first and is otherwise a normal connection. The target is a configured IP address, or else learned from the client whose hello sets outgoing_connection_target and kept through an Espex.OutgoingTargetStore. The timing rules (first dial after delay, no dial while a target client is connected, 5 s backoff doubling to 300 s) are under "Dial-back" in Espex.Supervisor.

Usage

defmodule MyApp.HAStates do
  @behaviour Espex.HomeAssistantStates

  alias Espex.HomeAssistantStates.Subscription

  @impl true
  def subscriptions, do: [Subscription.new!(entity_id: "sun.sun", attribute: "elevation")]

  @impl true
  def handle_state(entity_id, attribute, state) do
    send(MyApp.Display, {:ha_state, entity_id, attribute, state})
    :ok
  end
end

defmodule MyApp.Wizard do
  @behaviour Espex.Wizard

  alias Espex.Wizard.{Document, EntityFilter, Input, Page}

  @impl true
  def wizard do
    Document.new!(
      pages: [
        %Page{
          title: "Outdoor temperature",
          inputs: [
            %Input{
              id: "outdoor",
              entity_filters: [%EntityFilter{domain: ["sensor"]}],
              links: [[]]
            }
          ]
        }
      ]
    )
  end

  @impl true
  def input_set(_key, _entity_ids), do: :ok
end

target = MyApp.TargetStore.load()

Espex.start_link(
  device_config: [name: "my-device", psk: MyApp.Keys.psk()],
  infrared_proxy: MyApp.IRBlaster,
  sendspin: MyApp.Sendspin,
  wizard: MyApp.Wizard,
  home_assistant_states: MyApp.HAStates,
  outgoing_connection: if(target, do: [target: target], else: []),
  outgoing_target_store: MyApp.TargetStore
)

# Later, at runtime:
:ok = Espex.subscribe_home_assistant_state("weather.home")

Upgrade notes

  • The Noise server hello carries the bare MAC. It is now 12 lowercase hex digits ("aabbccddeeff") on every connection, as firmware sends it; Home Assistant's dial-back listener requires that form. DeviceInfoResponse.mac_address keeps the configured form. A tool of your own that read the colon form from the hello must change.
  • IR/RF transmits no longer block the connection. They run in a task, so Espex.InfraredProxy.transmit_raw/3 may run while the same connection handles other messages, and may run at the same time as calls on other adapter modules.
  • Long IR/RF frames are no longer refused. espex used to refuse a frame of more than 30 s on air. It now runs it, and kills the call 30 s past the frame's air time (air time counted up to 8 minutes).
  • Clients below API 1.18 get no transmit reply. espex used to reply to every client; it now replies as firmware does.
  • IR/RF requests for an unknown key are refused without calling the adapter.
  • thousand_island 1.5 or newer is required, for dial-back.