BB.TUI (BB.TUI v0.1.0)

Copy Markdown View Source

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 optionDistributed.attach/3
Where app callbacks runLocal (this) nodeRemote node
Where robot code is neededBoth nodesRemote node only
TransportAd-hoc :rpc.callErlang distribution
Reconnect on remote crashManualMonitor-driven cleanup
Good forDev/ops workstations already running BB.TUIThin 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.

Summary

Functions

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

Returns a child specification for supervision trees.

Detaches the Logger handler previously installed by attach_telemetry_logger/1. Returns {:error, :not_found} when no handler is attached.

Runs the TUI dashboard interactively, blocking until the user quits.

Starts the TUI dashboard as a linked process.

Starts the TUI dashboard as an SSH daemon.

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

Functions

attach_telemetry_logger(opts \\ [])

@spec attach_telemetry_logger(keyword()) :: :ok | {:error, :already_exists}

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 :modBB.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.

child_spec(opts)

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

detach_telemetry_logger()

@spec detach_telemetry_logger() :: :ok | {:error, :not_found}

Detaches the Logger handler previously installed by attach_telemetry_logger/1. Returns {:error, :not_found} when no handler is attached.

run(robot, opts \\ [])

@spec run(
  module(),
  keyword()
) :: :ok | {:error, term()}

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.
  • :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")

start(robot, opts \\ [])

@spec start(
  module(),
  keyword()
) :: {:ok, pid()} | {:error, term()}

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.).
  • :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)

start_ssh(robot, opts \\ [])

@spec start_ssh(
  module(),
  keyword()
) :: {:ok, pid()} | {:error, term()}

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.

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]]}
  }
]

subsystem(robot)

@spec subsystem(module()) :: {charlist(), {module(), keyword()}}

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