Skip to main content

lib/bb/tui.ex

defmodule BB.TUI do
  @moduledoc """
  Terminal-based dashboard for Beam Bots robots.

  BB.TUI provides a TUI interface for monitoring and controlling BB robots —
  safety controls, runtime state, joint positions, event stream, and command
  display — in terminal environments.

  ## Usage

      # Interactive — from IEx when robot is already running
      BB.TUI.run(MyApp.Robot)

      # Supervised — add to the app's supervision tree
      children = [
        {BB.Supervisor, MyApp.Robot},
        {BB.TUI, robot: MyApp.Robot}
      ]

      # Mix task — standalone
      $ mix bb.tui --robot MyApp.Robot

  ## Remote attach (distribution)

  When the robot is running on a different BEAM node — for example a
  Nerves device on the network — pass the `:node` option so the TUI
  renders on the local terminal but pulls all data and dispatches all
  commands across distribution:

      # On the dev node, after Node.connect/1 with the robot node
      BB.TUI.run(MyApp.Robot, node: :"robot@192.168.1.42")

  See `BB.TUI.Robot` for the routing layer that backs this option.

  ## SSH transport

  When the robot runs on a headless device (Nerves board, container, remote
  host), the dashboard can be served over SSH so any SSH client can connect
  without a local Elixir node or distribution setup on the client side:

      # In the robot's supervision tree
      children = [
        {BB.Supervisor, MyApp.Robot},
        {BB.TUI, robot: MyApp.Robot, transport: :ssh, port: 2222,
         auto_host_key: true, auth_methods: ~c"password",
         user_passwords: [{~c"admin", ~c"s3cret"}]}
      ]

  Then from any machine:

      ssh admin@robot.local -p 2222

  Each SSH client gets its own isolated session with independent panel
  selection, scroll positions, and event streams. Multiple operators can
  monitor the same robot simultaneously.

  For Nerves devices already running `nerves_ssh`, plug into the existing
  daemon as a subsystem instead — see `subsystem/1`.

  See `ExRatatui.SSH.Daemon` for the full list of SSH options.

  ## Distributed transport (attach from a connected node)

  As an alternative to the `:node` option — which keeps mount/render local
  and routes data calls through `:rpc` — the TUI app can run _on the robot
  node_ and be attached from any connected BEAM node. This is the
  `ExRatatui.Distributed` transport: the remote node runs the app
  (mount/render/handle_event), and the local node only renders the
  widgets it receives and forwards terminal events back.

  **1. On the robot node**, add the Distributed listener to its
  supervision tree (alongside whatever else the node normally supervises):

      children = [
        {BB.Supervisor, MyApp.Robot},
        ExRatatui.Distributed.Listener
      ]

  **2. From any connected node**, attach:

      iex --name dev@127.0.0.1 --cookie secret -S mix
      iex> Node.connect(:"robot@192.168.1.42")
      iex> ExRatatui.Distributed.attach(:"robot@192.168.1.42", BB.TUI.App,
      ...>   listener: ExRatatui.Distributed.Listener)

  For local experimentation, `Dev.Application` already supervises a
  matching `ExRatatui.Distributed.Listener` wired to `Dev.TestRobot`,
  so two named shells sharing a cookie are enough to exercise the
  full round-trip — see the README's "Testing distribution locally"
  section.

  **`:node` option vs `Distributed.attach/3` — which do I want?**

  | Concern                       | `:node` option      | `Distributed.attach/3` |
  |-------------------------------|---------------------|------------------------|
  | Where app callbacks run       | Local (this) node   | Remote node            |
  | Where robot code is needed    | Both nodes          | Remote node only       |
  | Transport                     | Ad-hoc `:rpc.call`  | Erlang distribution    |
  | Reconnect on remote crash     | Manual              | Monitor-driven cleanup |
  | Good for                      | Dev/ops workstations already running BB.TUI | Thin clients attaching to long-running robots |

  Both require Erlang distribution (same cookie, reachable EPMD/ports).

  ## Runtime inspection and tracing

  The supervising runtime exposes a few debugging hooks — handy when
  something goes wrong inside an SSH session that isn't otherwise observable:

      # Quick headless-or-not check plus dimensions, render count, etc.
      ExRatatui.Runtime.snapshot(pid)

      # Capture the last N state transitions in memory.
      ExRatatui.Runtime.enable_trace(pid, limit: 200)
      ExRatatui.Runtime.trace_events(pid)
      ExRatatui.Runtime.disable_trace(pid)

      # Deterministically drive input in tests (see test/bb/tui/integration_test.exs)
      ExRatatui.Runtime.inject_event(pid, %ExRatatui.Event.Key{code: "tab", kind: "press"})

  See `ExRatatui.Runtime` for the full API.

  ## Telemetry

  Every TUI session emits `:ex_ratatui`-prefixed `:telemetry` events
  (mount, every keyboard/info dispatch, every frame, transport
  connect/disconnect, session lifecycle). Metadata carries
  `:mod` (always `BB.TUI.App` here) and `:transport`, so consumers
  running multiple ex_ratatui apps can filter accordingly. For local
  debugging, attach the default Logger handler:

      BB.TUI.attach_telemetry_logger()
      BB.TUI.detach_telemetry_logger()

  For production observability, attach a custom `:telemetry` handler.
  See `ExRatatui.Telemetry` for the event surface and the README's
  Telemetry section for a Telemetry.Metrics-style wiring example.

  ## Reducer runtime

  `BB.TUI.App` is built on the ExRatatui **reducer runtime**
  (`use ExRatatui.App, runtime: :reducer`). Every keyboard event,
  PubSub message, async result, and subscription tick flows through
  a single `update/2` arrow; pure state transitions live in
  `BB.TUI.State`.

    * `init/1` — validates the robot, subscribes to PubSub, snapshots
      ETS state.
    * `update({:event, ev}, state)` — terminal input.
    * `update({:info, msg}, state)` — PubSub, async results,
      `send_after` deliveries, subscription ticks.
    * `subscriptions/1` — declares the 100ms throbber tick whenever
      the dashboard has something animating; the runtime diffs the
      result so the timer only runs when needed.

  Long-running command execution is owned by the runtime via
  `ExRatatui.Command.async/2`, batched with `Command.send_after/2`
  for the timeout. Both reach the reducer as `{:info, _}` messages.
  Fast, fire-and-forget robot calls (arm / disarm / set_actuator /
  set_parameter / publish) are invoked inline from `update/2`.

  See the README for the full rationale and the cross-references to
  `ExRatatui.Command`, `ExRatatui.Subscription`, and
  `ExRatatui.Runtime`.
  """

  alias BB.TUI.App

  @doc """
  Returns a child specification for supervision trees.

  Accepts all options supported by `start/2` and `start_ssh/2`. When
  `transport: :ssh` is present, the spec starts an SSH daemon instead
  of a local terminal.

  ## Examples

      iex> %{id: BB.TUI, start: {BB.TUI, :start, _}} = BB.TUI.child_spec(robot: MyApp.Robot)

      iex> spec = BB.TUI.child_spec(robot: MyApp.Robot, transport: :ssh, port: 2222)
      iex> spec.id
      BB.TUI

  """
  def child_spec(opts) when is_list(opts) do
    %{
      id: __MODULE__,
      start: {__MODULE__, :start, [opts[:robot], Keyword.delete(opts, :robot)]},
      type: :worker,
      restart: :temporary
    }
  end

  @doc """
  Runs the TUI dashboard interactively, blocking until the user quits.

  Use this from IEx or scripts. For local transport, the terminal is
  taken over for the duration and restored when the TUI exits (press
  `q` to quit). For SSH transport, the daemon runs until the process
  is stopped.

  ## Options

    * `:node` — connected remote node atom. When set, all robot data is
      fetched from that node via `:rpc.call/4` and PubSub messages are
      relayed back to the local TUI. The dev node must be connected to
      the remote node first via `Node.connect/1`.
    * `:transport` — `:local` (default) for the OS terminal, or `:ssh`
      to start an SSH daemon. When `:ssh`, all `ExRatatui.SSH.Daemon`
      options (`:port`, `:system_dir`, etc.) are accepted.
    * `:subscribe_paths` — PubSub paths the dashboard subscribes to,
      overriding the default control-plane set. For example,
      `[[:state_machine], [:command]]` narrows the dashboard to just
      state-machine and command traffic instead of the full firehose.
    * `:renderers` — a `%{path_prefix => module}` map registering consumer
      `BB.TUI.Renderer` implementations. A message whose path matches a
      registered prefix (longest prefix wins) is rendered by the consumer's
      module — the dashboard never inspects the payload itself. Default `%{}`
      (no renderers). See `BB.TUI.Renderer`.
    * `:test_mode` — `{width, height}` tuple for headless testing
      (optional).

  ## Examples

      # Local
      BB.TUI.run(MyApp.Robot)

      # Remote — render here, data from there
      Node.connect(:"robot@192.168.1.42")
      BB.TUI.run(MyApp.Robot, node: :"robot@192.168.1.42")

  """
  @spec run(module(), keyword()) :: :ok | {:error, term()}
  def run(robot, opts \\ []) when is_atom(robot) do
    case start(robot, opts) do
      {:ok, pid} ->
        ref = Process.monitor(pid)

        receive do
          {:DOWN, ^ref, :process, ^pid, _reason} -> :ok
        end

      {:error, _} = error ->
        error
    end
  end

  @doc """
  Starts the TUI dashboard as a linked process.

  When `transport: :ssh` is set in `opts`, starts an SSH daemon that
  serves the dashboard to connecting SSH clients. Otherwise starts a
  local terminal session.

  Use `run/2` for interactive use from IEx. Use `start/2` or the
  child spec when adding to a supervision tree.

  ## Options

    * `:node` — connected remote node atom (see `run/2`).
    * `:transport` — `:local` (default) or `:ssh`. When `:ssh`, all
      `ExRatatui.SSH.Daemon` options are accepted (`:port`,
      `:system_dir`, `:auto_host_key`, etc.).
    * `:subscribe_paths` — PubSub paths the dashboard subscribes to,
      overriding the default control-plane set (see `run/2`).
    * `:renderers` — consumer `BB.TUI.Renderer` map (see `run/2`).
    * `:test_mode` — `{width, height}` tuple for headless testing
      (optional).

  ## Examples

      # Local terminal
      BB.TUI.start(MyApp.Robot)

      # SSH daemon on port 2222
      BB.TUI.start(MyApp.Robot, transport: :ssh, port: 2222, auto_host_key: true)

  """
  @spec start(module(), keyword()) :: {:ok, pid()} | {:error, term()}
  def start(robot, opts \\ []) when is_atom(robot) do
    case Keyword.get(opts, :transport) do
      :ssh ->
        opts
        |> wrap_app_opts(robot)
        |> App.start_link()

      _ ->
        App.start_link(Keyword.put(opts, :robot, robot))
    end
  end

  @doc """
  Starts the TUI dashboard as an SSH daemon.

  Convenience wrapper around `start/2` that sets `transport: :ssh`
  automatically. Each connecting SSH client gets its own isolated
  dashboard session.

  ## Options

  Accepts all `ExRatatui.SSH.Daemon` options:

    * `:port` — TCP port to listen on (default `2222`).
    * `:auto_host_key` — auto-generate an RSA host key on first boot
      (default `false`).
    * `:system_dir` — host key directory (alternative to
      `:auto_host_key`).
    * `:auth_methods` — e.g. `~c"password"` or `~c"publickey"`.
    * `:user_passwords` — `[{~c"user", ~c"pass"}]` pairs.
    * `:node` — remote BEAM node atom, forwarded to each client's
      `mount/1`.
    * `:subscribe_paths` — PubSub paths the dashboard subscribes to,
      forwarded to each client's `mount/1` (see `run/2`).
    * `:renderers` — consumer `BB.TUI.Renderer` map, forwarded to each
      client's `mount/1` (see `run/2`).

  All other OTP `:ssh.daemon/2` options are forwarded as-is.

  ## Examples

      # Auto-generated host key, password auth
      BB.TUI.start_ssh(MyApp.Robot,
        port: 2222,
        auto_host_key: true,
        auth_methods: ~c"password",
        user_passwords: [{~c"admin", ~c"s3cret"}]
      )

      # In a supervision tree
      children = [
        {BB.Supervisor, MyApp.Robot},
        %{
          id: BB.TUI.SSH,
          start: {BB.TUI, :start_ssh, [MyApp.Robot, [port: 2222, auto_host_key: true]]}
        }
      ]

  """
  @spec start_ssh(module(), keyword()) :: {:ok, pid()} | {:error, term()}
  def start_ssh(robot, opts \\ []) when is_atom(robot) do
    opts
    |> Keyword.put(:transport, :ssh)
    |> wrap_app_opts(robot)
    |> App.start_link()
  end

  @doc """
  Returns a subsystem tuple for plugging into an existing SSH daemon.

  Use this when the robot already runs `nerves_ssh` (or any OTP
  `:ssh.daemon/2`) and the dashboard should be added as an SSH
  subsystem instead of spinning up a separate daemon.

  ## Nerves example

      # config/runtime.exs
      import Config

      if Application.spec(:nerves_ssh) do
        config :nerves_ssh,
          subsystems: [
            :ssh_sftpd.subsystem_spec(cwd: ~c"/"),
            BB.TUI.subsystem(MyApp.Robot)
          ]
      end

  Then connect with:

      ssh -t nerves.local -s Elixir.BB.TUI.App

  The `-t` flag is required — it forces PTY allocation, which the TUI
  needs for interactive input.

  ## Examples

      iex> {name, {mod, args}} = BB.TUI.subsystem(SomeRobot)
      iex> name
      ~c"Elixir.BB.TUI.App"
      iex> mod
      ExRatatui.SSH
      iex> Keyword.fetch!(args, :subsystem)
      true

  """
  @spec subsystem(module()) :: {charlist(), {module(), keyword()}}
  def subsystem(robot) when is_atom(robot) do
    {name, {mod, args}} = ExRatatui.SSH.subsystem(BB.TUI.App)
    args = Keyword.update(args, :app_opts, [robot: robot], &Keyword.put(&1, :robot, robot))
    {name, {mod, args}}
  end

  @doc """
  Attaches a development-time Logger handler to every `:ex_ratatui`
  telemetry event the runtime emits. Convenience delegate to
  `ExRatatui.Telemetry.attach_default_logger/1`.

  ExRatatui exposes spans for `mount`, every `handle_event`/`handle_info`
  dispatch, every frame draw, transport connect/disconnect, and
  session open/close. Every event's metadata carries `:mod` —
  `BB.TUI.App` for any TUI session — so consumers running multiple
  ex_ratatui apps can filter by metadata in their own handlers.

  Pass `level: :info` to bump the verbosity, or `events: [...]` to
  narrow which events the logger picks up. The same `:already_exists`
  return value flows back from `:telemetry.attach_many/4` on a
  second attach.
  """
  @spec attach_telemetry_logger(keyword()) :: :ok | {:error, :already_exists}
  defdelegate attach_telemetry_logger(opts \\ []),
    to: ExRatatui.Telemetry,
    as: :attach_default_logger

  @doc """
  Detaches the Logger handler previously installed by
  `attach_telemetry_logger/1`. Returns `{:error, :not_found}` when no
  handler is attached.
  """
  @spec detach_telemetry_logger() :: :ok | {:error, :not_found}
  defdelegate detach_telemetry_logger,
    to: ExRatatui.Telemetry,
    as: :detach_default_logger

  # Moves :robot, :node, :subscribe_paths and :renderers into :app_opts so they
  # reach each SSH client's mount/1 via the daemon. The daemon passes :app_opts
  # to every spawned channel's Server, which forwards them to mount/1. App-level
  # options must travel through this channel rather than staying at the top
  # level, where they'd be handed to the SSH daemon as daemon options.
  defp wrap_app_opts(opts, robot) do
    {node, opts} = Keyword.pop(opts, :node)
    {subscribe_paths, opts} = Keyword.pop(opts, :subscribe_paths)
    {renderers, opts} = Keyword.pop(opts, :renderers)

    app_opts =
      Keyword.get(opts, :app_opts, [])
      |> Keyword.put(:robot, robot)
      |> maybe_put(:node, node)
      |> maybe_put(:subscribe_paths, subscribe_paths)
      |> maybe_put(:renderers, renderers)

    Keyword.put(opts, :app_opts, app_opts)
  end

  defp maybe_put(opts, _key, nil), do: opts
  defp maybe_put(opts, key, value), do: Keyword.put(opts, key, value)
end