Ask the devices what they run, without waiting for the sweep

a1ddc30 · Gabriel Morell · 2026-09-17 01:06

30 files +150 -2
Message
{commit_body(@commit)}

Files changed

modified android/app/src/main/java/org/neiam/lambent/app/data/LambentSocket.kt
+3 −0
@@ -284,6 +284,9 @@ object LambentSocket {
284 284 fun poke(mac: String, mode: String = "auto") = push("devices", "poke", mapOf("id" to mac, "mode" to mode))
285 285 fun probe(ip: String) = push("devices", "probe", mapOf("ip" to ip))
286 286
287 + /** ask every known device what it is running, rather than waiting for the sweep */
288 + fun refreshDevices() = push("devices", "refresh", emptyMap())
289 +
287 290 fun sweepInterface(name: String, enabled: Boolean) =
288 291 push("devices", "interface", mapOf("name" to name, "enabled" to enabled))
289 292
modified android/app/src/main/java/org/neiam/lambent/app/ui/DevicesScreen.kt
+8 −1
@@ -16,6 +16,7 @@ import androidx.compose.material.icons.Icons
16 16 import androidx.compose.material.icons.filled.Bolt
17 17 import androidx.compose.material.icons.filled.ExpandLess
18 18 import androidx.compose.material.icons.filled.ExpandMore
19 +import androidx.compose.material.icons.filled.Refresh
19 20 import androidx.compose.material.icons.filled.Search
20 21 import androidx.compose.material.icons.filled.WbIridescent
21 22 import androidx.compose.material3.AssistChip
@@ -76,6 +77,12 @@ fun DevicesScreen() {
76 77 ) {
77 78 Icon(Icons.Filled.Search, contentDescription = "Probe")
78 79 }
80 + Spacer(Modifier.width(8.dp))
81 + // versions only change when something is reflashed, and the
82 + // sweep is minutes away
83 + FilledTonalIconButton(onClick = { LambentSocket.refreshDevices() }) {
84 + Icon(Icons.Filled.Refresh, contentDescription = "Re-probe everything")
85 + }
79 86 }
80 87 }
81 88
@@ -179,7 +186,7 @@ private fun DeviceCard(device: Device) {
179 186 Text(
180 187 listOfNotNull(
181 188 "${device.type ?: "?"} · ${device.channels}ch",
182 − device.version,
189 + device.version?.let { "v$it" },
183 190 ).joinToString(" · "),
184 191 style = MaterialTheme.typography.labelSmall,
185 192 )
modified lib/lambent_ex/scan/8266-7777.ex
+51 −0
@@ -102,6 +102,16 @@ defmodule LambentEx.Scan.ESP8266x7777 do
102 102 via_tuple("scan_8266") |> GenServer.cast({:manual, ip})
103 103 end
104 104
105 + @doc """
106 + Ask every device we already know about what it is running now.
107 +
108 + The sweep only comes round every few minutes, and a device that has just
109 + been reflashed is the one you most want to see the version of.
110 + """
111 + def refresh() do
112 + via_tuple("scan_8266") |> GenServer.cast(:refresh)
113 + end
114 +
105 115 # impls
106 116
107 117 @impl true
@@ -443,6 +453,47 @@ defmodule LambentEx.Scan.ESP8266x7777 do
443 453 {:reply, :ok, state}
444 454 end
445 455
456 + def handle_cast(:refresh, state) do
457 + targets =
458 + state[:devices]
459 + |> Enum.filter(fn {_mac, device} -> is_tuple(device["ip"]) end)
460 +
461 + Logger.notice("#{@p} Re-probing #{length(targets)} known devices")
462 +
463 + for {mac, device} <- targets do
464 + ip = device["ip"] |> Tuple.to_list() |> Enum.join(".")
465 +
466 + Task.Supervisor.async_nolink(LambentEx.Scan.Connectivity.Supervisor, fn ->
467 + case query_http(ip) do
468 + {:lambent, _ip, cnt, ind, ver} ->
469 + via_tuple("scan_8266") |> GenServer.cast({:refreshed, mac, cnt, ind, ver})
470 +
471 + _otherwise ->
472 + :ok
473 + end
474 + end)
475 + end
476 +
477 + {:noreply, state}
478 + end
479 +
480 + # we already know the mac, so this skips the arp dance a discovery needs
481 + def handle_cast({:refreshed, mac, count, ind, ver}, state) do
482 + case state[:devices] |> Map.get(mac) do
483 + nil ->
484 + {:noreply, state}
485 +
486 + device ->
487 + device =
488 + device
489 + |> Map.put("sox", count)
490 + |> Map.put("ind", ind)
491 + |> Map.put("ver", ver)
492 +
493 + {:noreply, %{state | devices: Map.put(state[:devices], mac, device)}}
494 + end
495 + end
496 +
446 497 def handle_cast({:manual, ip}, state) do
447 498 case resolve_iface(ip, state) do
448 499 nil ->
modified lib/lambent_ex_web/channels/devices_channel.ex
+8 −0
@@ -118,6 +118,14 @@ defmodule LambentExWeb.DevicesChannel do
118 118 {:reply, :ok, socket}
119 119 end
120 120
121 + # ask everything we know about what it is running now, rather than waiting
122 + # for the sweep to come round
123 + @impl true
124 + def handle_in("refresh", _params, socket) do
125 + S.refresh()
126 + {:reply, :ok, socket}
127 + end
128 +
121 129 @impl true
122 130 def handle_in("probe", %{"ip" => ip}, socket) do
123 131 ip = String.trim(ip)
modified lib/lambent_ex_web/live/devices/versions.ex
+11 −0
@@ -15,6 +15,17 @@ defmodule LambentExWeb.DevicesLive.Versions do
15 15 {:noreply, apply_action(socket, socket.assigns.live_action, params)}
16 16 end
17 17
18 + # the sweep is every few minutes, which is a long time to wait to see what a
19 + # device you have just reflashed is running
20 + @impl true
21 + def handle_event("refresh", _params, socket) do
22 + LambentEx.Scan.ESP8266x7777.refresh()
23 +
24 + {:noreply,
25 + socket
26 + |> put_flash(:info, "Asking #{map_size(socket.assigns.devices)} devices what they run...")}
27 + end
28 +
18 29 @impl true
19 30 def handle_info({:devices_pub, devices}, socket) do
20 31 n = Map.merge(socket.assigns.devices, devices)
modified lib/lambent_ex_web/live/devices/versions.html.heex
+5 −1
@@ -3,8 +3,12 @@
3 3 <.link patch={~p"/cfg/devices"} class="btn btn-ghost normal-case">
4 4 <i class="fa-solid fa-chevron-left"></i> All devices
5 5 </.link>
6 + <button class="btn btn-primary normal-case" phx-click="refresh">
7 + <i class="fa-solid fa-arrows-rotate"></i> Re-probe
8 + </button>
6 9 <span class="text-sm opacity-60">
7 − Firmware versions as reported by the 7777-flex discovery endpoint.
10 + Firmware versions as reported by the 7777-flex discovery endpoint. The sweep
11 + picks up a change on its own every few minutes; this asks right now.
8 12 </span>
9 13 </div>
10 14 <table class="table table-zebra w-full">
modified test/lambent_ex_web/channels/channels_test.exs
+10 −0
@@ -51,6 +51,16 @@ defmodule LambentExWeb.ChannelsTest do
51 51 end
52 52 end
53 53
54 + describe "re-probing" do
55 + test "a client can ask every device what it runs" do
56 + socket = connect_to("devices")
57 + assert_push "devices", %{devices: _snapshot}
58 +
59 + ref = push(socket, "refresh", %{})
60 + assert_reply ref, :ok
61 + end
62 + end
63 +
54 64 describe "choosing what gets swept" do
55 65 setup do
56 66 on_exit(fn -> LambentEx.Meta.put_skipped_ifaces([]) end)
added test/lambent_ex_web/live/versions_test.exs
+54 −0
@@ -0,0 +1,54 @@
1 +defmodule LambentExWeb.VersionsTest do
2 + use LambentExWeb.ConnCase
3 +
4 + import Phoenix.LiveViewTest
5 +
6 + @pubsub LambentEx.PubSub
7 +
8 + defp publish(devices) do
9 + Phoenix.PubSub.broadcast(@pubsub, "scan-82667777", {:devices_pub, devices})
10 + end
11 +
12 + test "versions are listed for the devices that report one", %{conn: conn} do
13 + {:ok, live, _html} = live(conn, "/cfg/devices/versions")
14 +
15 + publish(%{
16 + "cc:cc:cc:cc:cc:01" => %{
17 + "ip" => {10, 0, 0, 11},
18 + "sox" => 2,
19 + "type" => "7777-flex",
20 + "ver" => "0.1.0+ci20260916.gb81a582"
21 + },
22 + # a legacy device has no version to report and is not listed
23 + "cc:cc:cc:cc:cc:02" => %{"ip" => {10, 0, 0, 12}, "sox" => 0, "type" => "7777-8266"}
24 + })
25 +
26 + html = render(live)
27 + assert html =~ "0.1.0+ci20260916.gb81a582"
28 + assert html =~ "10.0.0.11"
29 + refute html =~ "cc:cc:cc:cc:cc:02"
30 + end
31 +
32 + test "a flex device with no version yet shows as unknown", %{conn: conn} do
33 + {:ok, live, _html} = live(conn, "/cfg/devices/versions")
34 +
35 + publish(%{
36 + "cc:cc:cc:cc:cc:03" => %{"ip" => {10, 0, 0, 13}, "sox" => 2, "type" => "7777-flex"}
37 + })
38 +
39 + assert render(live) =~ "unknown"
40 + end
41 +
42 + test "the page can ask every device what it runs", %{conn: conn} do
43 + {:ok, live, html} = live(conn, "/cfg/devices/versions")
44 +
45 + assert html =~ "Re-probe"
46 +
47 + publish(%{
48 + "cc:cc:cc:cc:cc:04" => %{"ip" => {10, 0, 0, 14}, "sox" => 2, "type" => "7777-flex"}
49 + })
50 +
51 + html = live |> element(~s(button[phx-click="refresh"])) |> render_click()
52 + assert html =~ "what they run"
53 + end
54 +end

Parents: 95c72a1