Skip to content
49 changes: 41 additions & 8 deletions lib/universal_proxy/esphome/zwave_proxy.ex
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,9 @@ defmodule UniversalProxy.ESPHome.ZWaveProxy do
parser: nil,
home_id: @zero_home_id,
home_id_ready: false,
query_retries: 0
query_retries: 0,
espex_server: Espex.Server,
open_fun: nil
]

# -- Adapter wiring --
Expand Down Expand Up @@ -111,7 +113,17 @@ defmodule UniversalProxy.ESPHome.ZWaveProxy do

@impl Espex.ZWaveProxy
def feature_flags do
if available?(), do: 0x01, else: 0
# Always advertise the Z-Wave proxy capability (matches ESPHome's
# compile-time constant). The flag means "this device can proxy
# Z-Wave"; whether a controller is actually attached is conveyed by
# `home_id/0` (0 = no network). Reporting 0 when no stick was
# present broke cold-hotplug discovery: HA arms its HOME_ID_CHANGE
# listener only if the flag is set AT CONNECT (esphome/manager.py),
# so a stick plugged in later never surfaced without a reconnect.
# With the flag always on, the home-ID broadcast (F4) reaches HA and
# discovery fires live; HA still won't create a flow until a nonzero
# home ID arrives, so advertising with no stick is inert.
0x01
end

@impl Espex.ZWaveProxy
Expand Down Expand Up @@ -169,7 +181,12 @@ defmodule UniversalProxy.ESPHome.ZWaveProxy do
parser: Parser.new(),
port_path: Keyword.get(opts, :port_path),
display_name: Keyword.get(opts, :display_name),
resolver: Keyword.get(opts, :resolver)
resolver: Keyword.get(opts, :resolver),
espex_server: Keyword.get(opts, :espex_server, Espex.Server),
# Seam for tests: the real opener needs a live tty. Production
# always uses open_port/1; tests inject a fake to exercise the
# open-success path (resolve_port, :uart_port_opened broadcast).
open_fun: Keyword.get(opts, :open_fun, &open_port/1)
Comment thread
bbangert marked this conversation as resolved.
Outdated
}

if state.port_path || state.resolver do
Expand Down Expand Up @@ -353,7 +370,7 @@ defmodule UniversalProxy.ESPHome.ZWaveProxy do
schedule_reopen(state)

{port_path, display_name} ->
case open_port(port_path) do
case state.open_fun.(port_path) do
{:ok, uart_pid} ->
Logger.info("Z-Wave proxy started on #{port_path}")

Expand Down Expand Up @@ -523,18 +540,34 @@ defmodule UniversalProxy.ESPHome.ZWaveProxy do
end

# Mirrors ESPHome's clear_home_id_: on device loss, zero the ID and
# tell the subscriber so the client's stored network identity resets.
# broadcast so every client's stored network identity resets.
defp clear_home_id(state), do: put_home_id(state, @zero_home_id)

defp put_home_id(%{home_id: home_id} = state, home_id), do: state

defp put_home_id(state, new_home_id) do
Logger.info("Z-Wave home ID changed: #{inspect(new_home_id)}")

if state.subscriber do
send(state.subscriber, {:espex_zwave_home_id_changed, new_home_id})
end
# Broadcast to ALL espex connections, not just the frame subscriber.
# HA's zwave_js discovery runs on a connection that never subscribes,
# and a stick hot-plugged after HA connected must still be seen — the
# subscriber-only send missed both (espex F4). Espex fans this out
# via its connection registry.
broadcast_home_id(state, new_home_id)

%{state | home_id: new_home_id}
end

