Crossfade a link onto strands someone else is driving

5e87a85 · Gabriel Morell · 2026-09-16 22:49

15 files +636 -16
Message
{commit_body(@commit)}

Files changed

modified lib/lambent_ex/link.ex
+201 −15
@@ -12,15 +12,38 @@ defmodule LambentEx.Link do
12 12 alias LambentEx.Brightness
13 13 alias LambentEx.Meta, as: M
14 14 alias LambentEx.Utils.Brightness, as: B
15 + alias LambentEx.Utils.Fade
16 +
17 + # how long a handover between two links on the same strands takes. long
18 + # enough, and eased at both ends, that the swap reads as the picture drifting
19 + # rather than as two machines trading places
20 + @fade 4000
21 +
22 + # a fade runs on its own clock rather than on whatever the machines happen to
23 + # be doing. a machine stepping once a second would otherwise give the whole
24 + # handover four frames.
25 + @fade_steps 128
15 26
16 27 def start_link(_args, opts) do
17 28 GenServer.start_link(__MODULE__, opts, name: via_tuple("link-#{opts[:name]}"))
18 29 end
19 30
31 + @impl true
20 32 def init(opts) do
21 33 Process.send_after(self(), :publish, 50)
22 34 # links saved before channels existed drove 7777 only, which is chan 0
23 − {:ok, opts} = Keyword.validate(opts, [:source, :target, :name, :persist, chan: 0])
35 + {:ok, opts} =
36 + Keyword.validate(opts, [
37 + :source,
38 + :target,
39 + :name,
40 + :persist,
41 + chan: 0,
42 + fade: @fade,
43 + fade_mode: :hue,
44 + fade_steps: @fade_steps
45 + ])
46 +
24 47 Phoenix.PubSub.subscribe(@pubsub_name, @pubsub_topic <> opts[:source])
25 48 Phoenix.PubSub.subscribe(@pubsub_name, @pubsub_pfx_on <> opts[:target])
26 49
@@ -30,6 +53,29 @@ defmodule LambentEx.Link do
30 53 name: opts[:name],
31 54 chan: opts[:chan],
32 55 enabled: true,
56 + fade: opts[:fade],
57 + # :hue walks the colour wheel, :rgb blends the channels straight
58 + fade_mode: opts[:fade_mode],
59 + fade_steps: opts[:fade_steps],
60 + # the clock that walks those steps, so the whole fade lands in `fade` ms
61 + fade_tick: tick_for(opts[:fade], opts[:fade_steps]),
62 + # the crossfade this link is coming up through, and the frames the link
63 + # it displaced is still feeding it
64 + fade_until: nil,
65 + fade_from: nil,
66 + # which link we are taking these strands off. every link pointed at this
67 + # device hears the handover, and only the one we displaced has anything
68 + # to do with what is on the wire right now
69 + fade_src: nil,
70 + # how long this link keeps feeding whoever displaced it
71 + standdown_until: nil,
72 + # the last frame off our own machine, so a newcomer has something live
73 + # to start from the moment it announces itself
74 + last: nil,
75 + # when the fade clock is running, and when this link last put something
76 + # on the wire, so the clock only fills the gaps the machines leave
77 + ticking: false,
78 + last_sent: nil,
33 79 # the target device's place, for the place clamp. refreshed on the
34 80 # publish tick so a device moving rooms does not need a link restart
35 81 place: M.get_place(opts[:target]),
@@ -38,6 +84,7 @@ defmodule LambentEx.Link do
38 84 full_opts: opts
39 85 }
40 86
87 + state = state |> start_fade()
41 88 neighbor_on(state)
42 89
43 90 # todo
@@ -100,6 +147,94 @@ defmodule LambentEx.Link do
100 147 )
101 148 end
102 149
150 + # coming on against strands someone else is driving is a crossfade, not a
151 + # cut. nothing to blend with if we turn out to be the only one here, and the
152 + # window simply lapses.
153 + defp start_fade(%{fade: fade} = state) when fade > 0 do
154 + %{state | fade_until: Fade.now() + fade, fade_from: nil, fade_src: nil} |> tick_fade()
155 + end
156 +
157 + defp start_fade(state), do: state
158 +
159 + defp tick_for(fade, steps) when fade > 0 and steps > 0, do: max(div(fade, steps), 1)
160 + defp tick_for(_fade, _steps), do: @fade_steps
161 +
162 + defp tick_fade(%{ticking: true} = state), do: state
163 +
164 + defp tick_fade(state) do
165 + Process.send_after(self(), :fade_tick, state[:fade_tick])
166 + %{state | ticking: true}
167 + end
168 +
169 + defp fading?(%{fade_until: nil}), do: false
170 + defp fading?(state), do: Fade.now() < state[:fade_until]
171 +
172 + # the outgoing side. both machines keep running, so rather than leaving a
173 + # still of our last frame behind we keep handing over live ones for as long
174 + # as the fade lasts.
175 + defp stand_down(state) do
176 + state = %{state | enabled: false, standdown_until: Fade.now() + state[:fade]}
177 + if state[:last], do: hand_over(state, state[:last])
178 + state
179 + end
180 +
181 + defp hand_over(state, data) do
182 + Phoenix.PubSub.broadcast(
183 + @pubsub_name,
184 + @pubsub_pfx_on <> state[:target],
185 + {:fade_frame, state[:name], state[:chan], data}
186 + )
187 + end
188 +
189 + defp mix(%{fade_mode: :rgb} = state, data, t), do: Fade.blend(state[:fade_from], data, t)
190 + defp mix(state, data, t), do: Fade.hue_walk(state[:fade_from], data, t)
191 +
192 + defp handing_over?(%{standdown_until: nil}), do: false
193 + defp handing_over?(state), do: Fade.now() < state[:standdown_until]
194 +
195 + defp send_frame(state, data) do
196 + # the machine has already applied its own brightness and the global clamp;
197 + # the place clamp is the last thing between here and the wire
198 + LambentEx.Scan.ESP8266x7777.submit(
199 + state[:target],
200 + data |> B.scale(Brightness.place(state[:place])),
201 + state[:chan]
202 + )
203 +
204 + %{state | last_sent: Fade.now()}
205 + end
206 +
207 + defp due?(%{last_sent: nil}), do: true
208 + defp due?(state), do: Fade.now() - state[:last_sent] >= state[:fade_tick]
209 +
210 + defp step_fade(state) do
211 + case {state[:last], state[:fade_from]} do
212 + # nothing of our own yet, so keep the outgoing machine on the wire
213 + {nil, nil} -> state
214 + {nil, from} -> send_frame(state, from)
215 + {_own, nil} -> state
216 + {own, _from} -> with_fade(state, own)
217 + end
218 + end
219 +
220 + defp with_fade(state, own) do
221 + {out, state} = crossfade(own, state)
222 + send_frame(state, out)
223 + end
224 +
225 + # the incoming side
226 + defp crossfade(data, %{fade_until: nil} = state), do: {data, state}
227 +
228 + defp crossfade(data, state) do
229 + t = Fade.progress(state[:fade_until], state[:fade], Fade.now())
230 +
231 + cond do
232 + t >= 1.0 -> {data, %{state | fade_until: nil, fade_from: nil}}
233 + is_nil(state[:fade_from]) -> {data, state}
234 + true -> {mix(state, data, t |> Fade.quantize(state[:fade_steps]) |> Fade.ease()), state}
235 + end
236 + end
237 +
103 238 # two links on the same device only fight over the strands they share, so a
104 239 # chan 0 and a chan 1 link can both run. :all covers every strand and so
105 240 # collides with anything, including another :all.
@@ -109,21 +244,42 @@ defmodule LambentEx.Link do
109 244
110 245 @impl true
111 246 def handle_info({:publish, data}, state) do
112 − case state[:enabled] do
247 + data = data |> List.flatten()
248 + state = %{state | last: data}
249 +
250 + cond do
251 + state[:enabled] ->
252 + {out, state} = crossfade(data, state)
253 + {:noreply, send_frame(state, out)}
254 +
255 + handing_over?(state) ->
256 + hand_over(state, data)
257 + {:noreply, state}
258 +
113 259 true ->
114 − # the machine has already applied its own brightness and the global
115 − # clamp; the place clamp is the last thing between here and the wire
116 − LambentEx.Scan.ESP8266x7777.submit(
117 − state[:target],
118 − data |> List.flatten() |> B.scale(Brightness.place(state[:place])),
119 − state[:chan]
120 − )
260 + {:noreply, state}
261 + end
262 + end
263 +
264 + # the fade's own clock. it fills in between the machines rather than doubling
265 + # up on them, so a fast machine keeps driving the frames itself
266 + @impl true
267 + def handle_info(:fade_tick, state) do
268 + state = %{state | ticking: false}
121 269
270 + case fading?(state) and state[:enabled] do
122 271 false ->
123 − :ok
124 − end
272 + {:noreply, state}
125 273
126 − {:noreply, state}
274 + true ->
275 + state =
276 + case due?(state) do
277 + false -> state
278 + true -> step_fade(state)
279 + end
280 +
281 + {:noreply, tick_fade(state)}
282 + end
127 283 end
128 284
129 285 @impl true
@@ -135,9 +291,37 @@ defmodule LambentEx.Link do
135 291
136 292 @impl true
137 293 def handle_info({:neighbor_on, id, chan}, state) do
294 + cond do
295 + state[:name] == id ->
296 + {:noreply, state}
297 +
298 + not overlaps?(state[:chan], chan) ->
299 + {:noreply, state}
300 +
301 + # only whoever was actually driving these strands has a handover to make.
302 + # a link that has been off since the last swap has nothing on the wire,
303 + # and feeding its machine in would drag every stale link on this device
304 + # into the fade
305 + state[:enabled] ->
306 + {:noreply, stand_down(state)}
307 +
308 + true ->
309 + {:noreply, %{state | standdown_until: nil}}
310 + end
311 + end
312 +
313 + # a frame from the link we are taking these strands off, for as long as our
314 + # own fade window lasts
315 + @impl true
316 + def handle_info({:fade_frame, id, chan, data}, state) do
138 317 cond do
139 318 state[:name] == id -> {:noreply, state}
140 − overlaps?(state[:chan], chan) -> {:noreply, %{state | enabled: false}}
319 + is_nil(state[:fade_until]) -> {:noreply, state}
320 + not overlaps?(state[:chan], chan) -> {:noreply, state}
321 + # whoever speaks first is the link we took these strands off; anyone else
322 + # talking during our fade is not what the device is showing
323 + is_nil(state[:fade_src]) -> {:noreply, %{state | fade_src: id, fade_from: data}}
324 + state[:fade_src] == id -> {:noreply, %{state | fade_from: data}}
141 325 true -> {:noreply, state}
142 326 end
143 327 end
@@ -161,15 +345,17 @@ defmodule LambentEx.Link do
161 345 {:noreply, %{state | enabled: false}}
162 346
163 347 false ->
348 + state = %{state | enabled: true} |> start_fade()
164 349 neighbor_on(state)
165 − {:noreply, %{state | enabled: true}}
350 + {:noreply, state}
166 351 end
167 352 end
168 353
169 354 @impl true
170 355 def handle_cast(:enable, state) do
356 + state = %{state | enabled: true} |> start_fade()
171 357 neighbor_on(state)
172 − {:noreply, %{state | enabled: true}}
358 + {:noreply, state}
173 359 end
174 360
175 361 @impl true
added lib/lambent_ex/util/fade.ex
+178 −0
@@ -0,0 +1,178 @@
1 +defmodule LambentEx.Utils.Fade do
2 + @moduledoc """
3 + Crossfading between two streams of channel bytes.
4 +
5 + Both sides of a handover are running machines, so this blends two live
6 + frames rather than fading away from a still of the outgoing one.
7 + """
8 +
9 + @doc """
10 + Blend two flat channel lists. `t` is how far the fade has got: 0.0 is all
11 + `from`, 1.0 is all `to`.
12 +
13 + Frames of different lengths happen when the two machines were built for
14 + different strand counts. The overlap blends, and anything past it is only
15 + the incoming stream, since the outgoing one has nothing to say there.
16 + """
17 + def blend(from, _to, t) when t <= 0.0, do: from
18 + def blend(_from, to, t) when t >= 1.0, do: to
19 +
20 + def blend(from, to, t) do
21 + blended = Enum.zip_with([from, to], fn [f, n] -> round(f + (n - f) * t) end)
22 +
23 + case length(to) - length(blended) do
24 + tail when tail > 0 -> blended ++ Enum.take(to, -tail)
25 + _otherwise -> blended
26 + end
27 + end
28 +
29 + @doc """
30 + Soften a linear 0..1 ramp into an S curve, so a handover has no moment where
31 + it visibly starts or stops. A straight ramp gives both away: the incoming
32 + machine appears the instant the fade begins, and the outgoing one is still a
33 + few percent of the picture when it is cut off at the end.
34 + """
35 + def ease(t) when t <= 0.0, do: 0.0
36 + def ease(t) when t >= 1.0, do: 1.0
37 + def ease(t), do: t * t * (3.0 - 2.0 * t)
38 +
39 + @doc "How far along a fade of `duration` ms ending at `finish` is, 0.0..1.0."
40 + def progress(_finish, duration, _now) when duration <= 0, do: 1.0
41 +
42 + def progress(finish, duration, now) do
43 + ((duration - (finish - now)) / duration) |> max(0.0) |> min(1.0)
44 + end
45 +
46 + @doc """
47 + Walk one frame to another around the colour wheel instead of through the
48 + channels. A channel blend of red to green spends the middle of the fade in
49 + muddy half-lit yellow; a hue walk turns red through orange to green at full
50 + saturation, which reads as the picture changing its mind rather than as one
51 + image dissolving into another.
52 +
53 + Hue takes the shorter way round. Saturation and value are ordinary ramps, so
54 + a pixel that is off at one end simply comes up, and one with no colour to
55 + speak of keeps whatever hue the other side has rather than drifting through
56 + the wheel on its way out of black.
57 + """
58 + def hue_walk(from, _to, t) when t <= 0.0, do: from
59 + def hue_walk(_from, to, t) when t >= 1.0, do: to
60 +
61 + def hue_walk(from, to, t) do
62 + case {rem(length(from), 3), rem(length(to), 3)} do
63 + # not whole pixels, so there is no colour here to walk
64 + {0, 0} -> walk_pixels(from, to, t)
65 + _otherwise -> blend(from, to, t)
66 + end
67 + end
68 +
69 + defp walk_pixels(from, to, t) do
70 + walked =
71 + Enum.zip_with(
72 + [Enum.chunk_every(from, 3), Enum.chunk_every(to, 3)],
73 + fn [f, n] -> walk_pixel(f, n, t) end
74 + )
75 + |> List.flatten()
76 +
77 + case length(to) - length(walked) do
78 + tail when tail > 0 -> walked ++ Enum.take(to, -tail)
79 + _otherwise -> walked
80 + end
81 + end
82 +
83 + defp walk_pixel(from, to, t) do
84 + {fh, fs, fv} = hsv(from)
85 + {nh, ns, nv} = hsv(to)
86 +
87 + # a pixel that is off has neither hue nor saturation to walk, so it takes
88 + # the other side's and just comes up in that colour. one that is lit but
89 + # colourless keeps its own saturation ramp and only borrows the hue.
90 + {fh, fs, nh, ns} =
91 + cond do
92 + fv == 0.0 -> {nh, ns, nh, ns}
93 + nv == 0.0 -> {fh, fs, fh, fs}
94 + fs == 0.0 -> {nh, fs, nh, ns}
95 + ns == 0.0 -> {fh, fs, fh, ns}
96 + true -> {fh, fs, nh, ns}
97 + end
98 +
99 + rgb({hue(fh, nh, t), lerp(fs, ns, t), lerp(fv, nv, t)})
100 + end
101 +
102 + defp lerp(a, b, t), do: a + (b - a) * t
103 +
104 + # the shorter way round the wheel
105 + defp hue(a, b, t) do
106 + d = b - a
107 +
108 + d =
109 + cond do
110 + d > 0.5 -> d - 1.0
111 + d < -0.5 -> d + 1.0
112 + true -> d
113 + end
114 +
115 + case a + d * t do
116 + h when h < 0.0 -> h + 1.0
117 + h when h >= 1.0 -> h - 1.0
118 + h -> h
119 + end
120 + end
121 +
122 + # kept here rather than taken from Utils.Color: the machines lean on that
123 + # one's quirks, and a fade wants a plain conversion
124 + defp hsv([r, g, b]) do
125 + r = r / 255.0
126 + g = g / 255.0
127 + b = b / 255.0
128 +
129 + v = Enum.max([r, g, b])
130 + c = v - Enum.min([r, g, b])
131 + s = if v == 0.0, do: 0.0, else: c / v
132 +
133 + h =
134 + cond do
135 + c == 0.0 -> 0.0
136 + v == r -> ((g - b) / c / 6.0) |> wrap()
137 + v == g -> ((b - r) / c + 2.0) / 6.0
138 + true -> ((r - g) / c + 4.0) / 6.0
139 + end
140 +
141 + {h, s, v}
142 + end
143 +
144 + defp wrap(h) when h < 0.0, do: h + 1.0
145 + defp wrap(h), do: h
146 +
147 + defp rgb({h, s, v}) do
148 + i = (h * 6.0) |> Float.floor() |> trunc
149 + f = h * 6.0 - i
150 + p = v * (1.0 - s)
151 + q = v * (1.0 - s * f)
152 + u = v * (1.0 - s * (1.0 - f))
153 +
154 + case rem(i, 6) do
155 + 0 -> [v, u, p]
156 + 1 -> [q, v, p]
157 + 2 -> [p, v, u]
158 + 3 -> [p, q, v]
159 + 4 -> [u, p, v]
160 + _otherwise -> [v, p, q]
161 + end
162 + |> Enum.map(fn c -> (c * 255.0) |> round |> max(0) |> min(255) end)
163 + end
164 +
165 + @doc """
166 + Snap a 0..1 ramp to one of `steps` stops. A fade is a fixed number of steps
167 + whatever the frame rate, so the same handover looks the same whether the
168 + machines are stepping faster than the clock or slower than it.
169 + """
170 + def quantize(t, steps) when steps > 0 do
171 + ((t * steps) |> trunc |> min(steps)) / steps
172 + end
173 +
174 + def quantize(t, _steps), do: t
175 +
176 + @doc "The clock a fade is measured against."
177 + def now, do: System.monotonic_time(:millisecond)
178 +end
modified lib/lambent_ex_web/live/brightness/index.ex
+3 −1
@@ -175,7 +175,9 @@ defmodule LambentExWeb.BrightnessLive.Index do
175 175 |> Enum.sort()
176 176 end
177 177
178 − defp walk(clamped, place), do: Map.get(clamped, place, %{tgt: B.full(), curr: B.full()})
178 + defp walk(clamped, place) do
179 + Map.get(clamped, place, %{tgt: B.full(), curr: B.full(), rate: B.default_fade()})
180 + end
179 181
180 182 defp devices_in(devices, place) do
181 183 devices |> Map.values() |> Enum.count(fn d -> d["place"] == place end)
added test/lambent_ex/fade_test.exs
+215 −0
@@ -0,0 +1,215 @@
1 +defmodule LambentEx.FadeTest do
2 + use ExUnit.Case, async: false
3 +
4 + alias LambentEx.Utils.Fade
5 +
6 + @pubsub LambentEx.PubSub
7 +
8 + describe "blending two frames" do
9 + test "meets in the middle" do
10 + assert Fade.blend([0, 0, 0, 255, 255, 255], [255, 255, 255, 0, 0, 0], 0.5) ==
11 + [128, 128, 128, 128, 128, 128]
12 + end
13 +
14 + test "the ends are the frames themselves" do
15 + assert Fade.blend([0, 0, 0], [255, 255, 255], 0.0) == [0, 0, 0]
16 + assert Fade.blend([0, 0, 0], [255, 255, 255], 1.0) == [255, 255, 255]
17 + end
18 +
19 + test "a longer incoming frame keeps its tail" do
20 + assert Fade.blend([0, 0, 0], [255, 255, 255, 10, 20, 30], 0.5) ==
21 + [128, 128, 128, 10, 20, 30]
22 + end
23 +
24 + test "a longer outgoing frame is cut to what the incoming one covers" do
25 + assert Fade.blend([0, 0, 0, 9, 9, 9], [255, 255, 255], 0.5) == [128, 128, 128]
26 + end
27 + end
28 +
29 + describe "walking the hue" do
30 + test "red to green goes round through yellow, not through mud" do
31 + assert Fade.hue_walk([255, 0, 0], [0, 255, 0], 0.5) == [255, 255, 0]
32 + # what the straight channel blend does with the same pair
33 + assert Fade.blend([255, 0, 0], [0, 255, 0], 0.5) == [128, 128, 0]
34 + end
35 +
36 + test "takes the shorter way round the wheel" do
37 + # red to blue is shorter through magenta than through green
38 + assert Fade.hue_walk([255, 0, 0], [0, 0, 255], 0.5) == [255, 0, 255]
39 + end
40 +
41 + test "the ends are the frames themselves" do
42 + assert Fade.hue_walk([255, 0, 0], [0, 255, 0], 0.0) == [255, 0, 0]
43 + assert Fade.hue_walk([255, 0, 0], [0, 255, 0], 1.0) == [0, 255, 0]
44 + end
45 +
46 + test "a dark pixel comes up in the colour it is headed for" do
47 + assert Fade.hue_walk([0, 0, 0], [0, 255, 0], 0.5) == [0, 128, 0]
48 + assert Fade.hue_walk([0, 255, 0], [0, 0, 0], 0.5) == [0, 128, 0]
49 + end
50 +
51 + test "walks every pixel in the frame" do
52 + assert Fade.hue_walk([255, 0, 0, 0, 0, 0], [0, 255, 0, 0, 255, 0], 0.5) ==
53 + [255, 255, 0, 0, 128, 0]
54 + end
55 +
56 + test "a longer incoming frame keeps its tail" do
57 + assert Fade.hue_walk([255, 0, 0], [0, 255, 0, 1, 2, 3], 0.5) == [255, 255, 0, 1, 2, 3]
58 + end
59 +
60 + test "falls back to a channel blend when the frame is not whole pixels" do
61 + assert Fade.hue_walk([0, 0], [255, 255], 0.5) == [128, 128]
62 + end
63 + end
64 +
65 + describe "easing" do
66 + test "starts and ends gently, and is symmetric" do
67 + assert Fade.ease(0.0) == 0.0
68 + assert Fade.ease(0.5) == 0.5
69 + assert Fade.ease(1.0) == 1.0
70 +
71 + # a tenth of the way in, the incoming machine is barely there
72 + assert Fade.ease(0.1) < 0.05
73 + # and the outgoing one is still visible a tenth from the end
74 + assert Fade.ease(0.9) > 0.95
75 + end
76 +
77 + test "is clamped" do
78 + assert Fade.ease(-1.0) == 0.0
79 + assert Fade.ease(2.0) == 1.0
80 + end
81 + end
82 +
83 + describe "progress" do
84 + test "runs from nothing to done over the duration" do
85 + assert Fade.progress(1000, 1000, 0) == 0.0
86 + assert Fade.progress(1000, 1000, 500) == 0.5
87 + assert Fade.progress(1000, 1000, 1000) == 1.0
88 + end
89 +
90 + test "is clamped, and a zero duration is already over" do
91 + assert Fade.progress(1000, 1000, 2000) == 1.0
92 + assert Fade.progress(0, 0, 0) == 1.0
93 + end
94 + end
95 +
96 + describe "a handover between two links" do
97 + setup do
98 + target = "mac-fade-test"
99 + Phoenix.PubSub.subscribe(@pubsub, "links_on_" <> target)
100 +
101 + {:ok, a} = start_link_for("fade-a", "fade-src-a", target)
102 + on_exit(fn -> LambentEx.LinkSupervisor.abort_child(a) end)
103 +
104 + %{a: a, target: target}
105 + end
106 +
107 + test "the outgoing link keeps feeding live frames while it stands down", ctx do
108 + publish("fade-src-a", [1, 2, 3])
109 + :sys.get_state(ctx.a)
110 +
111 + {:ok, b} = start_link_for("fade-b", "fade-src-b", ctx.target)
112 + on_exit(fn -> LambentEx.LinkSupervisor.abort_child(b) end)
113 +
114 + # whatever it had on the wire goes over the moment the newcomer announces
115 + # itself, so there is something to cross from before the next frame
116 + assert_receive {:fade_frame, "fade-a", 0, [1, 2, 3]}
117 + refute :sys.get_state(ctx.a).enabled
118 + assert :sys.get_state(b).fade_from == [1, 2, 3]
119 +
120 + # and its machine is still running, so the frames keep coming
121 + publish("fade-src-a", [4, 5, 6])
122 + assert_receive {:fade_frame, "fade-a", 0, [4, 5, 6]}
123 + assert :sys.get_state(b).fade_from == [4, 5, 6]
124 + end
125 +
126 + test "the fade keeps stepping while the machines are quiet", ctx do
127 + publish("fade-src-a", [255, 0, 0])
128 + :sys.get_state(ctx.a)
129 +
130 + {:ok, b} = start_link_for("fade-b3", "fade-src-b3", ctx.target)
131 + on_exit(fn -> LambentEx.LinkSupervisor.abort_child(b) end)
132 + assert_receive {:fade_frame, "fade-a", 0, [255, 0, 0]}
133 +
134 + # one frame each, and then both machines go quiet for a while
135 + publish("fade-src-b3", [0, 255, 0])
136 + first = :sys.get_state(b).last_sent
137 + assert first != nil
138 +
139 + Process.sleep(100)
140 +
141 + # the fade has carried on by itself rather than waiting on a machine
142 + later = :sys.get_state(b).last_sent
143 + assert later - first >= 50
144 + end
145 +
146 + test "a link that was already off has nothing to hand over", ctx do
147 + publish("fade-src-a", [1, 2, 3])
148 + :sys.get_state(ctx.a)
149 +
150 + # the first swap puts a out of the picture
151 + {:ok, b} = start_link_for("fade-b4", "fade-src-b4", ctx.target)
152 + on_exit(fn -> LambentEx.LinkSupervisor.abort_child(b) end)
153 + assert_receive {:fade_frame, "fade-a", 0, [1, 2, 3]}
154 + Process.sleep(250)
155 +
156 + # and the next one is between b and c only. a is still subscribed to its
157 + # own machine and to this device, but it is not on the wire
158 + {:ok, c} = start_link_for("fade-c4", "fade-src-c4", ctx.target)
159 + on_exit(fn -> LambentEx.LinkSupervisor.abort_child(c) end)
160 +
161 + publish("fade-src-a", [9, 9, 9])
162 + refute_receive {:fade_frame, "fade-a", 0, [9, 9, 9]}, 100
163 + refute :sys.get_state(c).fade_from == [9, 9, 9]
164 + end
165 +
166 + test "the newcomer only listens to the link it displaced", ctx do
167 + publish("fade-src-a", [1, 2, 3])
168 + :sys.get_state(ctx.a)
169 +
170 + {:ok, b} = start_link_for("fade-b5", "fade-src-b5", ctx.target)
171 + on_exit(fn -> LambentEx.LinkSupervisor.abort_child(b) end)
172 + assert_receive {:fade_frame, "fade-a", 0, [1, 2, 3]}
173 + :sys.get_state(ctx.a)
174 + assert :sys.get_state(b).fade_src == "fade-a"
175 +
176 + # some other link on this device piping up mid-fade is not what the
177 + # strands are showing, so it is ignored
178 + Phoenix.PubSub.broadcast(
179 + @pubsub,
180 + "links_on_" <> ctx.target,
181 + {:fade_frame, "someone-else", 0, [7, 7, 7]}
182 + )
183 +
184 + assert :sys.get_state(b).fade_from == [1, 2, 3]
185 + end
186 +
187 + test "both sides stop once the fade is over", ctx do
188 + publish("fade-src-a", [1, 2, 3])
189 + :sys.get_state(ctx.a)
190 +
191 + {:ok, b} = start_link_for("fade-b2", "fade-src-b2", ctx.target)
192 + on_exit(fn -> LambentEx.LinkSupervisor.abort_child(b) end)
193 + assert_receive {:fade_frame, "fade-a", 0, [1, 2, 3]}
194 +
195 + Process.sleep(250)
196 +
197 + publish("fade-src-a", [7, 8, 9])
198 + refute_receive {:fade_frame, "fade-a", 0, [7, 8, 9]}, 100
199 +
200 + publish("fade-src-b2", [9, 9, 9])
201 + assert :sys.get_state(b).fade_until == nil
202 + end
203 + end
204 +
205 + defp start_link_for(name, source, target) do
206 + DynamicSupervisor.start_child(
207 + LambentEx.LinkSupervisor,
208 + {LambentEx.Link, [name: name, source: source, target: target, chan: 0, fade: 200]}
209 + )
210 + end
211 +
212 + defp publish(source, frame) do
213 + Phoenix.PubSub.broadcast(@pubsub, "machine-" <> source, {:publish, [frame]})
214 + end
215 +end
added test/lambent_ex_web/live/brightness_test.exs
+39 −0
@@ -0,0 +1,39 @@
1 +defmodule LambentExWeb.BrightnessTest do
2 + use LambentExWeb.ConnCase
3 +
4 + import Phoenix.LiveViewTest
5 +
6 + @pubsub LambentEx.PubSub
7 +
8 + test "a place nobody has clamped yet still renders", %{conn: conn} do
9 + {:ok, live, _html} = live(conn, "/cfg/brightness")
10 +
11 + Phoenix.PubSub.broadcast(
12 + @pubsub,
13 + "scan-82667777",
14 + {:devices_pub,
15 + %{
16 + "8c:fd:49:10:7a:28" => %{
17 + "ip" => {172, 16, 15, 139},
18 + "place" => "Ballpit",
19 + "sox" => 2,
20 + "type" => "7777-flex"
21 + }
22 + }}
23 + )
24 +
25 + html = render(live)
26 +
27 + assert html =~ "Ballpit"
28 + # the row carries the default fade, not a missing key
29 + assert html =~ "TWOMINS"
30 + end
31 +
32 + test "the global clamp is always there, target slider and all", %{conn: conn} do
33 + {:ok, _live, html} = live(conn, "/cfg/brightness")
34 +
35 + assert html =~ "Global"
36 + assert html =~ ~s(name="rung")
37 + assert html =~ "disabled"
38 + end
39 +end

Parents: 5d8a805