Schedule links and brightness from the cronos form

5d8a805 · Gabriel Morell · 2026-09-16 21:58

22 files +584 -94
Message
{commit_body(@commit)}

Files changed

modified config/runtime.exs
+11 −4
@@ -19,10 +19,17 @@ import Config
19 19 # where the dets metadata table is kept — device names/places/order, and the
20 20 # persisted machines and links. in the container this wants to be the mounted
21 21 # volume, everything else keeps it next to the project.
22 −config :lambent_ex,
23 − :meta_dir,
24 − System.get_env("LEX_META_DIR") ||
25 − if(config_env() == :prod, do: "/opt/app/meta", else: ".")
22 +meta_dir =
23 + System.get_env("LEX_META_DIR") ||
24 + case config_env() do
25 + :prod -> "/opt/app/meta"
26 + # never the project root, a test run would write into whatever the dev
27 + # instance on this machine is keeping there
28 + :test -> Path.join(System.tmp_dir!(), "lambent_ex_test_meta")
29 + _otherwise -> "."
30 + end
31 +
32 +config :lambent_ex, :meta_dir, meta_dir
26 33
27 34 if System.get_env("PHX_SERVER") do
28 35 config :lambent_ex, LambentExWeb.Endpoint,
modified lib/lambent_ex/cronos.ex
+41 −1
@@ -10,6 +10,8 @@ defmodule LambentEx.Cronos do
10 10 )
11 11
12 12 Process.send_after(self(), :work, :timer.seconds(60 - DateTime.utc_now().second + 1))
13 + # once the saved links have had a chance to come back up
14 + Process.send_after(self(), :pin_links, :timer.seconds(30))
13 15 {zone, result} = System.cmd("date", ["+%Z"])
14 16 zone = zone |> String.trim()
15 17
@@ -38,10 +40,37 @@ defmodule LambentEx.Cronos do
38 40 {:ok, now} = DateTime.now(state[:tz])
39 41
40 42 cronos
41 − |> Enum.map(fn {key, value} -> value end)
43 + |> Enum.map(fn {key, value} -> value |> Map.put(:name, key) end)
42 44 |> Enum.filter(fn val -> val[:time].minute == now.minute and val[:time].hour == now.hour end)
43 45 end
44 46
47 + defp key_of(entry), do: entry[:name] || "a schedule"
48 +
49 + @link_actions [:link_on, :link_off, :link_toggle]
50 +
51 + @doc """
52 + Every link a schedule names, so it can be pinned. A link that is not
53 + persistent would be gone after the next restart and its schedule would be
54 + firing into nothing.
55 + """
56 + def scheduled_links(cronos) do
57 + cronos
58 + |> Enum.filter(fn {_key, cfg} -> cfg[:action] in @link_actions end)
59 + |> Enum.flat_map(fn {_key, cfg} -> cfg[:targets] || [] end)
60 + |> Enum.uniq()
61 + end
62 +
63 + def handle_info(:pin_links, state) do
64 + links = LambentEx.Meta.get_cronos() |> scheduled_links()
65 +
66 + if links != [] do
67 + Logger.info("#{@s} Pinning #{length(links)} link(s) a schedule depends on")
68 + links |> Enum.each(fn link -> LambentEx.Link.set_persist(link, true) end)
69 + end
70 +
71 + {:noreply, state}
72 + end
73 +
45 74 def handle_info(:work, state) do
46 75 Process.send_after(self(), :work, :timer.minutes(1))
47 76 curr = LambentEx.Meta.get_cronos() |> group(state)
@@ -93,8 +122,19 @@ defmodule LambentEx.Cronos do
93 122 i[:targets] |> Enum.map(fn t -> LambentEx.Brightness.set_place(t, otherwise) end)
94 123 end
95 124
125 + :link_on ->
126 + i[:targets] |> Enum.map(&LambentEx.Link.enable/1)
127 +
128 + :link_off ->
129 + i[:targets] |> Enum.map(&LambentEx.Link.disable/1)
130 +
96 131 :link_toggle ->
97 132 i[:targets] |> Enum.map(&LambentEx.Link.toggle/1)
133 +
134 + # an entry saved by a version that knew an action this one does not
135 + # should not take the whole scheduler down with it
136 + otherwise ->
137 + Logger.warning("#{@s} Skipping #{key_of(i)}, nothing here does #{inspect(otherwise)}")
98 138 end
99 139 end
100 140
modified lib/lambent_ex/link.ex
+34 −1
@@ -58,10 +58,24 @@ defmodule LambentEx.Link do
58 58 via_tuple("link-#{id}") |> GenServer.cast(:do_toggle)
59 59 end
60 60
61 + # cronos wants to say which way a link ends up, not just flip it
62 + def enable(id) do
63 + via_tuple("link-#{id}") |> GenServer.cast(:enable)
64 + end
65 +
66 + def disable(id) do
67 + via_tuple("link-#{id}") |> GenServer.cast(:disable)
68 + end
69 +
61 70 def persist(id) do
62 71 via_tuple("link-#{id}") |> GenServer.cast(:persist)
63 72 end
64 73
74 + # persist/1 flips; this one says which way, for callers that need it pinned
75 + def set_persist(id, val) when is_boolean(val) do
76 + via_tuple("link-#{id}") |> GenServer.cast({:set_persist, val})
77 + end
78 +
65 79 def quit(id) do
66 80 via_tuple("link-#{id}") |> GenServer.cast(:quit)
67 81 end
@@ -152,8 +166,22 @@ defmodule LambentEx.Link do
152 166 end
153 167 end
154 168
155 − @impl
169 + @impl true
170 + def handle_cast(:enable, state) do
171 + neighbor_on(state)
172 + {:noreply, %{state | enabled: true}}
173 + end
174 +
175 + @impl true
176 + def handle_cast(:disable, state) do
177 + {:noreply, %{state | enabled: false}}
178 + end
179 +
180 + @impl true
156 181 def handle_cast(:quit, state) do
182 + # a schedule pointed at a link that no longer exists would fire into
183 + # nothing every day, so the link takes its cronos entries with it
184 + M.del_cronos_for_link(state[:name])
157 185 LambentEx.LinkSupervisor.abort_child(self())
158 186 {:noreply, state}
159 187 end
@@ -162,4 +190,9 @@ defmodule LambentEx.Link do
162 190 def handle_cast(:persist, state) do
163 191 {:noreply, state |> Map.put(:persist, !state.persist)}
164 192 end
193 +
194 + @impl true
195 + def handle_cast({:set_persist, val}, state) do
196 + {:noreply, state |> Map.put(:persist, val)}
197 + end
165 198 end
modified lib/lambent_ex/meta.ex
+26 −0
@@ -146,6 +146,32 @@ defmodule LambentEx.Meta do
146 146 def del_cronos(key), do: del_gen("cronos", key)
147 147 def get_cronos, do: get_gen("cronos")
148 148
149 + # put_gen appends, so saving over a name would leave both entries firing
150 + def upsert_cronos(name, cfg) do
151 + del_cronos(name)
152 + put_cronos(name, cfg)
153 + end
154 +
155 + @link_actions [:link_on, :link_off, :link_toggle]
156 +
157 + @doc """
158 + Drop a link out of every schedule that names it, and drop the entry entirely
159 + once it has nothing left to act on.
160 + """
161 + def del_cronos_for_link(link) do
162 + get_cronos()
163 + |> Enum.each(fn {key, cfg} ->
164 + targets = cfg[:targets] || []
165 +
166 + if cfg[:action] in @link_actions and link in targets do
167 + case targets -- [link] do
168 + [] -> del_cronos(key)
169 + rest -> upsert_cronos(key, cfg |> Map.put(:targets, rest))
170 + end
171 + end
172 + end)
173 + end
174 +
149 175 # init functions
150 176 def get_saved_machines do
151 177 case :dets.lookup(@table, "machs") do
modified lib/lambent_ex/schema/cronos.ex
+179 −6
@@ -1,19 +1,192 @@
1 1 defmodule LambentEx.Schema.Cronos do
2 − @moduledoc false
2 + @moduledoc """
3 + A scheduled action, as the form collects it.
4 +
5 + What goes into the metadata table is the plain map `LambentEx.Cronos` reads
6 + at the top of each minute — this only exists to get one built and validated.
7 + """
3 8
4 9 use Ecto.Schema
5 10 import Ecto.Changeset
6 11
12 + alias LambentEx.Schema.Cronos
13 + alias LambentEx.Meta, as: M
14 + alias LambentEx.Utils.Brightness, as: B
15 +
16 + @actions [
17 + link_on: "Link on",
18 + link_off: "Link off",
19 + link_toggle: "Link toggle",
20 + global_bright: "Global brightness",
21 + place_bright: "Place brightness",
22 + machine_bright: "Machine brightness",
23 + machine_speed: "Machine speed"
24 + ]
25 +
26 + @link_actions [:link_on, :link_off, :link_toggle]
27 + @bright_actions [:global_bright, :place_bright, :machine_bright]
28 + # the one action that acts on everything at once and so takes no targets
29 + @untargeted [:global_bright]
30 +
31 + @speeds [
32 + ONES: 1000,
33 + TWOS: 2000,
34 + FIVES: 5000,
35 + TENS: 10000,
36 + TWENTYS: 20000,
37 + THIRTYS: 30000,
38 + MINS: 60000
39 + ]
40 +
7 41 embedded_schema do
8 42 field(:name, :string)
43 + field(:time, :time)
44 + field(:action, Ecto.Enum, values: Keyword.keys(@actions), default: :link_on)
45 + # what the action does when it fires: a brightness, a speed, or a
46 + # one-step brighter/dimmer. links have nothing to say here
47 + field(:arg, :string)
48 + field(:targets, {:array, :string}, default: [])
49 + end
9 50
10 − field(:action, :string)
11 − field(:targets, {:array, :string})
51 + def change(%Cronos{} = entry, attrs \\ %{}) do
52 + changeset(entry, attrs)
12 53 end
13 54
14 55 def changeset(source, params) do
15 56 source
16 − |> cast(params, ~w(name action targets)a)
17 − |> validate_required([:name, :action, :targets])
57 + |> cast(params, ~w(name time action arg targets)a)
58 + |> validate_required([:name, :time, :action])
59 + |> validate_targets()
60 + |> validate_arg()
61 + end
62 +
63 + defp validate_targets(changeset) do
64 + targeted = get_field(changeset, :action) not in @untargeted
65 +
66 + case {targeted, get_field(changeset, :targets)} do
67 + {true, nil} -> add_error(changeset, :targets, "pick at least one")
68 + {true, []} -> add_error(changeset, :targets, "pick at least one")
69 + _otherwise -> changeset
70 + end
71 + end
72 +
73 + defp validate_arg(changeset) do
74 + case get_field(changeset, :action) in @link_actions do
75 + true -> changeset
76 + false -> validate_required(changeset, [:arg])
77 + end
78 + end
79 +
80 + @doc "Save an entry, and keep the links it names alive across a restart."
81 + def save(changeset) do
82 + case apply_action(changeset, :insert) do
83 + {:ok, entry} ->
84 + M.upsert_cronos(entry.name, config(entry))
85 + entry |> targets() |> pin_links(entry.action)
86 + {:ok, entry}
87 +
88 + {:error, changeset} ->
89 + {:error, changeset}
90 + end
91 + end
92 +
93 + # what cronos itself reads
94 + defp config(entry) do
95 + %{
96 + time: entry.time,
97 + action: entry.action,
98 + arg: decode_arg(entry.arg),
99 + targets: targets(entry)
100 + }
101 + end
102 +
103 + defp targets(%{action: action}) when action in @untargeted, do: []
104 + defp targets(%{targets: targets}), do: targets || []
105 +
106 + # a schedule is no use if the link it drives is gone after the next restart
107 + defp pin_links(targets, action) when action in @link_actions do
108 + targets |> Enum.each(fn link -> LambentEx.Link.set_persist(link, true) end)
109 + end
110 +
111 + defp pin_links(_targets, _action), do: :ok
112 +
113 + @doc """
114 + The form hands back strings; cronos wants the atoms and integers its own
115 + case statement matches on.
116 + """
117 + def decode_arg(nil), do: nil
118 +
119 + def decode_arg(arg) when is_binary(arg) do
120 + case Integer.parse(arg) do
121 + {val, ""} -> val
122 + _otherwise -> String.to_existing_atom(arg)
123 + end
124 + end
125 +
126 + def decode_arg(arg), do: arg
127 +
128 + # options, for the form
129 +
130 + def actions, do: @actions |> Enum.map(fn {value, label} -> {label, value} end)
131 +
132 + def link_action?(action), do: action in @link_actions
133 +
134 + def bright_action?(action), do: action in @bright_actions
135 +
136 + def targeted?(action), do: action not in @untargeted
137 +
138 + @doc "What the target select offers, which depends on what is being acted on."
139 + def target_options(action, %{links: links, machines: machines, devices: devices}) do
140 + cond do
141 + action in @link_actions -> links |> Map.keys() |> Enum.sort() |> label_each()
142 + action == :place_bright -> devices |> places() |> label_each()
143 + true -> machines |> Map.values() |> Enum.map(fn m -> m[:name] end) |> sorted_labels()
144 + end
145 + end
146 +
147 + defp places(devices) do
148 + devices
149 + |> Map.values()
150 + |> Enum.map(fn d -> d["place"] end)
151 + |> Enum.reject(&is_nil/1)
152 + |> Enum.uniq()
153 + |> Enum.sort()
154 + end
155 +
156 + defp sorted_labels(values), do: values |> Enum.reject(&is_nil/1) |> Enum.sort() |> label_each()
157 +
158 + defp label_each(values), do: values |> Enum.map(fn v -> {v, v} end)
159 +
160 + @doc "What the action does when it fires, for the arg select."
161 + def arg_options(action) do
162 + cond do
163 + action in @link_actions ->
164 + []
165 +
166 + action in @bright_actions ->
167 + [{"One step brighter", "brighter"}, {"One step dimmer", "dimmer"}] ++
168 + (B.steps()
169 + |> Enum.sort_by(fn {val, _name} -> -val end)
170 + |> Enum.map(fn {val, name} -> {"#{name} (#{B.pct(val)}%)", "#{val}"} end))
171 +
172 + true ->
173 + [{"One step faster", "faster"}, {"One step slower", "slower"}] ++
174 + (@speeds |> Enum.map(fn {name, ms} -> {"#{name}", "#{ms}"} end))
175 + end
176 + end
177 +
178 + @doc "A saved entry as a line of text, for the services index."
179 + def describe(cfg) do
180 + action = @actions |> Keyword.get(cfg[:action], "#{cfg[:action]}")
181 + arg = if cfg[:arg], do: " → #{cfg[:arg]}", else: ""
182 +
183 + targets =
184 + case cfg[:targets] do
185 + [] -> ""
186 + nil -> ""
187 + list -> " [#{Enum.join(list, ", ")}]"
188 + end
189 +
190 + "#{action}#{arg}#{targets}"
18 191 end
19 −end
192 +end
modified lib/lambent_ex_web/live/services/cronos_form_component.ex
+48 −21

Click to load diff…

modified lib/lambent_ex_web/live/services/cronos_form_component.html.heex
+40 −35

Click to load diff…

modified lib/lambent_ex_web/live/services/index.ex
+22 −0

Click to load diff…

modified lib/lambent_ex_web/live/services/index.html.heex
+38 −29

Click to load diff…

added test/lambent_ex/cronos_test.exs
+62 −0

Click to load diff…

added test/lambent_ex_web/live/cronos_test.exs
+86 −0

Click to load diff…

Parents: 81f5281