Let a place pick up what is already sitting in it

65d680a · Gabriel Morell · 2026-09-17 01:12

14 files +155 -6
Message
{commit_body(@commit)}

Files changed

modified lib/lambent_ex/places.ex
+40 −1
@@ -40,7 +40,14 @@ defmodule LambentEx.Places do
40 40
41 41 Process.send_after(self(), :settled, settle())
42 42
43 − {:ok, %{defaults: M.get_place_defaults(), seen: %{}, links: %{}, settled: false}}
43 + {:ok,
44 + %{
45 + defaults: M.get_place_defaults(),
46 + seen: %{},
47 + devices: %{},
48 + links: %{},
49 + settled: false
50 + }}
44 51 end
45 52
46 53 defp settle, do: Application.get_env(:lambent_ex, :places_settle_ms, @settle)
@@ -59,6 +66,17 @@ defmodule LambentEx.Places do
59 66 GenServer.cast(__MODULE__, {:set_default, place, machine})
60 67 end
61 68
69 + @doc """
70 + Link everything in a place that nothing is driving yet.
71 +
72 + Adoption only happens as devices turn up or move, which is no help for the
73 + ones that were already sitting there when the place got its default — the
74 + first time you set one up, that is most of them.
75 + """
76 + def adopt_place(place) do
77 + GenServer.cast(__MODULE__, {:adopt_place, place})
78 + end
79 +
62 80 # impls
63 81
64 82 @impl true
@@ -78,6 +96,26 @@ defmodule LambentEx.Places do
78 96 {:noreply, state |> put_defaults(defaults)}
79 97 end
80 98
99 + @impl true
100 + def handle_cast({:adopt_place, place}, state) do
101 + case Map.get(state.defaults, place) do
102 + nil ->
103 + {:noreply, state}
104 +
105 + machine ->
106 + state =
107 + state.devices
108 + |> Enum.filter(fn {mac, device} ->
109 + device["place"] == place and device["type"] in @linkable and not driven?(mac, state)
110 + end)
111 + |> Enum.reduce(state, fn {mac, device}, state ->
112 + link(mac, device, place, machine, state)
113 + end)
114 +
115 + {:noreply, state}
116 + end
117 + end
118 +
81 119 defp put_defaults(state, defaults) do
82 120 M.put_place_defaults(defaults)
83 121 Phoenix.PubSub.broadcast(@pubsub_name, @pubsub_places, {:place_defaults, defaults})
@@ -94,6 +132,7 @@ defmodule LambentEx.Places do
94 132
95 133 @impl true
96 134 def handle_info({:devices_pub, devices}, state) do
135 + state = %{state | devices: Map.merge(state.devices, devices)}
97 136 {:noreply, Enum.reduce(devices, state, &consider/2)}
98 137 end
99 138
modified lib/lambent_ex_web/live/devices/places.ex
+10 −0
@@ -63,6 +63,16 @@ defmodule LambentExWeb.DevicesLive.Places do
63 63 {:noreply, socket}
64 64 end
65 65
66 + # adoption happens as devices turn up, which is no help for the ones already
67 + # sitting in a place when it gets its default
68 + @impl true
69 + def handle_event("adopt", %{"place" => place}, socket) do
70 + waiting = waiting(socket.assigns.devices, socket.assigns.links, place)
71 + Places.adopt_place(place)
72 +
73 + {:noreply, socket |> put_flash(:info, "Linking #{waiting} device(s) in #{place}...")}
74 + end
75 +
66 76 defp blank_to_nil(""), do: nil
67 77 defp blank_to_nil(machine), do: machine
68 78
modified lib/lambent_ex_web/live/devices/places.html.heex
+13 −5
@@ -57,11 +57,19 @@
57 57 </form>
58 58 </td>
59 59 <td>
60 − <%= if @defaults[place] do %>
61 − <span class="text-sm opacity-60">
62 − <i class="fa-solid fa-thumbtack fa-fw"></i> pinned so it comes back
63 − </span>
64 − <% end %>
60 + <div class="flex items-center gap-3">
61 + <%= if @defaults[place] && waiting(@devices, @links, place) > 0 do %>
62 + <button class="btn btn-sm btn-primary" phx-click="adopt" phx-value-place={place}>
63 + <i class="fa-solid fa-link"></i>
64 + Link <%= waiting(@devices, @links, place) %> now
65 + </button>
66 + <% end %>
67 + <%= if @defaults[place] do %>
68 + <span class="text-sm opacity-60">
69 + <i class="fa-solid fa-thumbtack fa-fw"></i> pinned so it comes back
70 + </span>
71 + <% end %>
72 + </div>
65 73 </td>
66 74 </tr>
67 75 <% end %>
modified test/lambent_ex/places_test.exs
+69 −0
@@ -113,6 +113,75 @@ defmodule LambentEx.PlacesTest do
113 113 drop_link("test-hall3-mover")
114 114 end
115 115
116 + test "a place can pick up everything already sitting in it" do
117 + {:ok, _pid} =
118 + LambentEx.Machine.start_link(nil,
119 + name: "places-machine-4",
120 + step: LambentEx.Machine.Steps.Solid,
121 + step_opts: %{h: 84},
122 + count: 1,
123 + single: true
124 + )
125 +
126 + # two devices seen before the place had a default, so nothing adopted them
127 + publish(
128 + Map.merge(
129 + device("aa:bb:cc:dd:ee:05", "test-hall5", "early-one"),
130 + device("aa:bb:cc:dd:ee:06", "test-hall5", "early-two")
131 + )
132 + )
133 +
134 + refute "link-test-hall5-early-one" in link_names()
135 +
136 + Places.set_default("test-hall5", "places-machine-4")
137 + Places.adopt_place("test-hall5")
138 + Places.defaults()
139 +
140 + assert "link-test-hall5-early-one" in link_names()
141 + assert "link-test-hall5-early-two" in link_names()
142 +
143 + drop_link("test-hall5-early-one")
144 + drop_link("test-hall5-early-two")
145 + end
146 +
147 + test "picking up a place twice does not double up" do
148 + {:ok, _pid} =
149 + LambentEx.Machine.start_link(nil,
150 + name: "places-machine-5",
151 + step: LambentEx.Machine.Steps.Solid,
152 + step_opts: %{h: 84},
153 + count: 1,
154 + single: true
155 + )
156 +
157 + publish(device("aa:bb:cc:dd:ee:07", "test-hall6", "only-one"))
158 + Places.set_default("test-hall6", "places-machine-5")
159 + Places.adopt_place("test-hall6")
160 + Places.defaults()
161 +
162 + assert "link-test-hall6-only-one" in link_names()
163 +
164 + # the link publishes, so the second pass knows it is driven
165 + Phoenix.PubSub.broadcast(
166 + @pubsub,
167 + "links_idx",
168 + {:links_pub,
169 + %{
170 + name: "test-hall6-only-one",
171 + target: "aa:bb:cc:dd:ee:07",
172 + source: "places-machine-5",
173 + enabled: true
174 + }}
175 + )
176 +
177 + Places.adopt_place("test-hall6")
178 + Places.defaults()
179 +
180 + assert link_names() |> Enum.count(&(&1 == "link-test-hall6-only-one")) == 1
181 +
182 + drop_link("test-hall6-only-one")
183 + end
184 +
116 185 test "a default is remembered, and pins the machine it names" do
117 186 {:ok, pid} =
118 187 LambentEx.Machine.start_link(nil,
modified test/lambent_ex_web/live/places_test.exs
+23 −0
@@ -52,6 +52,29 @@ defmodule LambentExWeb.PlacesTest do
52 52 assert render(live) =~ "pinned so it comes back"
53 53 end
54 54
55 + test "the page offers to link what is sitting there unlinked", %{conn: conn} do
56 + {:ok, live, _html} = live(conn, "/cfg/devices/places")
57 +
58 + Phoenix.PubSub.broadcast(
59 + @pubsub,
60 + "scan-82667777",
61 + {:devices_pub,
62 + %{"bb:bb:bb:bb:bb:02" => %{"place" => "page-hall", "type" => "7777-flex", "sox" => 2}}}
63 + )
64 +
65 + # nothing to offer until the place has a default
66 + refute render(live) =~ "Link 1 now"
67 +
68 + Places.set_default("page-hall", "page-machine")
69 + Places.defaults()
70 +
71 + html = render(live)
72 + assert html =~ "Link 1 now"
73 +
74 + html = live |> element(~s(button[phx-click="adopt"])) |> render_click()
75 + assert html =~ "Linking 1 device(s) in page-hall"
76 + end
77 +
55 78 test "a default pointing at something not running says so", %{conn: conn} do
56 79 Places.set_default("page-hall", "long-gone")
57 80 Places.defaults()

Parents: a1ddc30