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.RobotRemote 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 2222Each 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_afterdeliveries, 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
@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 :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.
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
@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.
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/4and PubSub messages are relayed back to the local TUI. The dev node must be connected to the remote node first viaNode.connect/1.:transport—:local(default) for the OS terminal, or:sshto start an SSH daemon. When:ssh, allExRatatui.SSH.Daemonoptions (: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")
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 (seerun/2).:transport—:local(default) or:ssh. When:ssh, allExRatatui.SSH.Daemonoptions 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)
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 (default2222).:auto_host_key— auto-generate an RSA host key on first boot (defaultfalse).: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'smount/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]]}
}
]
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)
]
endThen connect with:
ssh -t nerves.local -s Elixir.BB.TUI.AppThe -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