neiam
/lambent_ex
Lambent Aether 5
public · Issues · Pulls 1 · Labels · Forks · Compare · Actions success · Badges · Packages
Log in to mark this repository.
Schedule links and brightness from the cronos form
5d8a805 · Gabriel Morell · 2026-09-16 21:58
Message
{commit_body(@commit)}
Files changed
modified
config/runtime.exs
+11
−4
| 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 | 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 | 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 | 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 | 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 | 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 | 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 | 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 | 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