Start new devices on their place's machine

95c72a1 · Gabriel Morell · 2026-09-17 01:03

22 files +558 -1
Message
{commit_body(@commit)}

Files changed

modified config/test.exs
+4 −0
@@ -10,5 +10,9 @@ config :lambent_ex, LambentExWeb.Endpoint,
10 10 # Print only warnings and errors during test
11 11 config :logger, level: :warning
12 12
13 +# places wait for the saved links to come back before adopting anything, and a
14 +# test has no saved links to wait for
15 +config :lambent_ex, :places_settle_ms, 0
16 +
13 17 # Initialize plugs at runtime for faster test compilation
14 18 config :phoenix, :plug_init_mode, :runtime
modified lib/lambent_ex/machine.ex
+10 −0
@@ -148,6 +148,11 @@ defmodule LambentEx.Machine do
148 148 via_tuple("machine-#{id}") |> GenServer.cast(:persist)
149 149 end
150 150
151 + # persist/1 flips; this one says which way, for callers that need it pinned
152 + def set_persist(id, val) when is_boolean(val) do
153 + via_tuple("machine-#{id}") |> GenServer.cast({:set_persist, val})
154 + end
155 +
151 156 def quit(id) do
152 157 via_tuple("machine-#{id}") |> GenServer.cast(:quit)
153 158 end
@@ -473,6 +478,11 @@ defmodule LambentEx.Machine do
473 478 {:noreply, state |> Map.put(:persist, !state.persist)}
474 479 end
475 480
481 + @impl true
482 + def handle_cast({:set_persist, val}, state) do
483 + {:noreply, state |> Map.put(:persist, val)}
484 + end
485 +
476 486 @impl
477 487 def handle_cast(:quit, state) do
478 488 LambentEx.MachineSupervisor.abort_child(self())
modified lib/lambent_ex/meta.ex
+15 −0
@@ -87,6 +87,21 @@ defmodule LambentEx.Meta do
87 87 :dets.insert(@table, {"place-#{key}", value})
88 88 end
89 89
90 + # what a place starts driving a device with, the moment one turns up in it
91 + def get_place_defaults do
92 + case :dets.lookup(@table, "place-defaults") do
93 + [] ->
94 + %{}
95 +
96 + [{_key, value}] ->
97 + value
98 + end
99 + end
100 +
101 + def put_place_defaults(value) do
102 + :dets.insert(@table, {"place-defaults", value})
103 + end
104 +
90 105 # interfaces the sweep has been told to leave alone, beyond the prefixes the
91 106 # scanner filters out on its own
92 107 def get_skipped_ifaces do
added lib/lambent_ex/places.ex
+157 −0
@@ -0,0 +1,157 @@
1 +defmodule LambentEx.Places do
2 + @moduledoc """
3 + What a place does with a device that turns up in it.
4 +
5 + A place can name a machine as its default. A device discovered in that place,
6 + or moved into it, gets linked to that machine without anyone having to go and
7 + build the link — which is the difference between plugging a strand in and
8 + having it light up, and plugging it in and then going to a laptop.
9 +
10 + It never touches a device something is already driving: a link you made
11 + yourself, however it is configured, is the answer to what should be on that
12 + device.
13 + """
14 + use GenServer
15 + require Logger
16 +
17 + alias LambentEx.Meta, as: M
18 +
19 + @pubsub_name LambentEx.PubSub
20 + @pubsub_devices "scan-82667777"
21 + @pubsub_links "links_idx"
22 + @pubsub_places "places"
23 +
24 + # the links saved from last time come back on their own, and until they have
25 + # said so there is no telling what is already driven
26 + @settle 30_000
27 +
28 + @linkable ["7777-8266", "7777-flex"]
29 +
30 + @s "🏠"
31 +
32 + def start_link(arg) do
33 + GenServer.start_link(__MODULE__, arg, name: __MODULE__)
34 + end
35 +
36 + @impl true
37 + def init(_arg) do
38 + Phoenix.PubSub.subscribe(@pubsub_name, @pubsub_devices)
39 + Phoenix.PubSub.subscribe(@pubsub_name, @pubsub_links)
40 +
41 + Process.send_after(self(), :settled, settle())
42 +
43 + {:ok, %{defaults: M.get_place_defaults(), seen: %{}, links: %{}, settled: false}}
44 + end
45 +
46 + defp settle, do: Application.get_env(:lambent_ex, :places_settle_ms, @settle)
47 +
48 + # public
49 +
50 + @doc "Every place that names a machine, as `%{place => machine}`."
51 + def defaults, do: GenServer.call(__MODULE__, :defaults)
52 +
53 + @doc """
54 + Give a place a default, or take it away with nil. The machine is pinned on
55 + the way through: a default pointing at something that will not be here after
56 + a restart is not much of a default.
57 + """
58 + def set_default(place, machine) do
59 + GenServer.cast(__MODULE__, {:set_default, place, machine})
60 + end
61 +
62 + # impls
63 +
64 + @impl true
65 + def handle_call(:defaults, _from, state), do: {:reply, state.defaults, state}
66 +
67 + @impl true
68 + def handle_cast({:set_default, place, nil}, state) do
69 + defaults = Map.delete(state.defaults, place)
70 + {:noreply, state |> put_defaults(defaults)}
71 + end
72 +
73 + @impl true
74 + def handle_cast({:set_default, place, machine}, state) do
75 + LambentEx.Machine.set_persist(machine, true)
76 + defaults = Map.put(state.defaults, place, machine)
77 + Logger.notice("#{@s} #{place} now starts new devices on #{machine}")
78 + {:noreply, state |> put_defaults(defaults)}
79 + end
80 +
81 + defp put_defaults(state, defaults) do
82 + M.put_place_defaults(defaults)
83 + Phoenix.PubSub.broadcast(@pubsub_name, @pubsub_places, {:place_defaults, defaults})
84 + %{state | defaults: defaults}
85 + end
86 +
87 + @impl true
88 + def handle_info(:settled, state), do: {:noreply, %{state | settled: true}}
89 +
90 + @impl true
91 + def handle_info({:links_pub, link}, state) do
92 + {:noreply, %{state | links: Map.put(state.links, link[:name], link)}}
93 + end
94 +
95 + @impl true
96 + def handle_info({:devices_pub, devices}, state) do
97 + {:noreply, Enum.reduce(devices, state, &consider/2)}
98 + end
99 +
100 + @impl true
101 + def handle_info(_msg, state), do: {:noreply, state}
102 +
103 + # a device is worth a look when it is one we have not placed before, or when
104 + # the place it is in has changed since the last time we looked
105 + defp consider({mac, device}, state) do
106 + place = device["place"]
107 +
108 + case Map.fetch(state.seen, mac) do
109 + {:ok, ^place} -> state
110 + _otherwise -> %{adopt(mac, device, place, state) | seen: Map.put(state.seen, mac, place)}
111 + end
112 + end
113 +
114 + defp adopt(_mac, _device, nil, state), do: state
115 +
116 + defp adopt(mac, device, place, state) do
117 + cond do
118 + not state.settled ->
119 + state
120 +
121 + device["type"] not in @linkable ->
122 + state
123 +
124 + driven?(mac, state) ->
125 + state
126 +
127 + true ->
128 + case Map.get(state.defaults, place) do
129 + nil -> state
130 + machine -> link(mac, device, place, machine, state)
131 + end
132 + end
133 + end
134 +
135 + defp driven?(mac, state) do
136 + state.links |> Map.values() |> Enum.any?(fn link -> link[:target] == mac end)
137 + end
138 +
139 + # :all rather than a strand: a device that has just turned up has not been
140 + # told what any of its strands are for yet
141 + defp link(mac, device, place, machine, state) do
142 + name = "#{place}-#{device["name"] || tail(mac)}"
143 +
144 + case LambentEx.LinkSupervisor.start_child(name, machine, mac, :all) do
145 + {:ok, _pid} ->
146 + LambentEx.Link.set_persist(name, true)
147 + Logger.notice("#{@s} #{place} picked up #{name}, running #{machine}")
148 + state
149 +
150 + otherwise ->
151 + Logger.notice("#{@s} #{place} could not link #{mac} to #{machine}: #{inspect(otherwise)}")
152 + state
153 + end
154 + end
155 +
156 + defp tail(mac), do: mac |> String.replace(":", "") |> String.slice(-4, 4)
157 +end
modified lib/lambent_ex/supervision/component.ex
+2 −1
@@ -23,7 +23,8 @@ defmodule LambentEx.ComponentSupervisor do
23 23 {Registry, [keys: :unique, name: :lambent_mqtt]},
24 24 # {LambentEx.Links, []}
25 25 # {LambentEx.Zones, []}
26 − {LambentEx.Cronos, []}
26 + {LambentEx.Cronos, []},
27 + {LambentEx.Places, []}
27 28 ]
28 29
29 30 Supervisor.init(children, strategy: :one_for_one)
modified lib/lambent_ex_web/live/devices/index.html.heex
+3 −0

Click to load diff…

added lib/lambent_ex_web/live/devices/places.ex
+97 −0

Click to load diff…

added lib/lambent_ex_web/live/devices/places.html.heex
+70 −0

Click to load diff…

modified lib/lambent_ex_web/router.ex
+1 −0

Click to load diff…

added test/lambent_ex/places_test.exs
+135 −0

Click to load diff…

added test/lambent_ex_web/live/places_test.exs
+64 −0

Click to load diff…

Parents: 8c68923