# `Registry.dispatch` raises ArgumentError when espex's registry isn't
# started — which happens at boot: ZWaveProxy is the FIRST child of the
# rest_for_one ESPHome tree, so the very first home-ID read can precede
# Espex's registry. Rescue it (per the project's registry convention,
# NOT catch :exit) and drop the broadcast: the auth-time push in espex
# (`:client_connected`) re-delivers the current home ID to every client
# once they connect, so nothing is permanently lost.
defp broadcast_home_id(state, home_id) do
Espex.push_zwave_home_id(state.espex_server, home_id)
rescue
ArgumentError -> :ok
end
end
40 changes: 39 additions & 1 deletion lib/universal_proxy/hardware.ex
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,17 @@ defmodule UniversalProxy.Hardware do
chip: "Silicon Labs EFR32ZG23",
locked: true
}},
# Aeotec Z-Stick Gen5+ — unambiguous VID/PID (HA zwave_js manifest).
# Other Z-Wave sticks share the generic Silicon Labs CP210x VID/PID
# (10C4:EA60/8A2A) and are disambiguated by USB descriptor strings in
# `zwave_by_descriptor/1` rather than a fixed table entry.
{0x0658, 0x0200,
%{
kind: :zwave,
name: "Aeotec Z-Stick Gen5+",
vendor: "Aeotec",
locked: true
}},
{0x04D8, 0xFD08,
%{
kind: :ir,
Expand Down Expand Up @@ -452,7 +463,7 @@ defmodule UniversalProxy.Hardware do
pid = info[:product_id]
serial = info[:serial_number]
{in_use?, user} = port_usage(tty_name, usage)
device_info = Map.get(@usb_device_table, {vid, pid})
device_info = zwave_by_descriptor(info) || Map.get(@usb_device_table, {vid, pid})
saved_cfg = Map.get(saved, {slot_sub, vid, pid})

classification = classify(device_info, saved_cfg)
Expand Down Expand Up @@ -599,6 +610,33 @@ defmodule UniversalProxy.Hardware do
}
end

# Several Z-Wave 800 sticks (SONOFF ZBDongle-Z, Nortek HUSBZB-1, …)
# ship the generic Silicon Labs CP210x bridge (VID 0x10C4), so VID/PID
# alone can't tell them from a plain USB-serial cable. HA's zwave_js
# manifest disambiguates by USB descriptor string; mirror that. Gated
# to VID 0x10C4 + a "z-wave"/"zwave" token in the descriptor so a bare
# CP2102 cable (e.g. the ZWA-2's own bridge reports "CP2102N USB to
# UART Bridge") never false-positives. Returns a synthetic locked
# device-info map so the normal branded-lock classify path applies, or
# nil to fall through to the VID/PID table.
defp zwave_by_descriptor(info) do
if info[:vendor_id] == 0x10C4 and zwave_descriptor?(info) do
name = descriptor_name(info) || "Z-Wave Controller"
%{kind: :zwave, name: name, vendor: info[:manufacturer], locked: true}
end
end

@zwave_descriptor ~r/z-?wave/i

defp zwave_descriptor?(info) do
[info[:product], info[:description], info[:manufacturer]]
|> Enum.any?(fn s -> is_binary(s) and Regex.match?(@zwave_descriptor, s) end)
end

defp descriptor_name(info) do
Enum.find([info[:product], info[:description]], fn s -> is_binary(s) and s != "" end)
end

# Branded device: hardware-locked, ignore any saved override.
defp classify(%{locked: true, kind: kind}, _saved) do
%{
Expand Down
4 changes: 2 additions & 2 deletions mix.exs
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ defmodule UniversalProxy.MixProject do
use Mix.Project

@app :universal_proxy
@version "0.8.2"
@version "0.8.3"
@all_targets [
:bbb,
:mangopi_mq_pro,
Expand Down Expand Up @@ -113,7 +113,7 @@ defmodule UniversalProxy.MixProject do
{:muontrap, "~> 1.8"},

# ESPHome Native API server library
{:espex, "~> 0.6.0"},
{:espex, "~> 0.7"},

# Dependencies for all targets
{:nerves, "~> 1.10", runtime: false},
Expand Down
2 changes: 1 addition & 1 deletion mix.lock
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
"elixir_make": {:hex, :elixir_make, "0.9.0", "6484b3cd8c0cee58f09f05ecaf1a140a8c97670671a6a0e7ab4dc326c3109726", [:mix], [], "hexpm", "db23d4fd8b757462ad02f8aa73431a426fe6671c80b200d9710caf3d1dd0ffdb"},
"erlex": {:hex, :erlex, "0.2.8", "cd8116f20f3c0afe376d1e8d1f0ae2452337729f68be016ea544a72f767d9c12", [:mix], [], "hexpm", "9d66ff9fedf69e49dc3fd12831e12a8a37b76f8651dd21cd45fcf5561a8a7590"},
"esbuild": {:hex, :esbuild, "0.10.0", "b0aa3388a1c23e727c5a3e7427c932d89ee791746b0081bbe56103e9ef3d291f", [:mix], [{:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "468489cda427b974a7cc9f03ace55368a83e1a7be12fba7e30969af78e5f8c70"},
"espex": {:hex, :espex, "0.6.0", "48c5033ac0d8b83edefa78b76cdf2aed863e222a382654528c16ae09c9094030", [:mix], [{:protobuf, "~> 0.12", [hex: :protobuf, repo: "hexpm", optional: false]}, {:protobuf_generate, "~> 0.2", [hex: :protobuf_generate, repo: "hexpm", optional: false]}, {:thousand_island, "~> 1.4", [hex: :thousand_island, repo: "hexpm", optional: false]}], "hexpm", "421ec86f7af67ccb4b63976972b865d9115ed889ca42757ce5da3a4f770fde7c"},
"espex": {:hex, :espex, "0.7.0", "3f4d167c5bcf2acd60dc1d6bbaf2fb8a863396fc78ca918413e6015d15e51534", [:mix], [{:protobuf, "~> 0.12", [hex: :protobuf, repo: "hexpm", optional: false]}, {:protobuf_generate, "~> 0.2", [hex: :protobuf_generate, repo: "hexpm", optional: false]}, {:thousand_island, "~> 1.4", [hex: :thousand_island, repo: "hexpm", optional: false]}], "hexpm", "f973f7c33cc2a449a8fe5ac2f944ff652483c3df98cee9ddca983b4bd220ff33"},
"expo": {:hex, :expo, "1.1.1", "4202e1d2ca6e2b3b63e02f69cfe0a404f77702b041d02b58597c00992b601db5", [:mix], [], "hexpm", "5fb308b9cb359ae200b7e23d37c76978673aa1b06e2b3075d814ce12c5811640"},
"file_system": {:hex, :file_system, "1.1.1", "31864f4685b0148f25bd3fbef2b1228457c0c89024ad67f7a81a3ffbc0bbad3a", [:mix], [], "hexpm", "7a15ff97dfe526aeefb090a7a9d3d03aa907e100e262a0f8f7746b78f8f87a5d"},
"finch": {:hex, :finch, "0.23.0", "e3f9287ac25a8832f848b144c2b57346aac65b205e2e0629a52adfe6507fd837", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mint, "~> 1.8", [hex: :mint, repo: "hexpm", optional: false]}, {:nimble_options, "~> 0.4 or ~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:nimble_pool, "~> 1.1", [hex: :nimble_pool, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "80e58d3f936f57e3fdf404f83a3642897ae6d9fb642934e46da4d8fe761b99d5"},
Expand Down
94 changes: 91 additions & 3 deletions test/universal_proxy/esphome/zwave_proxy_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,19 @@ defmodule UniversalProxy.ESPHome.ZWaveProxyTest do
fake
end

# Stand up an espex-convention connection registry with self() registered
# as a subscriber, so `Espex.push_zwave_home_id/2` broadcasts land here.
# Returns the server name to feed the adapter as `:espex_server`. Using
# `Espex.Supervisor.registry_name/1` guarantees the name matches what the
# broadcast dispatches to.
defp start_espex_registry! do
server = Module.concat(__MODULE__, "Server#{System.unique_integer([:positive])}")
registry = Espex.Supervisor.registry_name(server)
start_supervised!({Registry, keys: :duplicate, name: registry})
{:ok, _} = Registry.register(registry, :subscribers, nil)
server
Comment thread
bbangert marked this conversation as resolved.
end

describe "without hardware" do
test "available? returns false", %{server: server} do
refute ZWaveProxy.available?(server)
Expand Down Expand Up @@ -87,15 +100,18 @@ defmodule UniversalProxy.ESPHome.ZWaveProxyTest do

describe "frame pipeline (fake UART)" do
test "UART bytes are ACKed locally, forwarded, and update the home ID", %{server: server} do
attach_fake_uart(server)
espex = start_espex_registry!()
attach_fake_uart(server, %{espex_server: espex})
assert {:ok, <<0, 0, 0, 0>>} = ZWaveProxy.subscribe(server, self())

frame = network_ids_response()
send(server, {:circuits_uart, "ttyFake", frame})

# Local ACK reaches the wire before the frame reaches the client.
assert_receive {:uart_write, <<0x06>>}
# Home-ID change is broadcast to every espex connection (we are one).
assert_receive {:espex_zwave_home_id_changed, <<0xDE, 0xAD, 0xBE, 0xEF>>}
# The frame itself still goes only to the subscriber.
assert_receive {:espex_zwave_frame, ^frame}

assert ZWaveProxy.home_id(server) == 0xDEADBEEF
Expand Down Expand Up @@ -197,8 +213,15 @@ defmodule UniversalProxy.ESPHome.ZWaveProxyTest do
end

describe "home-ID lifecycle (fake UART)" do
test "UART error clears the home ID and notifies the subscriber", %{server: server} do
attach_fake_uart(server, %{home_id: <<0xDE, 0xAD, 0xBE, 0xEF>>, home_id_ready: true})
test "UART error clears the home ID and broadcasts the zeroed ID", %{server: server} do
espex = start_espex_registry!()

attach_fake_uart(server, %{
home_id: <<0xDE, 0xAD, 0xBE, 0xEF>>,
home_id_ready: true,
espex_server: espex
})

assert {:ok, <<0xDE, 0xAD, 0xBE, 0xEF>>} = ZWaveProxy.subscribe(server, self())

send(server, {:circuits_uart, "ttyFake", {:error, :eio}})
Expand All @@ -208,6 +231,19 @@ defmodule UniversalProxy.ESPHome.ZWaveProxyTest do
assert ZWaveProxy.home_id(server) == 0
end

test "home-ID broadcast is dropped (not crashed) when espex registry is down",
%{server: server} do
# Default espex_server (Espex.Server) has no registry in the test
# env; the boot-ordering rescue must swallow it and keep the server
# alive with the home ID stored.
attach_fake_uart(server)
frame = network_ids_response()
send(server, {:circuits_uart, "ttyFake", frame})

assert ZWaveProxy.home_id(server) == 0xDEADBEEF
assert Process.alive?(server)
end

test "retry tick re-sends GET_NETWORK_IDS while the home ID is unknown", %{server: server} do
attach_fake_uart(server, %{home_id_ready: false, query_retries: 0})

Expand All @@ -231,6 +267,58 @@ defmodule UniversalProxy.ESPHome.ZWaveProxyTest do
end
end

describe "feature_flags/0" do
test "always advertises the Z-Wave capability, even with no hardware" do
# Cold-hotplug discovery depends on this: HA arms its HOME_ID_CHANGE
# listener only if the flag is set at connect.
assert ZWaveProxy.feature_flags() == 0x01
end
end

describe "open path (injected opener)" do
# Exercises attempt_open/resolve_port/:uart_port_opened without a real
# tty by injecting an opener that hands back a FakeUART. Subscribe to
# the lifecycle topic BEFORE the server starts so the open broadcast
# (fired from handle_continue) isn't missed.
defp start_opening_proxy!(resolver_or_opts) do
{:ok, fake} = FakeUART.start_link(self())
Phoenix.PubSub.subscribe(UniversalProxy.PubSub, "uart:port_opened")

opts =
[name: nil, open_fun: fn _path -> {:ok, fake} end] ++
List.wrap(resolver_or_opts)

server = start_supervised!({ZWaveProxy, opts}, id: {:zw_open, System.unique_integer()})
{server, fake}
end

test "resolver map: broadcasts port_opened with the display name and queries home ID" do
{server, _fake} =
start_opening_proxy!(
resolver: fn -> %{path: "/dev/ttyZW", display_name: "ZWA-2 (1-1.1)"} end
)

assert_receive {:uart_port_opened, %{friendly_name: "ZWA-2 (1-1.1)", owner: :zwave_proxy}}
assert_receive {:uart_write, cmd}
assert cmd == Frame.get_network_ids_command()

assert ZWaveProxy.claimed_port(server) ==
%{tty_name: "ttyZW", display_name: "ZWA-2 (1-1.1)", subscribed: false}
end

test "resolver bare string: display name falls back to the tty basename" do
{_server, _fake} = start_opening_proxy!(resolver: fn -> "/dev/ttyBare" end)

assert_receive {:uart_port_opened, %{friendly_name: "ttyBare", owner: :zwave_proxy}}
end

test "no resolver: opens the static port_path, basename as display name" do
{_server, _fake} = start_opening_proxy!(port_path: "/dev/ttyStatic")

assert_receive {:uart_port_opened, %{friendly_name: "ttyStatic", owner: :zwave_proxy}}
end
end

describe "callbacks tolerate a dead/missing server" do
setup do
{:ok, pid} = ZWaveProxy.start_link(name: nil, port_path: nil)
Expand Down
65 changes: 65 additions & 0 deletions test/universal_proxy/hardware_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -190,6 +190,71 @@ defmodule UniversalProxy.HardwareTest do
assert port.locked == false
end

# A SONOFF Z-Wave dongle shares the CP210x VID/PID with plain serial
# cables; the USB descriptor string is what distinguishes it (HA does
# the same). NOT HW-validated — no SONOFF stick on the testbed.
test "auto-detects a SONOFF Z-Wave dongle (10C4:EA60) by descriptor" do
[port] =
Hardware.list_ports(
slots: nil,
enumerated: %{
"ttyUSB0" => %{
vendor_id: 0x10C4,
product_id: 0xEA60,
manufacturer: "ITEAD",
description: "SONOFF Zwave 800 Dongle Plus",
serial_number: "SZW800"
}
},
bus_paths: %{"ttyUSB0" => "1-1.2"},
saved_configs: %{},
in_use_ports: MapSet.new()
)

assert port.detection == :auto
assert port.kind == :zwave
assert port.locked == true
assert port.name == "SONOFF Zwave 800 Dongle Plus"
end

test "auto-detects a Nortek-style CP210x by '*z-wave*' descriptor" do
[port] =
Hardware.list_ports(
slots: nil,
enumerated: %{
"ttyUSB0" => %{
vendor_id: 0x10C4,
product_id: 0x8A2A,
description: "HubZ Smart Home Controller — Z-Wave",
serial_number: "N1"
}
},
bus_paths: %{"ttyUSB0" => "1-1.2"},
saved_configs: %{},
in_use_ports: MapSet.new()
)

assert port.kind == :zwave
assert port.locked == true
end

test "auto-detects an Aeotec Z-Stick (0658:0200) as locked Z-Wave" do
[port] =
Hardware.list_ports(
slots: nil,
enumerated: %{
"ttyACM0" => %{vendor_id: 0x0658, product_id: 0x0200, serial_number: "AE1"}
},
bus_paths: %{"ttyACM0" => "1-1.2"},
saved_configs: %{},
in_use_ports: MapSet.new()
)

assert port.kind == :zwave
assert port.locked == true
assert port.name == "Aeotec Z-Stick Gen5+"
end

test "auto-detects branded IRdroid IR Toy as locked IR" do
[port] =
Hardware.list_ports(
Expand Down
Loading