neiam
/lambent_ex
Lambent Aether 5
public · Issues · Pulls 1 · Labels · Forks · Compare · Actions success · Badges · Packages
Log in to mark this repository.
Start new devices on their place's machine
95c72a1 · Gabriel Morell · 2026-09-17 01:03
Message
{commit_body(@commit)}
Files changed
modified
config/test.exs
+4
−0
| 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 | 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 | 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 | 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
| 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 | 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