Skip to content

Commit cfb577e

Browse files
authored
Merge pull request #140 from bbangert/feat/espex-0.8-lazy-open
Bump espex to 0.8 + persist serial-proxy line settings
2 parents 6405d7d + 908525d commit cfb577e

9 files changed

Lines changed: 530 additions & 69 deletions

File tree

lib/universal_proxy/application.ex

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,16 @@ defmodule UniversalProxy.Application do
4444
# top level so a restart/0 of the ESPHome subtree never closes its
4545
# DETS file. Holds the HA-provisioned API encryption key.
4646
UniversalProxy.ESPHome.PskStore,
47+
# UART per-port line-settings store (DETS). Sits beside
48+
# ConfigStore/PskStore at the top level so a restart/0 of the
49+
# ESPHome subtree never closes its DETS file, since the writer
50+
# (the ESPHome serial-proxy adapter) lives there. Remembers the
51+
# last settings each port was successfully opened with, served
52+
# back through the serial-proxy adapter's default_open_opts/1 so a
53+
# client resuming after a restart without CONFIGURE gets the
54+
# right baud rate instead of the espex 9600-8-N-1 fallback, and
55+
# through Hardware.list_ports/0 for the Overview drawer display.
56+
UniversalProxy.UART.SettingsStore,
4757
# Firmware update flow (ConfigStore + library Supervisor, wired
4858
# together so the snapshot lands before the library starts).
4959
UniversalProxy.FirmwareUpdate,

lib/universal_proxy/esphome/serial_proxy.ex

