neiam
/lambent_ex
Lambent Aether 5
public · Issues · Pulls 1 · Labels · Forks · Compare · Actions success · Badges · Packages
⭐
Log in to mark this repository.
Crossfade a link onto strands someone else is driving
5e87a85 · Gabriel Morell · 2026-09-16 22:49
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