fix the 218 issue, multi channel linking, a better thing

44aeeea · Gabriel Morell · 2026-08-17 21:21

25 files +585 -142

Files changed

modified lib/lambent_ex/link.ex
+37 −7
@@ -15,7 +15,8 @@ defmodule LambentEx.Link do
15 15
16 16 def init(opts) do
17 17 Process.send_after(self(), :publish, 50)
18 − {:ok, opts} = Keyword.validate(opts, [:source, :target, :name, :persist])
18 + # links saved before channels existed drove 7777 only, which is chan 0
19 + {:ok, opts} = Keyword.validate(opts, [:source, :target, :name, :persist, chan: 0])
19 20 Phoenix.PubSub.subscribe(@pubsub_name, @pubsub_topic <> opts[:source])
20 21 Phoenix.PubSub.subscribe(@pubsub_name, @pubsub_pfx_on <> opts[:target])
21 22
@@ -23,6 +24,7 @@ defmodule LambentEx.Link do
23 24 source: opts[:source],
24 25 target: opts[:target],
25 26 name: opts[:name],
27 + chan: opts[:chan],
26 28 enabled: true,
27 29 started: DateTime.utc_now(),
28 30 persist: opts[:persist] || false,
@@ -62,6 +64,7 @@ defmodule LambentEx.Link do
62 64 name: state[:name],
63 65 source: state[:source],
64 66 target: state[:target],
67 + chan: state[:chan],
65 68 enabled: state[:enabled],
66 69 persist: state[:persist],
67 70 opts: state[:full_opts] |> Keyword.update(:persist, true, fn _ -> state[:persist] end)
@@ -72,15 +75,29 @@ defmodule LambentEx.Link do
72 75 Phoenix.PubSub.broadcast(
73 76 @pubsub_name,
74 77 @pubsub_pfx_on <> state[:target],
75 − {:neighbor_on, state[:name]}
78 + {:neighbor_on, state[:name], state[:chan]}
76 79 )
77 80 end
78 81
82 + # two links on the same device only fight over the strands they share, so a
83 + # chan 0 and a chan 1 link can both run. :all covers every strand and so
84 + # collides with anything, including another :all.
85 + defp overlaps?(:all, _theirs), do: true
86 + defp overlaps?(_mine, :all), do: true
87 + defp overlaps?(mine, theirs), do: mine == theirs
88 +
79 89 @impl true
80 90 def handle_info({:publish, data}, state) do
81 91 case state[:enabled] do
82 − true -> LambentEx.Scan.ESP8266x7777.submit(state[:target], data |> List.flatten())
83 − false -> :ok
92 + true ->
93 + LambentEx.Scan.ESP8266x7777.submit(
94 + state[:target],
95 + data |> List.flatten(),
96 + state[:chan]
97 + )
98 +
99 + false ->
100 + :ok
84 101 end
85 102
86 103 {:noreply, state}
@@ -94,13 +111,26 @@ defmodule LambentEx.Link do
94 111 end
95 112
96 113 @impl true
97 − def handle_info({:neighbor_on, id}, state) do
98 − case state[:name] == id do
114 + def handle_info({:neighbor_on, id, chan}, state) do
115 + cond do
116 + state[:name] == id -> {:noreply, state}
117 + overlaps?(state[:chan], chan) -> {:noreply, %{state | enabled: false}}
99 118 true -> {:noreply, state}
100 − false -> {:noreply, %{state | enabled: false}}
101 119 end
102 120 end
103 121
122 + # a link still running old code across a reload announces itself without a
123 + # channel, and back then that meant the whole device
124 + @impl true
125 + def handle_info({:neighbor_on, id}, state) do
126 + handle_info({:neighbor_on, id, :all}, state)
127 + end
128 +
129 + @impl true
130 + def handle_info(_msg, state) do
131 + {:noreply, state}
132 + end
133 +
104 134 @impl true
105 135 def handle_cast(:do_toggle, state) do
106 136 case state[:enabled] do
modified lib/lambent_ex/scan/8266-7777.ex
+247 −80
@@ -4,6 +4,7 @@ defmodule LambentEx.Scan.ESP8266x7777 do
4 4 require Logger
5 5
6 6 import IP
7 + require IP.Subnet
7 8
8 9 alias LambentEx.Utils.Color, as: C
9 10 alias LambentEx.Meta, as: M
@@ -13,6 +14,7 @@ defmodule LambentEx.Scan.ESP8266x7777 do
13 14 @p "ꙭ👉"
14 15 @filtered_prefixes ["br", "veth", "docker", "lo", "cni", "flannel", "tun", "tailscale", "wg"]
15 16 @pubsub_name LambentEx.PubSub
17 + @pubsub_manual "scan-manual"
16 18
17 19 def start_link(opts) do
18 20 GenServer.start_link(__MODULE__, opts, name: via_tuple("scan_8266"))
@@ -61,13 +63,31 @@ defmodule LambentEx.Scan.ESP8266x7777 do
61 63 end
62 64
63 65 def submit(dvc, stream) do
64 − via_tuple("scan_8266") |> GenServer.cast({:submit, dvc, stream})
66 + submit(dvc, stream, 0)
65 67 end
66 68
69 + # chan is either a strand index or :all, which mirrors the stream onto every
70 + # strand the device has
71 + def submit(dvc, stream, chan) do
72 + via_tuple("scan_8266") |> GenServer.cast({:submit, dvc, stream, chan})
73 + end
74 +
75 + # how many udp strands a device exposes. legacy 8266s report no sox at all and
76 + # anything that is not a 7777 device (tasmota, say) only ever has the one.
77 + def channels(%{"sox" => sox}) when is_integer(sox), do: max(sox, 1)
78 + def channels(_device), do: 1
79 +
67 80 def poke(dvc) do
68 81 via_tuple("scan_8266") |> GenServer.cast({:poke, dvc})
69 82 end
70 83
84 + # :auto uses the onboard indicator LED when the device advertises one, and the
85 + # strands otherwise. :strands is the old whole-strand poke, kept around for
86 + # indicator devices where you want to see the actual lights move.
87 + def poke(dvc, mode) when mode in [:auto, :indicator, :strands] do
88 + via_tuple("scan_8266") |> GenServer.cast({:poke, dvc, mode})
89 + end
90 +
71 91 def devices() do
72 92 via_tuple("scan_8266") |> GenServer.call(:get_devices)
73 93 end
@@ -122,64 +142,116 @@ defmodule LambentEx.Scan.ESP8266x7777 do
122 142 end
123 143
124 144 @impl true
125 − def handle_cast({:submit, dvc, stream}, state) do
145 + def handle_cast({:submit, dvc, stream, chan}, state) do
126 146 case state[:devices] |> Map.get(dvc) do
127 147 nil ->
128 148 {:noreply, state}
129 149
130 150 device ->
131 − :gen_udp.send(state.socket, device["ip"], 7777, rgb_shift(stream, device) |> rgb_stream)
151 + payload = rgb_shift(stream, device) |> rgb_stream
152 +
153 + for port <- submit_ports(device, chan) do
154 + :gen_udp.send(state.socket, device["ip"], port, payload)
155 + end
156 +
132 157 {:noreply, state}
133 158 end
134 159 end
135 160
161 + defp submit_ports(device, :all) do
162 + 0..(channels(device) - 1) |> Enum.map(fn idx -> 7777 + idx end)
163 + end
164 +
165 + defp submit_ports(device, chan) when is_integer(chan) do
166 + # a link outliving a reflash can outrun the strands the device now has
167 + case chan < channels(device) do
168 + true -> [7777 + chan]
169 + false -> []
170 + end
171 + end
172 +
136 173 @impl true
137 174 def handle_cast({:poke, dvc}, state) do
175 + handle_cast({:poke, dvc, :auto}, state)
176 + end
177 +
178 + @impl true
179 + def handle_cast({:poke, dvc, mode}, state) do
138 180 case state[:devices] |> Map.get(dvc) do
139 181 nil ->
140 182 {:noreply, state}
141 183
142 184 device ->
143 − Logger.notice("#{@p} Poking #{dvc}}")
144 −
145 − for {i, c} <- [
146 − {10, C.cre()},
147 − {100, C.cbe()},
148 − {200, C.cge()},
149 − {300, C.cce()},
150 − {400, C.cye()},
151 − {500, C.cme()},
152 − {600, C.cke()}
153 − ] do
154 − if device["sox"] == 1 do
155 − Process.send_after(self(), {:send, device, c}, i)
156 − else
157 − 0..device["sox"]
158 − |> Enum.map(fn idx ->
159 − Process.send_after(self(), {:send, device, c, idx}, i)
160 − end)
185 + target = poke_target(mode, device)
186 + Logger.notice("#{@p} Poking #{dvc} (#{target})")
187 +
188 + for {i, c} <- poke_steps(target) do
189 + case target do
190 + :indicator ->
191 + Process.send_after(self(), {:send_ind, device, c}, i)
192 +
193 + :strands ->
194 + if device["sox"] == 1 do
195 + Process.send_after(self(), {:send, device, c}, i)
196 + else
197 + # ranges are inclusive, and the device only binds sox ports
198 + 0..(max(device["sox"], 1) - 1)
199 + |> Enum.map(fn idx ->
200 + Process.send_after(self(), {:send, device, c, idx}, i)
201 + end)
202 + end
161 203 end
162 204 end
163 205
164 206 {:noreply, state}
165 207 end
166 −
167 − {:noreply, state}
168 208 end
169 209
170 − def handle_cast({:scan_results, iface, ip, count}, state) do
171 − IO.inspect({"SR", iface, ip, count})
210 + def handle_cast({:scan_results, iface, ip, count, ind}, state) do
211 + IO.inspect({"SR", iface, ip, count, ind})
172 212 if_fmt = Kernel.to_string(iface)
173 − ip_fmt = ip |> String.split(".") |> Enum.map(&String.to_integer/1) |> List.to_tuple
174 − instances = %{if_fmt => [{ip, count}]}
175 − arp = state[:arps] |> Map.get(iface, %{}) |> Map.get(ip_fmt)
176 − state = case arp do
177 − nil -> %{state | to_arp: List.insert_at(state.to_arp, 0, {iface, ip, count})}
178 − val -> join_new_instances_old(instances, state)
179 − end
213 + ip_fmt = ip |> String.split(".") |> Enum.map(&String.to_integer/1) |> List.to_tuple()
214 + instances = %{if_fmt => [{ip, count, ind}]}
215 + arp = state[:arps] |> Map.get(if_fmt, %{}) |> Map.get(ip_fmt)
216 +
217 + state =
218 + case arp do
219 + nil -> %{state | to_arp: List.insert_at(state.to_arp, 0, {iface, ip, count, ind})}
220 + _val -> join_new_instances_old(instances, state)
221 + end
222 +
180 223 {:noreply, state}
181 224 end
182 225
226 + # a device without an indicator has nothing to blink but its strands
227 + defp poke_target(:strands, _device), do: :strands
228 + defp poke_target(_mode, %{"ind" => port}) when is_integer(port), do: :indicator
229 + defp poke_target(_mode, _device), do: :strands
230 +
231 + defp poke_steps(:strands) do
232 + [
233 + {10, C.cre()},
234 + {100, C.cbe()},
235 + {200, C.cge()},
236 + {300, C.cce()},
237 + {400, C.cye()},
238 + {500, C.cme()},
239 + {600, C.cke()}
240 + ]
241 + end
242 +
243 + defp poke_steps(:indicator) do
244 + [
245 + {10, C.cr()},
246 + {100, C.cb()},
247 + {200, C.cg()},
248 + {300, C.cc()},
249 + {400, C.cy()},
250 + {500, C.cm()},
251 + {600, C.ck()}
252 + ]
253 + end
254 +
183 255 @impl true
184 256 def handle_call(:get_devices, _from, state) do
185 257 {:reply, state[:devices], state}
@@ -277,8 +349,8 @@ defmodule LambentEx.Scan.ESP8266x7777 do
277 349 case resp.status_code do
278 350 404 ->
279 351 case resp.body do
280 − "File not found." -> {:lambent, ip, 0}
281 − _other -> {:not_lambent, ip, -1}
352 + "File not found." -> {:lambent, ip, 0, nil}
353 + _other -> {:not_lambent, ip, -1, nil}
282 354 end
283 355
284 356 # esp32rs
@@ -286,17 +358,26 @@ defmodule LambentEx.Scan.ESP8266x7777 do
286 358 IO.inspect({"218@", ip})
287 359
288 360 case resp.body |> Poison.decode(keys: :atoms) do
289 − {:ok, json} -> {:lambent, ip, json.udp_sox |> length}
290 − {:error, _error} -> {:not_lambent, ip, -1}
361 + {:ok, json} -> {:lambent, ip, json.udp_sox |> length, indicator_port(json)}
362 + {:error, _error} -> {:not_lambent, ip, -1, nil}
291 363 end
292 364
293 365 _other ->
294 − {:not_lambent, ip, -1}
366 + {:not_lambent, ip, -1, nil}
295 367 end
296 368
297 369 {:error, err} ->
298 370 if ip == "172.16.15.80", do: IO.inspect(err)
299 − {:not_lambent, ip, -1}
371 + {:not_lambent, ip, -1, nil}
372 + end
373 + end
374 +
375 + # the firmware only advertises an indicator it actually brought up, and older
376 + # builds do not advertise one at all
377 + defp indicator_port(json) do
378 + case {Map.get(json, :has_indicator), Map.get(json, :indicator_port)} do
379 + {true, port} when is_integer(port) -> port
380 + _otherwise -> nil
300 381 end
301 382 end
302 383
@@ -345,15 +426,69 @@ defmodule LambentEx.Scan.ESP8266x7777 do
345 426 end
346 427
347 428 def handle_cast({:manual, ip}, state) do
348 − scans = LambentEx.Scan.NMAP.retrieve()
349 − i = scans |> Enum.filter(fn {k,v} -> v |> Map.keys() |> Enum.member?(ip) end) |> Enum.map(fn {k,v} -> k end) |> List.first
350 − case query_http(ip) |> IO.inspect do
351 − {:lambent, ip, cnt} -> via_tuple("scan_8266") |> GenServer.cast({:scan_results, i, ip, cnt})
352 − _otherwise -> :ok
429 + case resolve_iface(ip, state) do
430 + nil ->
431 + Logger.notice("#{@p} Manual probe of #{ip} skipped, no matching interface")
432 + publish_manual(ip, :no_iface)
433 +
434 + iface ->
435 + Logger.notice("#{@p} Manually probing #{ip} on #{iface}")
436 +
437 + Task.Supervisor.async_nolink(LambentEx.Scan.Connectivity.Supervisor, fn ->
438 + case query_http(ip) do
439 + {:lambent, ip, cnt, ind} ->
440 + via_tuple("scan_8266") |> GenServer.cast({:scan_results, iface, ip, cnt, ind})
441 + publish_manual(ip, {:lambent, cnt})
442 +
443 + _otherwise ->
444 + publish_manual(ip, :not_lambent)
445 + end
446 + end)
353 447 end
448 +
354 449 {:noreply, state}
355 450 end
356 451
452 + # its own topic: the device topic carries device data to several views, and
453 + # this is feedback for whoever asked for the probe
454 + defp publish_manual(ip, status) do
455 + Phoenix.PubSub.broadcast(@pubsub_name, @pubsub_manual, {:manual_result, ip, status})
456 + end
457 +
458 + # which of our interfaces would this address live on? arp table first, since
459 + # that also tells us the device is actually reachable, then subnet matching
460 + # for things we haven't talked to yet.
461 + defp resolve_iface(ip, state) do
462 + case IP.from_string(ip) do
463 + {:ok, addr} -> arp_iface(addr, state) || subnet_iface(addr, state)
464 + _otherwise -> nil
465 + end
466 + end
467 +
468 + defp arp_iface(addr, state) do
469 + state[:arps]
470 + |> Enum.find_value(fn {iface, table} -> if Map.has_key?(table, addr), do: iface end)
471 + end
472 +
473 + defp subnet_iface(addr, state) do
474 + state[:interfaces]
475 + |> Enum.find_value(fn {iface, ifd} ->
476 + subnet =
477 + case state[:subnet_override] |> Map.get(iface) do
478 + nil ->
479 + LambentEx.Utils.Network.get_ip_range(
480 + Keyword.get(ifd, :addr),
481 + Keyword.get(ifd, :netmask) |> LambentEx.Utils.Network.get_netmask()
482 + )
483 +
484 + val ->
485 + val
486 + end
487 +
488 + if IP.Subnet.is_in(subnet, addr), do: iface
489 + end)
490 + end
491 +
357 492 @impl true
358 493 def handle_cast(_msg, state) do
359 494 {:noreply, state}
@@ -404,31 +539,40 @@ defmodule LambentEx.Scan.ESP8266x7777 do
404 539 def handle_info(:arp_flush, state) do
405 540 Process.send_after(self(), :arp_flush, :timer.seconds(5))
406 541 to_arp = state.to_arp
407 − state = to_arp |> Enum.reduce(state, fn {if, ip, count}, state ->
408 − if_fmt = Kernel.to_string(if)
409 − ip_fmt = ip |> String.split(".") |> Enum.map(&String.to_integer/1) |> List.to_tuple
410 − arp = state[:arps] |> Map.get(if_fmt, %{}) |> Map.get(ip_fmt)
411 − case arp do
412 − nil -> state
413 − val -> instances = %{if_fmt => [{ip, count}]}
414 − state = join_new_instances_old(instances, state)
415 − %{state | in_arp: state.in_arp |> List.insert_at(-1, {if, ip, count})}
416 − end
417 − end)
542 +
543 + state =
544 + to_arp
545 + |> Enum.reduce(state, fn {if, ip, count, ind}, state ->
546 + if_fmt = Kernel.to_string(if)
547 + ip_fmt = ip |> String.split(".") |> Enum.map(&String.to_integer/1) |> List.to_tuple()
548 + arp = state[:arps] |> Map.get(if_fmt, %{}) |> Map.get(ip_fmt)
549 +
550 + case arp do
551 + nil ->
552 + state
553 +
554 + _val ->
555 + instances = %{if_fmt => [{ip, count, ind}]}
556 + state = join_new_instances_old(instances, state)
557 + %{state | in_arp: state.in_arp |> List.insert_at(-1, {if, ip, count, ind})}
558 + end
559 + end)
418 560
419 561 new_to_arp = to_arp -- state.in_arp
420 −# IO.inspect({"2flush", state.to_arp})
562 + # IO.inspect({"2flush", state.to_arp})
421 563 {:noreply, %{state | to_arp: new_to_arp, in_arp: []}}
422 564 end
423 565
424 566 @impl true
425 567 def handle_info(:scan, state) do
426 568 founds = LambentEx.Scan.NMAP.retrieve()
427 − case founds |> Enum.map(fn {k,v} -> Map.keys(v) |> Kernel.length end) |> Enum.sum do
569 +
570 + case founds |> Enum.map(fn {k, v} -> Map.keys(v) |> Kernel.length() end) |> Enum.sum() do
428 571 0 ->
429 572 Process.send_after(self(), :scan, :timer.seconds(10))
430 573 Logger.notice("NMAP Scanner has not populated results, waiting")
431 574 {:noreply, state}
575 +
432 576 _otherwise ->
433 577 Process.send_after(self(), :scan, :timer.seconds(180))
434 578 Logger.notice("#{@s} Scanning for 7777 instances")
@@ -436,12 +580,17 @@ defmodule LambentEx.Scan.ESP8266x7777 do
436 580 instances =
437 581 LambentEx.Scan.NMAP.retrieve()
438 582 |> Enum.map(fn {i, results} ->
439 − iface_items = state.devices |> Enum.map(fn {_m, %{"ip" => ip}} -> ip |> Tuple.to_list |> Enum.join(".") end)
440 − keys = results |> Map.keys() |> Enum.filter(fn k -> k != "" end) |> Enum.reject(fn x -> Enum.member?(iface_items, x) end)
441 −
442 − # todo avoid booming by accruing a counter that only scans existing shit every n scan attempts
583 + iface_items =
584 + state.devices
585 + |> Enum.map(fn {_m, %{"ip" => ip}} -> ip |> Tuple.to_list() |> Enum.join(".") end)
443 586
587 + keys =
588 + results
589 + |> Map.keys()
590 + |> Enum.filter(fn k -> k != "" end)
591 + |> Enum.reject(fn x -> Enum.member?(iface_items, x) end)
444 592
593 + # todo avoid booming by accruing a counter that only scans existing shit every n scan attempts
445 594
446 595 # state.interfaces
447 596 # |> Enum.map(fn {i, v} ->
@@ -454,13 +603,13 @@ defmodule LambentEx.Scan.ESP8266x7777 do
454 603 # end
455 604 # |> IO.inspect
456 605 # |> trim_range
457 −# lambents =
458 −# keys
459 −# |> IO.inspect()
460 −# |> Enum.map(&Task.async(fn -> query_http(&1) end))
461 −# |> Enum.map(fn x -> Task.await(x, 30000) end)
462 −# |> Enum.filter(fn {return, _value, _ch} -> return == :lambent end)
463 −# |> Enum.map(fn {_atom, ip, sox} -> {ip, sox} end)
606 + # lambents =
607 + # keys
608 + # |> IO.inspect()
609 + # |> Enum.map(&Task.async(fn -> query_http(&1) end))
610 + # |> Enum.map(fn x -> Task.await(x, 30000) end)
611 + # |> Enum.filter(fn {return, _value, _ch} -> return == :lambent end)
612 + # |> Enum.map(fn {_atom, ip, sox} -> {ip, sox} end)
464 613
465 614 lambents =
466 615 keys
@@ -468,22 +617,26 @@ defmodule LambentEx.Scan.ESP8266x7777 do
468 617 |> Enum.map(fn ip ->
469 618 Task.Supervisor.async_nolink(LambentEx.Scan.Connectivity.Supervisor, fn ->
470 619 case query_http(ip) do
471 − {:lambent, ip, cnt} -> via_tuple("scan_8266") |> GenServer.cast({:scan_results, i, ip, cnt})
472 − _otherwise -> :ok
620 + {:lambent, ip, cnt, ind} ->
621 + via_tuple("scan_8266") |> GenServer.cast({:scan_results, i, ip, cnt, ind})
622 +
623 + _otherwise ->
624 + :ok
473 625 end
474 626 end)
475 627 end)
476 628
477 −# Logger.notice("#{@s} Found #{lambents |> Kernel.length()} on #{i}")
478 −# IO.inspect(lambents)
479 −# {i |> Kernel.to_string(), lambents}
629 + # Logger.notice("#{@s} Found #{lambents |> Kernel.length()} on #{i}")
630 + # IO.inspect(lambents)
631 + # {i |> Kernel.to_string(), lambents}
480 632 end)
481 −# |> Map.new()
482 −# IO.inspect(instances)
483 −# state = join_new_instances_old(instances, state)
633 +
634 + # |> Map.new()
635 + # IO.inspect(instances)
636 + # state = join_new_instances_old(instances, state)
484 637
485 638 {:noreply, state}
486 − end
639 + end
487 640 end
488 641
489 642 def handle_info({:send, dvc, stream}, state) do
@@ -496,6 +649,13 @@ defmodule LambentEx.Scan.ESP8266x7777 do
496 649 {:noreply, state}
497 650 end
498 651
652 + # the indicator is a bare WS2812 the firmware packs itself, so it wants plain
653 + # rgb and none of the strand ord shuffling
654 + def handle_info({:send_ind, dvc, [_r, _g, _b] = color}, state) do
655 + :gen_udp.send(state.socket, dvc["ip"], dvc["ind"], color)
656 + {:noreply, state}
657 + end
658 +
499 659 defp add_type(devices) do
500 660 devices
501 661 |> Enum.map(fn {k, v} ->
@@ -531,7 +691,6 @@ defmodule LambentEx.Scan.ESP8266x7777 do
531 691 {:noreply, state}
532 692 end
533 693
534 −
535 694 defp join_arps(new, state) do
536 695 arps = state[:arps]
537 696
@@ -544,6 +703,11 @@ defmodule LambentEx.Scan.ESP8266x7777 do
544 703 %{state | arps: result}
545 704 end
546 705
706 + # the esp32rs firmware does its own grb packing and wants the bytes handed
707 + # over unshuffled, which is what the (badly named) "grb" passthrough does
708 + defp default_ord(%{"sox" => sox}) when sox > 0, do: "grb"
709 + defp default_ord(_device), do: nil
710 +
547 711 defp join_new_instances_old(new, state) do
548 712 devices = state[:devices]
549 713 arps = state[:arps]
@@ -552,12 +716,15 @@ defmodule LambentEx.Scan.ESP8266x7777 do
552 716 new
553 717 |> Enum.map(fn {if, arr} ->
554 718 arr
555 − |> Enum.map(fn {ip, sox} ->
719 + |> Enum.map(fn {ip, sox, ind} ->
556 720 ip = IP.from_string!(ip)
557 721 mac = Map.get(arps[if], ip)
558 722
559 723 {Map.get(arps[if], ip),
560 − Map.get(devices, mac, %{}) |> Map.put("ip", ip) |> Map.put("sox", sox)}
724 + Map.get(devices, mac, %{})
725 + |> Map.put("ip", ip)
726 + |> Map.put("sox", sox)
727 + |> Map.put("ind", ind)}
561 728 end)
562 729 |> Map.new()
563 730 end)
@@ -568,7 +735,7 @@ defmodule LambentEx.Scan.ESP8266x7777 do
568 735 {k,
569 736 v
570 737 |> Map.put("name", M.get_name(k))
571 − |> Map.put("ord", M.get_ord(k))
738 + |> Map.put("ord", M.get_ord(k) || default_ord(v))
572 739 |> Map.put("place", M.get_place(k))}
573 740 end)
574 741 |> Enum.filter(fn {k, _v} -> k != nil end)
modified lib/lambent_ex/schema/links.ex
+52 −5
@@ -12,24 +12,28 @@ defmodule LambentEx.Schema.Links do
12 12 field(:source_type, Ecto.Enum, values: [:machine, :anchor], default: :machine)
13 13 field(:target_id, :string)
14 14 field(:target_type, Ecto.Enum, values: [:device, :group], default: :device)
15 + field(:chan, :string, default: "all")
15 16 end
16 17
17 18 def change_link(%Links{} = link, attrs \\ %{}) do
18 19 Links.changeset(link, attrs)
19 20 end
20 21
21 − def update_links(%Links{} = link, attrs) do
22 + def update_links(%Links{} = link, attrs, devices \\ %{}) do
22 23 res =
23 24 link
24 25 |> Links.changeset(attrs)
25 26
26 27 case res.valid? do
27 28 true ->
29 + target = res.changes[:target_id]
30 +
28 31 {:ok,
29 32 LambentEx.LinkSupervisor.start_child(
30 33 res.changes[:name],
31 34 res.changes[:source_id],
32 − res.changes[:target_id]
35 + target,
36 + LambentEx.Schema.Links.Chan.resolve(res.changes[:chan], devices |> Map.get(target))
33 37 )}
34 38
35 39 false ->
@@ -39,11 +43,52 @@ defmodule LambentEx.Schema.Links do
39 43
40 44 def changeset(source, params) do
41 45 source
42 − |> cast(params, ~w(name source_id source_type target_id target_type)a)
46 + |> cast(params, ~w(name source_id source_type target_id target_type chan)a)
43 47 |> validate_required([:name, :source_id, :source_type, :target_id, :target_type])
44 48 end
45 49 end
46 50
51 +defmodule LambentEx.Schema.Links.Chan do
52 + @moduledoc false
53 +
54 + alias LambentEx.Scan.ESP8266x7777, as: S
55 +
56 + @doc """
57 + How many channels a link may be pointed at. One means there is nothing to
58 + choose between, so the form hides the selector entirely.
59 + """
60 + def count(nil), do: 1
61 + def count(device), do: S.channels(device)
62 +
63 + @doc """
64 + Turns the form's channel choice into what the link process wants, an index or
65 + :all. Single channel devices — legacy 8266s and tasmota — are always chan 0,
66 + and a choice the device cannot honour falls back to the same.
67 + """
68 + def resolve(chan, device) do
69 + chans = count(device)
70 +
71 + case parse(chan) do
72 + _any when chans <= 1 -> 0
73 + :all -> :all
74 + idx when idx < chans -> idx
75 + _out_of_range -> 0
76 + end
77 + end
78 +
79 + defp parse(nil), do: :all
80 + defp parse(:all), do: :all
81 + defp parse("all"), do: :all
82 + defp parse(chan) when is_integer(chan), do: chan
83 +
84 + defp parse(chan) when is_binary(chan) do
85 + case Integer.parse(chan) do
86 + {idx, ""} -> idx
87 + _otherwise -> :all
88 + end
89 + end
90 +end
91 +
47 92 defmodule LambentEx.Schema.LinksB do
48 93 @moduledoc false
49 94
@@ -58,6 +103,7 @@ defmodule LambentEx.Schema.LinksB do
58 103 field(:source_type, Ecto.Enum, values: [:machine, :anchor], default: :machine)
59 104 field(:target_ids, {:array, :string})
60 105 field(:target_type, Ecto.Enum, values: [:device, :group], default: :device)
106 + field(:chan, :string, default: "all")
61 107 end
62 108
63 109 def change_link(%LinksB{} = link, attrs \\ %{}) do
@@ -90,7 +136,8 @@ defmodule LambentEx.Schema.LinksB do
90 136 LambentEx.LinkSupervisor.start_child(
91 137 "#{res.changes[:name]}|#{id |> get_name(devices)}",
92 138 res.changes[:source_id],
93 − id
139 + id,
140 + LambentEx.Schema.Links.Chan.resolve(res.changes[:chan], devices |> Map.get(id))
94 141 )
95 142 end
96 143
@@ -104,7 +151,7 @@ defmodule LambentEx.Schema.LinksB do
104 151
105 152 def changeset(source, params) do
106 153 source
107 − |> cast(params, ~w(name source_id source_type target_ids target_type)a)
154 + |> cast(params, ~w(name source_id source_type target_ids target_type chan)a)
108 155 |> validate_required([:name, :source_id, :source_type, :target_ids, :target_type])
109 156 end
110 157 end
modified lib/lambent_ex/supervision/link.ex
+2 −2
@@ -5,8 +5,8 @@ defmodule LambentEx.LinkSupervisor do
5 5 DynamicSupervisor.start_link(__MODULE__, init_arg, name: __MODULE__)
6 6 end
7 7
8 − def start_child(name, source, target) do
9 − spec = {LambentEx.Link, [name: name, source: source, target: target]}
8 + def start_child(name, source, target, chan \\ 0) do
9 + spec = {LambentEx.Link, [name: name, source: source, target: target, chan: chan]}
10 10 DynamicSupervisor.start_child(__MODULE__, spec)
11 11 end
12 12
modified lib/lambent_ex_web/components/core_components.ex
+22 −0
@@ -321,6 +321,28 @@ defmodule LambentExWeb.CoreComponents do
321 321 """
322 322 end
323 323
324 + def input(%{type: "radio"} = assigns) do
325 + ~H"""
326 + <div phx-feedback-for={@name} class="form-control">
327 + <.label for={@id}><%= @label %></.label>
328 + <div class="btn-group">
329 + <input
330 + :for={{opt_label, opt_value} <- @options}
331 + type="radio"
332 + id={"#{@id || @name}-#{opt_value}"}
333 + name={@name}
334 + value={opt_value}
335 + data-title={opt_label}
336 + checked={input_equals?(@value, opt_value)}
337 + class="btn"
338 + {@rest}
339 + />
340 + </div>
341 + <.error :for={msg <- @errors}><%= msg %></.error>
342 + </div>
343 + """
344 + end
345 +
324 346 def input(%{type: "textarea"} = assigns) do
325 347 ~H"""
326 348 <div phx-feedback-for={@name}>
modified lib/lambent_ex_web/live/devices/index.ex
+55 −1

Click to load diff…

modified lib/lambent_ex_web/live/devices/index.html.heex
+54 −4

Click to load diff…

modified lib/lambent_ex_web/live/links/bulk_form_component.ex
+32 −7

Click to load diff…

modified lib/lambent_ex_web/live/links/bulk_form_component.html.heex
+3 −0

Click to load diff…

modified lib/lambent_ex_web/live/links/form_component.ex
+33 −7

Click to load diff…

modified lib/lambent_ex_web/live/links/form_component.html.heex
+3 −0

Click to load diff…

modified lib/lambent_ex_web/live/links/index.ex
+6 −2

Click to load diff…

modified lib/lambent_ex_web/live/links/index.html.heex
+8 −1

Click to load diff…

modified mix.exs
+2 −2

Click to load diff…

modified mix.lock
+29 −24

Click to load diff…

Parents: 98f4b28