Lines changed: 68 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,25 @@ defmodule UniversalProxy.ESPHome.SerialProxy do
2020
`SERIAL_PROXY_REQUEST_TYPE_SUBSCRIBE`. `UNSUBSCRIBE` halts forwarding
2121
again. Both are handled through `c:Espex.SerialProxy.request/2` and
2222
routed to the per-instance `Relay`.
23+
24+
espex 0.8 tracks subscribe *intent* per connection rather than a
25+
one-shot stash: the client's first operation of any kind (write,
26+
subscribe, modem pins, flush) against an advertised-but-unopened
27+
instance lazily opens it via `open/3`, and a previously-set subscribe
28+
intent is reattached (another `request/2` call) after *every*
29+
successful open — whether that open was triggered by CONFIGURE or by
30+
the lazy path. This lets a client resume traffic (e.g. Home Assistant
31+
writing to a Zigbee coordinator, or re-subscribing) after a proxy
32+
restart without re-sending CONFIGURE first.
33+
34+
## Persisted line settings
35+
36+
A lazily-opened instance has no CONFIGURE to draw options from, so
37+
`default_open_opts/1` below serves the settings the port was last
38+
successfully opened with (persisted in `SettingsStore`) instead of
39+
espex's 9600-8-N-1 fallback. Real ESPHome hardware retains its UART
40+
settings across a client reconnect, so this keeps a resumed connection
41+
talking at the baud rate the attached device actually expects.
2342
"""
2443

2544
@behaviour Espex.SerialProxy
@@ -30,6 +49,7 @@ defmodule UniversalProxy.ESPHome.SerialProxy do
3049
alias UniversalProxy.ESPHome.SerialProxy.Relay
3150
alias UniversalProxy.Hardware
3251
alias UniversalProxy.UART
52+
alias UniversalProxy.UART.SettingsStore
3353

3454
@impl true
3555
def list_instances do
@@ -43,13 +63,15 @@ defmodule UniversalProxy.ESPHome.SerialProxy do
4363
@impl true
4464
def open(instance, opts, subscriber) do
4565
case Enum.at(inventory(), instance) do
46-
%{path: path, friendly_name: friendly_name} ->
66+
%{id: id, path: path, friendly_name: friendly_name} ->
4767
with {:ok, _pid} <- UART.open(path, Keyword.put(opts, :friendly_name, friendly_name)),
4868
{:ok, relay} <- start_relay_or_close(path, friendly_name, subscriber) do
4969
Logger.info(
5070
"ESPHome serial proxy opened instance #{instance} (#{friendly_name} @ #{path}, #{opts[:speed]} baud)"
5171
)
5272

73+
persist_opts(id, opts)
74+
5375
{:ok, {relay, path}}
5476
else
5577
{:error, reason} = err ->
@@ -65,6 +87,20 @@ defmodule UniversalProxy.ESPHome.SerialProxy do
6587
end
6688
end
6789

90+
@impl true
91+
def default_open_opts(instance) do
92+
# espex 0.8 calls this when a client operates on an advertised
93+
# instance without a prior CONFIGURE on this connection (e.g. HA
94+
# resuming after a proxy restart). Serve the settings the port was
95+
# last successfully opened with; fall back to espex's 9600-8-N-1.
96+
with %{id: id} <- Enum.at(inventory(), instance),
97+
opts when is_list(opts) <- get_stored_opts(id) do
98+
opts
99+
else
100+
_ -> Espex.SerialProxy.default_open_opts()
101+
end
102+
end
103+
68104
# If the UART port opened but the relay can't start, the port would stay
69105
# registered against this connection forever — release it before bubbling
70106
# the error.
@@ -99,9 +135,11 @@ defmodule UniversalProxy.ESPHome.SerialProxy do
99135
end
100136
end
101137

102-
# Espex.Connection calls set_modem_pins/3 and get_modem_pins/1 unguarded
103-
# (no `function_exported?` check), so we provide stubs even though both
104-
# are declared `@optional_callbacks` in `Espex.SerialProxy`.
138+
# espex 0.8 guards both set_modem_pins/3 and get_modem_pins/1 with
139+
# function_exported? and would otherwise fall back to its own
140+
# omitted-callback default (all lines low / no-op). We keep these stubs
141+
# anyway to answer NOT_SUPPORTED explicitly rather than relying on that
142+
# fallback.
105143
@impl true
106144
def set_modem_pins(_handle, _rts, _dtr), do: {:error, :not_supported}
107145

@@ -143,11 +181,37 @@ defmodule UniversalProxy.ESPHome.SerialProxy do
143181
end)
144182
|> Enum.map(fn port ->
145183
%{
184+
id: port.id,
146185
path: port.tty_name,
147186
friendly_name: port.ha_name,
148187
port_type: port.kind
149188
}
150189
end)
151190
|> Enum.sort_by(& &1.friendly_name)
152191
end
192+
193+
# Best-effort: a `SettingsStore` hiccup (down, wedged, or DETS write
194+
# failure) must not fail an otherwise-good open. Follows this project's
195+
# public-API `catch :exit` idiom (CLAUDE.md) — a wedged store degrades
196+
# to "settings not persisted" rather than failing the connection.
197+
defp persist_opts(id, opts) do
198+
case SettingsStore.put_opts(id, opts) do
199+
:ok ->
200+
:ok
201+
202+
{:error, reason} ->
203+
Logger.warning("ESPHome serial settings store write failed for #{id}: #{inspect(reason)}")
204+
end
205+
catch
206+
:exit, reason ->
207+
Logger.warning("ESPHome serial settings store unavailable for #{id}: #{inspect(reason)}")
208+
end
209+
210+
# Same idiom as persist_opts/2: a down/wedged store must degrade to the
211+
# espex default, not crash the espex connection process.
212+
defp get_stored_opts(id) do
213+
SettingsStore.get_opts(id)
214+
catch
215+
:exit, _ -> nil
216+
end
153217
end

lib/universal_proxy/hardware.ex

Lines changed: 38 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -175,6 +175,9 @@ defmodule UniversalProxy.Hardware do
175175
* `:slots` — explicit list of slot bus paths (overrides the
176176
target-based mapping; pass `nil` for dynamic mode)
177177
* `:saved_configs` — pre-built `%{serial_number => config}` map
178+
* `:line_settings` — pre-built `%{port_id => keyword()}` map of
179+
persisted UART line settings (see
180+
`UniversalProxy.UART.SettingsStore.all_opts/1`)
178181
* `:in_use_ports` — pre-built `MapSet` of currently-opened tty
179182
basenames
180183
* `:zwave_claim` — the Z-Wave proxy's port claim
@@ -197,6 +200,8 @@ defmodule UniversalProxy.Hardware do
197200
end)
198201
end)
199202

203+
line_settings = Keyword.get_lazy(opts, :line_settings, &safe_line_settings/0)
204+
200205
usage = %{
201206
in_use_ports:
202207
Keyword.get_lazy(opts, :in_use_ports, fn ->
@@ -211,11 +216,19 @@ defmodule UniversalProxy.Hardware do
211216
slots = Keyword.get(opts, :slots, target_slots())
212217

213218
case slots do
214-
nil -> dynamic_listing(enumerated, bus_paths, saved, usage)
215-
slots -> slot_listing(slots, enumerated, bus_paths, saved, usage)
219+
nil -> dynamic_listing(enumerated, bus_paths, saved, usage, line_settings)
220+
slots -> slot_listing(slots, enumerated, bus_paths, saved, usage, line_settings)
216221
end
217222
end
218223

224+
# A wedged/absent SettingsStore must degrade to "no settings shown", not
225+
# take down the caller (public-API catch :exit idiom, CLAUDE.md).
226+
defp safe_line_settings do
227+
UART.SettingsStore.all_opts()
228+
catch
229+
:exit, _ -> %{}
230+
end
231+
219232
@doc """
220233
Map of `{slot_sub, vendor_id, product_id} => tty_basename` for every
221234
currently-connected USB serial adapter. ESPHome consumers use this to
@@ -357,7 +370,7 @@ defmodule UniversalProxy.Hardware do
357370

