defmodule LambentExWeb.MachinesChannel do
@moduledoc """
Mirrors `LambentExWeb.MachinesLive.Index`: running machines, their preview
firehose, the speed/brightness/persist/quit controls, and both ways of
starting one (library entry or a full machine definition).
Pushes
* `"machine"` — one machine's published state, keyed by `id`
* `"machine_gone"` — `%{name: name}`, once a machine has been quit
* `"preview"` — `%{name: name, pixels: [[r,g,b], ...]}`, first 32 pixels,
up to ~4Hz per machine. Join with `%{"previews" => false}` to skip it.
* `"types"` — what the new machine form can build and what each type
carries, so a client draws the same fields the web form does without
keeping its own copy of them.
"""
use Phoenix.Channel
alias LambentEx.Machines.Preview
alias LambentEx.Schema.Machines
alias LambentExWeb.Channels.Payload
@pubsub LambentEx.PubSub
@impl true
def join("machines", params, socket) do
Phoenix.PubSub.subscribe(@pubsub, "machines_idx")
Phoenix.PubSub.subscribe(@pubsub, LambentEx.Machine.gone_topic())
previews = Map.get(params, "previews", true)
if previews, do: Phoenix.PubSub.subscribe(@pubsub, "machine:")
send(self(), :after_join)
{:ok, socket}
end
@impl true
def handle_info(:after_join, socket) do
push(socket, "library", %{library: library()})
push(socket, "types", %{types: types()})
{:noreply, socket}
end
@impl true
def handle_info({:machines_pub, machine}, socket) do
push(socket, "machine", Payload.wire(machine))
{:noreply, socket}
end
@impl true
def handle_info({:machines_gone, name}, socket) do
push(socket, "machine_gone", %{name: name})
{:noreply, socket}
end
@impl true
def handle_info({:firehose, {name, pixels}}, socket) do
push(socket, "preview", %{name: name, pixels: Payload.wire(pixels)})
{:noreply, socket}
end
@impl true
def handle_in(cmd, %{"id" => id}, socket)
when cmd in ~w(faster slower brighter dimmer persist quit) do
apply(LambentEx.Machine, String.to_existing_atom(cmd), [id])
{:reply, :ok, socket}
end
# pushed on join too, so a client can just listen rather than ask
@impl true
def handle_in("library", _params, socket) do
lib = library()
push(socket, "library", %{library: lib})
{:reply, {:ok, %{library: lib}}, socket}
end
@impl true
def handle_in("library_add", %{"cls" => cls, "entry" => entry}, socket) do
spec =
LambentEx.Machines.Library.machines()
|> get_in([safe_atom(cls), safe_atom(entry)])
case spec do
nil ->
{:reply, {:error, %{reason: "no library entry #{cls}/#{entry}"}}, socket}
spec ->
LambentEx.MachineSupervisor.start_child(
spec[:machine],
spec[:args],
entry,
spec[:mach_opts] || []
)
{:reply, :ok, socket}
end
end
@impl true
def handle_in("types", _params, socket) do
types = types()
push(socket, "types", %{types: types})
{:reply, {:ok, %{types: types}}, socket}
end
# what a configuration looks like running, before committing to it: a single
# pixel over time, the same thing the web form and the catalog draw
@impl true
def handle_in("preview", %{"machine" => params}, socket) do
changeset = Machines.change_machine(%Machines{}, params)
type = Ecto.Changeset.get_field(changeset, :type)
class = Ecto.Changeset.get_field(changeset, :class)
case {Machines.spec(type), class, changeset.valid?} do
{%{step: step}, class, true} when not is_nil(class) ->
pixels = Preview.strip(step, Machines.args(class))
{:reply, {:ok, %{pixels: Payload.wire(pixels)}}, socket}
_otherwise ->
{:reply, {:ok, %{pixels: []}}, socket}
end
end
# a catalog entry as the form would hold it, for editing rather than
# starting. entries with no form behind them have nothing to hand over.
@impl true
def handle_in("library_edit", %{"cls" => cls, "entry" => entry}, socket) do
case Machines.from_library(cls, entry) do
nil ->
{:reply, {:error, %{reason: "#{cls}/#{entry} has no form behind it"}}, socket}
machine ->
{:reply,
{:ok,
%{
machine: %{
name: machine.name,
type: to_string(machine.type),
class: machine.class |> Map.from_struct() |> Payload.wire()
}
}}, socket}
end
end
# same params the web form posts: %{"name" => _, "type" => _, "class" => %{...}}
@impl true
def handle_in("create", %{"machine" => params}, socket) do
case LambentEx.Schema.Machines.update_machines(%LambentEx.Schema.Machines{}, params) do
{:ok, _machine} -> {:reply, :ok, socket}
{:error, changeset} -> {:reply, {:error, %{errors: errors(changeset)}}, socket}
end
end
defp library, do: Payload.wire(LambentEx.Machines.Library.machines())
# the form metadata, in the order the web form offers it
defp types do
Machines.options()
|> Enum.map(fn {label, type} ->
%{
type: to_string(type),
label: label,
fields:
Machines.fields(type)
|> Enum.map(fn field ->
%{
name: to_string(field.name),
kind: to_string(field.kind),
label: field.label,
required: field.required,
default: Map.get(struct(Machines.spec(type).schema), field.name),
options: options(field.kind)
}
end)
}
end)
end
# the kinds that pick from a list the server holds. a band's value is its
# position in the list
defp options(:broker), do: LambentEx.MQTTSupervisor.names()
defp options(:band), do: Machines.band_options() |> Enum.map(fn {label, _idx} -> label end)
defp options(_kind), do: nil
# the library is keyed by atoms, and a client should not be able to grow the
# atom table by asking for entries that do not exist
defp safe_atom(str) do
String.to_existing_atom(str)
rescue
ArgumentError -> nil
end
defp errors(changeset) do
Ecto.Changeset.traverse_errors(changeset, fn {msg, opts} ->
Regex.replace(~r"%{(\w+)}", msg, fn _, key ->
opts |> Keyword.get(String.to_existing_atom(key), "") |> to_string()
end)
end)
end
end
neiam
/lambent_ex
Lambent Aether 5
public · Issues · Pulls 1 · Labels · Forks · Compare · Actions success · Badges · Packages
⭐
Log in to mark this repository.
6.2 KiB · text
History
9c0fc16