6.2 KiB · text History 9c0fc16
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