358371
# -- Slot mode (per-target, every port shows) ------------------------
359372

360-
defp slot_listing(slots, enumerated, bus_paths, saved, usage) do
373+
defp slot_listing(slots, enumerated, bus_paths, saved, usage, line_settings) do
361374
tty_by_bus_path = Map.new(bus_paths, fn {tty, bus} -> {bus, tty} end)
362375
known_slots = MapSet.new(slots)
363376

@@ -371,7 +384,7 @@ defmodule UniversalProxy.Hardware do
371384

372385
tty_name ->
373386
info = Map.get(enumerated, tty_name, %{})
374-
build_port(tty_name, info, slot_sub, saved, usage, idx)
387+
build_port(tty_name, info, slot_sub, saved, usage, line_settings, idx)
375388
end
376389
end)
377390

@@ -384,23 +397,23 @@ defmodule UniversalProxy.Hardware do
384397
|> Enum.with_index(length(slots) + 1)
385398
|> Enum.map(fn {{tty_name, bus_path}, idx} ->
386399
info = Map.get(enumerated, tty_name, %{})
387-
build_port(tty_name, info, bus_path, saved, usage, idx)
400+
build_port(tty_name, info, bus_path, saved, usage, line_settings, idx)
388401
end)
389402

390403
declared ++ bonus
391404
end
392405

393406
# -- Dynamic mode (host / unknown target) ----------------------------
394407

395-
defp dynamic_listing(enumerated, bus_paths, saved, usage) do
408+
defp dynamic_listing(enumerated, bus_paths, saved, usage, line_settings) do
396409
enumerated
397410
|> Enum.filter(fn {name, _} -> usb_serial?(name) end)
398411
|> Enum.map(fn {name, info} -> {name, info, Map.get(bus_paths, name)} end)
399412
|> Enum.sort_by(fn {name, _info, bus_path} -> {bus_path || "~", name} end)
400413
|> Enum.with_index(1)
401414
|> Enum.map(fn {{name, info, bus_path}, idx} ->
402415
slot_sub = bus_path || name
403-
build_port(name, info, slot_sub, saved, usage, idx)
416+
build_port(name, info, slot_sub, saved, usage, line_settings, idx)
404417
end)
405418
end
406419

@@ -458,7 +471,7 @@ defmodule UniversalProxy.Hardware do
458471

459472
# -- Port shape ------------------------------------------------------
460473

461-
defp build_port(tty_name, info, slot_sub, saved, usage, idx) do
474+
defp build_port(tty_name, info, slot_sub, saved, usage, line_settings, idx) do
462475
vid = info[:vendor_id]
463476
pid = info[:product_id]
464477
serial = info[:serial_number]
@@ -470,6 +483,7 @@ defmodule UniversalProxy.Hardware do
470483
name = pick_name(saved_cfg, device_info, info)
471484
vendor = pick_vendor(device_info, info)
472485
chip = pick_chip(device_info, info)
486+
line = Map.get(line_settings, port_id(slot_sub))
473487

474488
%{
475489
id: port_id(slot_sub),
@@ -494,12 +508,27 @@ defmodule UniversalProxy.Hardware do
494508
locked: classification.locked,
495509
user: user,
496510
user_href: nil,
497-
baud: nil,
498511
throughput: %{in: 0, out: 0, unit: "B/s"},
499512
errors: 0,
500513
since: nil,
501514
notes: classification.notes
502515
}
516+
|> Map.merge(line_setting_fields(line))
517+
end
518+
519+
# No persisted entry (never opened, or store unavailable) → `baud: nil`,
520+
# which `fact_rows/1` in the Overview LiveView treats as "hide the Serial
521+
# settings row". `parity` MUST be a string, never an atom — the LiveView
522+
# calls `String.first/1` on it.
523+
defp line_setting_fields(nil), do: %{baud: nil, data_bits: nil, stop_bits: nil, parity: nil}
524+
525+
defp line_setting_fields(opts) do
526+
%{
527+
baud: opts[:speed],
528+
data_bits: opts[:data_bits],
529+
stop_bits: opts[:stop_bits],
530+
parity: to_string(opts[:parity] || :none)
531+
}
503532
end
504533

505534
# Who holds this tty open, if anyone. The Z-Wave proxy owns its port

0 commit comments

Comments
 (0)