Clamp machine output globally and per place

81f5281 · Gabriel Morell · 2026-09-16 21:19

20 files +759 -46
Message
{commit_body(@commit)}

Files changed

added lib/lambent_ex/brightness.ex
+255 −0
@@ -0,0 +1,255 @@
1 +defmodule LambentEx.Brightness do
2 + @moduledoc """
3 + The global and per-place brightness clamps.
4 +
5 + Both are ceilings rather than levels: a machine's own brightness walk still
6 + runs, and what it produces is then scaled by the global clamp on its way out
7 + of the machine, and by the target device's place clamp on its way through the
8 + link. Everything sits at 255 (unclamped) unless something moves it.
9 +
10 + Targets walk down to their current value the way a machine's brightness does,
11 + one step at a time on this server's own clock, so a clamp change fades rather
12 + than snapping.
13 +
14 + Current values live in a public ETS table so the hot paths — every machine
15 + step, every link publish — read them without a call into this process.
16 + """
17 + use GenServer
18 + require Logger
19 +
20 + alias LambentEx.Utils.Brightness, as: B
21 + alias LambentEx.Meta, as: M
22 +
23 + @table :lambent_brightness
24 + @pubsub_name LambentEx.PubSub
25 + @pubsub_topic "brightness"
26 +
27 + # how often a walk moves. how far it moves per tick comes from that clamp's
28 + # own fade rate, so this is just the resolution of the fade
29 + @tick 20
30 + @publish 400
31 +
32 + @s "🔆"
33 +
34 + def start_link(arg) do
35 + GenServer.start_link(__MODULE__, arg, name: __MODULE__)
36 + end
37 +
38 + @impl true
39 + def init(_arg) do
40 + table = :ets.new(@table, [:set, :named_table, :protected, read_concurrency: true])
41 +
42 + saved = M.get_brightness()
43 +
44 + global = saved |> Map.get(:global) |> restore()
45 +
46 + places =
47 + saved
48 + |> Map.get(:places, %{})
49 + |> Enum.map(fn {place, val} -> {place, restore(val)} end)
50 + |> Map.new()
51 +
52 + # restored clamps start where they left off rather than fading in from full
53 + :ets.insert(table, {:global, global.curr})
54 + places |> Enum.each(fn {place, w} -> :ets.insert(table, {{:place, place}, w.curr}) end)
55 +
56 + if global.curr != B.full() or places != %{} do
57 + Logger.info(
58 + "#{@s} Restored a global clamp of #{global.curr} and #{map_size(places)} place(s)"
59 + )
60 + end
61 +
62 + Process.send_after(self(), :tick, @tick)
63 + Process.send_after(self(), :publish, @publish)
64 +
65 + {:ok, %{table: table, global: global, places: places}}
66 + end
67 +
68 + # acc carries the fraction of a step a slow fade did not get to move this
69 + # tick, so a five minute walk still moves rather than rounding to nothing
70 + defp walk(val, rate \\ B.default_fade()),
71 + do: %{tgt: val, curr: val, rate: B.fade_clamp(rate), acc: 0.0}
72 +
73 + # a saved clamp was a bare target before fade rates existed
74 + defp restore(nil), do: walk(B.full())
75 + defp restore(val) when is_integer(val), do: walk(B.clamp(val))
76 +
77 + defp restore(%{} = saved),
78 + do: walk(B.clamp(Map.get(saved, :tgt)), Map.get(saved, :rate, B.default_fade()))
79 +
80 + # reads -- straight off the table, no call into the server
81 +
82 + @doc "The global clamp, 0..255. Full brightness before the server is up."
83 + def global do
84 + read(:global)
85 + end
86 +
87 + @doc "The clamp for a place. Devices without a place are never clamped."
88 + def place(nil), do: B.full()
89 + def place(place), do: read({:place, place})
90 +
91 + defp read(key) do
92 + case :ets.whereis(@table) do
93 + :undefined ->
94 + B.full()
95 +
96 + tid ->
97 + case :ets.lookup(tid, key) do
98 + [{^key, val}] -> val
99 + [] -> B.full()
100 + end
101 + end
102 + end
103 +
104 + # writes
105 +
106 + def set_global(val), do: GenServer.cast(__MODULE__, {:set_global, val})
107 + def brighter, do: GenServer.cast(__MODULE__, {:step_global, :up})
108 + def dimmer, do: GenServer.cast(__MODULE__, {:step_global, :down})
109 +
110 + def set_place(place, val), do: GenServer.cast(__MODULE__, {:set_place, place, val})
111 +
112 + @doc "How long a full 0..255 walk takes for this clamp, in ms. 0 snaps."
113 + def set_global_rate(rate), do: GenServer.cast(__MODULE__, {:set_global_rate, rate})
114 +
115 + def set_place_rate(place, rate),
116 + do: GenServer.cast(__MODULE__, {:set_place_rate, place, rate})
117 +
118 + def brighter(place), do: GenServer.cast(__MODULE__, {:step_place, place, :up})
119 + def dimmer(place), do: GenServer.cast(__MODULE__, {:step_place, place, :down})
120 +
121 + @doc "Drop a place's clamp entirely, which is the same as leaving it at full."
122 + def clear_place(place), do: GenServer.cast(__MODULE__, {:clear_place, place})
123 +
124 + @doc "Everything at once, for a view that has just mounted."
125 + def snapshot, do: GenServer.call(__MODULE__, :snapshot)
126 +
127 + # impls
128 +
129 + @impl true
130 + def handle_cast({:set_global, val}, state) do
131 + {:noreply, state |> put_in([:global, :tgt], B.clamp(val)) |> flush()}
132 + end
133 +
134 + @impl true
135 + def handle_cast({:step_global, dir}, state) do
136 + {:noreply, state |> put_in([:global, :tgt], rung(dir, state.global.tgt)) |> flush()}
137 + end
138 +
139 + @impl true
140 + def handle_cast({:set_global_rate, rate}, state) do
141 + {:noreply, state |> put_in([:global, :rate], B.fade_clamp(rate)) |> flush()}
142 + end
143 +
144 + @impl true
145 + def handle_cast({:set_place_rate, place, rate}, state) do
146 + w = state.places |> Map.get(place, walk(B.full())) |> Map.put(:rate, B.fade_clamp(rate))
147 + {:noreply, %{state | places: Map.put(state.places, place, w)} |> flush()}
148 + end
149 +
150 + @impl true
151 + def handle_cast({:set_place, place, val}, state) do
152 + {:noreply, state |> put_place(place, B.clamp(val)) |> flush()}
153 + end
154 +
155 + @impl true
156 + def handle_cast({:step_place, place, dir}, state) do
157 + curr = state.places |> Map.get(place, walk(B.full()))
158 + {:noreply, state |> put_place(place, rung(dir, curr.tgt)) |> flush()}
159 + end
160 +
161 + @impl true
162 + def handle_cast({:clear_place, place}, state) do
163 + :ets.delete(state.table, {:place, place})
164 + {:noreply, %{state | places: Map.delete(state.places, place)} |> flush()}
165 + end
166 +
167 + @impl true
168 + def handle_cast(_msg, state), do: {:noreply, state}
169 +
170 + @impl true
171 + def handle_call(:snapshot, _from, state) do
172 + {:reply, publish(state), state}
173 + end
174 +
175 + @impl true
176 + def handle_call(_msg, _from, state), do: {:reply, :ok, state}
177 +
178 + defp rung(:up, val), do: B.up(val)
179 + defp rung(:down, val), do: B.down(val)
180 +
181 + # a place picks up a walk the first time it is clamped, starting from full
182 + defp put_place(state, place, tgt) do
183 + w = state.places |> Map.get(place, walk(B.full())) |> Map.put(:tgt, tgt)
184 + %{state | places: Map.put(state.places, place, w)}
185 + end
186 +
187 + @impl true
188 + def handle_info(:tick, state) do
189 + Process.send_after(self(), :tick, @tick)
190 +
191 + global = step(state.global)
192 + if global.curr != state.global.curr, do: :ets.insert(state.table, {:global, global.curr})
193 +
194 + places =
195 + state.places
196 + |> Enum.map(fn {place, w} ->
197 + stepped = step(w)
198 +
199 + if stepped.curr != w.curr,
200 + do: :ets.insert(state.table, {{:place, place}, stepped.curr})
201 +
202 + {place, stepped}
203 + end)
204 + |> Map.new()
205 +
206 + {:noreply, %{state | global: global, places: places}}
207 + end
208 +
209 + @impl true
210 + def handle_info(:publish, state) do
211 + Process.send_after(self(), :publish, @publish)
212 + Phoenix.PubSub.broadcast(@pubsub_name, @pubsub_topic, {:brightness_pub, publish(state)})
213 + {:noreply, state}
214 + end
215 +
216 + @impl true
217 + def handle_info(_msg, state), do: {:noreply, state}
218 +
219 + defp step(%{tgt: tgt, curr: curr} = w) when tgt == curr, do: %{w | acc: 0.0}
220 +
221 + # a zero fade is a snap, and skips the accumulator entirely
222 + defp step(%{rate: 0} = w), do: %{w | curr: w.tgt, acc: 0.0}
223 +
224 + defp step(w) do
225 + acc = w.acc + @tick * B.full() / w.rate
226 + units = trunc(acc)
227 + %{w | curr: toward(w.curr, w.tgt, units), acc: acc - units}
228 + end
229 +
230 + defp toward(curr, tgt, units) when curr < tgt, do: min(curr + units, tgt)
231 + defp toward(curr, tgt, units), do: max(curr - units, tgt)
232 +
233 + defp publish(state) do
234 + %{
235 + global: state.global |> shown(),
236 + places: state.places |> Enum.map(fn {k, w} -> {k, shown(w)} end) |> Map.new()
237 + }
238 + end
239 +
240 + defp shown(w), do: %{tgt: w.tgt, curr: w.curr, rate: w.rate}
241 +
242 + # only the targets are worth keeping -- a restart that lands mid-walk should
243 + # come up where the clamp was headed, not where it happened to be
244 + defp flush(state) do
245 + M.put_brightness(%{
246 + global: %{tgt: state.global.tgt, rate: state.global.rate},
247 + places:
248 + state.places
249 + |> Enum.map(fn {k, w} -> {k, %{tgt: w.tgt, rate: w.rate}} end)
250 + |> Map.new()
251 + })
252 +
253 + state
254 + end
255 +end
modified lib/lambent_ex/cronos.ex
+33 −6
@@ -11,15 +11,18 @@ defmodule LambentEx.Cronos do
11 11
12 12 Process.send_after(self(), :work, :timer.seconds(60 - DateTime.utc_now().second + 1))
13 13 {zone, result} = System.cmd("date", ["+%Z"])
14 − zone = zone |> String.trim
15 − zone = case zone do
16 − "EDT" -> "America/New_York"
17 − _otherwise -> zone
18 − end
14 + zone = zone |> String.trim()
15 +
16 + zone =
17 + case zone do
18 + "EDT" -> "America/New_York"
19 + _otherwise -> zone
20 + end
19 21
20 22 Logger.info(
21 − "#{@s} Cronos uses the system timezone to determine when to tick and when to sleep, we've found this to be '#{zone}'"
23 + "#{@s} Cronos uses the system timezone to determine when to tick and when to sleep, we've found this to be '#{zone}'"
22 24 )
25 +
23 26 if result == 0 do
24 27 {:ok, %{tz: zone |> String.trim()}}
25 28 else
@@ -69,6 +72,27 @@ defmodule LambentEx.Cronos do
69 72 i[:targets] |> Enum.map(fn t -> LambentEx.Machine.set_brightness(t, otherwise) end)
70 73 end
71 74
75 + # the global clamp has no targets, it is the one ceiling over everything
76 + :global_bright ->
77 + case i[:arg] do
78 + :dimmer -> LambentEx.Brightness.dimmer()
79 + :brighter -> LambentEx.Brightness.brighter()
80 + otherwise -> LambentEx.Brightness.set_global(otherwise)
81 + end
82 +
83 + # targets here are places, not machines
84 + :place_bright ->
85 + case i[:arg] do
86 + :dimmer ->
87 + i[:targets] |> Enum.map(&LambentEx.Brightness.dimmer/1)
88 +
89 + :brighter ->
90 + i[:targets] |> Enum.map(&LambentEx.Brightness.brighter/1)
91 +
92 + otherwise ->
93 + i[:targets] |> Enum.map(fn t -> LambentEx.Brightness.set_place(t, otherwise) end)
94 + end
95 +
72 96 :link_toggle ->
73 97 i[:targets] |> Enum.map(&LambentEx.Link.toggle/1)
74 98 end
@@ -83,3 +107,6 @@ end
83 107 # LambentEx.Meta.put_cronos("testb", %{time: ~T[23:11:00], action: :machine_speed, arg: :faster, targets: ["red"]})
84 108 # LambentEx.Meta.put_cronos("testc", %{time: ~T[23:10:00], action: :machine_bright, arg: :dimmer, targets: ["red"]})
85 109 # LambentEx.Meta.put_cronos("testd", %{time: ~T[23:12:00], action: :machine_bright, arg: :brighter, targets: ["red"]})
110 +# LambentEx.Meta.put_cronos("teste", %{time: ~T[23:00:00], action: :global_bright, arg: 63, targets: []})
111 +# LambentEx.Meta.put_cronos("testf", %{time: ~T[07:00:00], action: :global_bright, arg: 255, targets: []})
112 +# LambentEx.Meta.put_cronos("testg", %{time: ~T[22:00:00], action: :place_bright, arg: :dimmer, targets: ["bedroom"]})
modified lib/lambent_ex/link.ex
+11 −2
@@ -9,6 +9,10 @@ defmodule LambentEx.Link do
9 9
10 10 @registry :lambent_links
11 11
12 + alias LambentEx.Brightness
13 + alias LambentEx.Meta, as: M
14 + alias LambentEx.Utils.Brightness, as: B
15 +
12 16 def start_link(_args, opts) do
13 17 GenServer.start_link(__MODULE__, opts, name: via_tuple("link-#{opts[:name]}"))
14 18 end
@@ -26,6 +30,9 @@ defmodule LambentEx.Link do
26 30 name: opts[:name],
27 31 chan: opts[:chan],
28 32 enabled: true,
33 + # the target device's place, for the place clamp. refreshed on the
34 + # publish tick so a device moving rooms does not need a link restart
35 + place: M.get_place(opts[:target]),
29 36 started: DateTime.utc_now(),
30 37 persist: opts[:persist] || false,
31 38 full_opts: opts
@@ -90,9 +97,11 @@ defmodule LambentEx.Link do
90 97 def handle_info({:publish, data}, state) do
91 98 case state[:enabled] do
92 99 true ->
100 + # the machine has already applied its own brightness and the global
101 + # clamp; the place clamp is the last thing between here and the wire
93 102 LambentEx.Scan.ESP8266x7777.submit(
94 103 state[:target],
95 − data |> List.flatten(),
104 + data |> List.flatten() |> B.scale(Brightness.place(state[:place])),
96 105 state[:chan]
97 106 )
98 107
@@ -107,7 +116,7 @@ defmodule LambentEx.Link do
107 116 def handle_info(:publish, state) do
108 117 Process.send_after(self(), :publish, 400)
109 118 Phoenix.PubSub.broadcast(@pubsub_name, @pubsub_topic_idx, {:links_pub, publish(state)})
110 − {:noreply, state}
119 + {:noreply, %{state | place: M.get_place(state[:target])}}
111 120 end
112 121
113 122 @impl true
modified lib/lambent_ex/machine.ex
+15 −38
@@ -9,6 +9,8 @@ defmodule LambentEx.Machine do
9 9 @pubsub_topic_fh "machine:"
10 10 @pubsub_topic_idx "machines_idx"
11 11
12 + alias LambentEx.Utils.Brightness, as: B
13 +
12 14 @speeds %{
13 15 # 1 => :THOU,
14 16 # 5 => :FTHOU,
@@ -25,19 +27,6 @@ defmodule LambentEx.Machine do
25 27 60000 => :MINS
26 28 }
27 29
28 − @brightness %{
29 − 0 => :OFF,
30 − # 1 => :XDIM,
31 − 3 => :SIXTYFOURTH,
32 − 7 => :THIRTY2ND,
33 − 15 => :SIXTEENTH,
34 − 31 => :EIGHTH,
35 − 63 => :QUARTER,
36 − 127 => :HALF,
37 − # 191 => :THREEFOURTHS
38 − 255 => :FULL
39 − }
40 −
41 30 # check to see if machine-opts[:step]-name is better in machine-name
42 31 def start_link(_foo, opts) do
43 32 Parent.GenServer.start_link(__MODULE__, opts, name: via_tuple("machine-#{opts[:name]}"))
@@ -110,6 +99,8 @@ defmodule LambentEx.Machine do
110 99 speed: opts[:speed],
111 100 bright_tgt: 255,
112 101 bright_curr: 255,
102 + # the global clamp, re-read once per step rather than per pixel
103 + bright_global: 255,
113 104 bright_delay: 0,
114 105 bright_mvmul: 2,
115 106 cnt: cnt,
@@ -176,7 +167,7 @@ defmodule LambentEx.Machine do
176 167 end
177 168
178 169 defp bmath(i, state) do
179 − (i * state.bright_curr / 255.0) |> round
170 + (i * state.bright_curr * state.bright_global / 65_025.0) |> round
180 171 end
181 172
182 173 def stripstep(step) do
@@ -239,6 +230,7 @@ defmodule LambentEx.Machine do
239 230 @impl true
240 231 def handle_info(:step, state) do
241 232 Process.send_after(self(), :step, state.speed)
233 + state = state |> Map.put(:bright_global, LambentEx.Brightness.global())
242 234
243 235 state[:steps]
244 236 |> Enum.map(&GenServer.cast(&1, :step))
@@ -403,28 +395,6 @@ defmodule LambentEx.Machine do
403 395 end
404 396 end
405 397
406 − defp do_brighter(current_val) do
407 − sl = (@brightness |> Map.keys() |> length) - 1
408 −
409 − case @brightness |> Map.keys() |> Enum.find_index(fn x -> x == current_val end) do
410 − ^sl ->
411 − current_val
412 −
413 − otherwise ->
414 − @brightness |> Map.keys() |> Enum.fetch!(otherwise + 1)
415 − end
416 − end
417 −
418 − defp do_dimmer(current_val) do
419 − case @brightness |> Map.keys() |> Enum.find_index(fn x -> x == current_val end) do
420 − 0 ->
421 − current_val
422 −
423 − otherwise ->
424 − @brightness |> Map.keys() |> Enum.fetch!(otherwise - 1)
425 − end
426 − end
427 −
428 398 defp do_bright_step(tgt, curr) do
429 399 if curr < tgt do
430 400 curr + 1
@@ -483,12 +453,19 @@ defmodule LambentEx.Machine do
483 453
484 454 @impl true
485 455 def handle_cast(:brighter, state) do
486 − {:noreply, state |> Map.put(:bright_tgt, do_brighter(state.bright_tgt))}
456 + {:noreply, state |> Map.put(:bright_tgt, B.up(state.bright_tgt))}
487 457 end
488 458
489 459 @impl true
490 460 def handle_cast(:dimmer, state) do
491 − {:noreply, state |> Map.put(:bright_tgt, do_dimmer(state.bright_tgt))}
461 + {:noreply, state |> Map.put(:bright_tgt, B.down(state.bright_tgt))}
462 + end
463 +
464 + # cronos has been able to ask for this since it was written, but the cast
465 + # had no clause and fell through to the catch-all below
466 + @impl true
467 + def handle_cast({:set_brightness, val}, state) do
468 + {:noreply, state |> Map.put(:bright_tgt, B.clamp(val))}
492 469 end
493 470
494 471 @impl true
modified lib/lambent_ex/meta.ex
+16 −0
@@ -87,6 +87,22 @@ defmodule LambentEx.Meta do
87 87 :dets.insert(@table, {"place-#{key}", value})
88 88 end
89 89
90 + # brightness clamps. one row, since it is a single small map and the walk
91 + # targets change together often enough that separate keys buy nothing
92 + def get_brightness do
93 + case :dets.lookup(@table, "brightness") do
94 + [] ->
95 + %{}
96 +
97 + [{_key, value}] ->
98 + value
99 + end
100 + end
101 +
102 + def put_brightness(value) do
103 + :dets.insert(@table, {"brightness", value})
104 + end
105 +
90 106 # svcs
91 107 defp put_gen(type, name, entry) do
92 108 curr = get_gen(type)
modified lib/lambent_ex/supervision/component.ex
+1 −0

Click to load diff…

added lib/lambent_ex/util/brightness.ex
+130 −0

Click to load diff…

modified lib/lambent_ex_web/components/layouts/app.html.heex
+10 −0

Click to load diff…

added lib/lambent_ex_web/live/brightness/index.ex
+185 −0

Click to load diff…

added lib/lambent_ex_web/live/brightness/index.html.heex
+102 −0

Click to load diff…

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

Click to load diff…

Parents: 46b785f