defmodule BB.TUI.Robot do
@moduledoc """
Routing layer for BB.* calls used by the TUI.
When the TUI is launched against a remote BEAM node (via
`BB.TUI.run(robot, node: :"robot@host")`) all robot data needs to come
from that node — but the rendering, keyboard input and process state
live locally on the developer's machine. This module is the boundary
that decides where each call goes:
* `node == nil` — call the local `BB.*` module directly.
* `node` is a connected remote node atom — call via `BB.TUI.Rpc`,
a thin wrapper over `:rpc.call/4` that exists so the cross-node
paths can be mocked in tests (`:rpc` itself is a sticky kernel
module that cannot be replaced at runtime).
## PubSub across nodes
`BB.PubSub` is built on `Registry`, which is node-local, so we cannot
simply call `BB.subscribe/2` from the dev node and expect to receive
messages published on the robot node. Instead `subscribe/3` spawns a
small relay process on the remote node via `Node.spawn_link/2`. The
relay subscribes locally there and forwards every `{:bb, _, _}`
message back to the TUI process on the dev node.
This is the only "process with a runtime reason" introduced by the
remote path: it exists because we need (1) a place to receive PubSub
messages on the remote node and (2) fault isolation if the remote node
goes away (the link will tear it down on disconnect).
"""
alias BB.Dsl.Info
alias BB.Robot.Runtime
alias BB.TUI.Rpc
@typedoc "Either nil for local execution or a connected remote node."
@type maybe_node :: node() | nil
# ── PubSub ─────────────────────────────────────────────────
@doc """
Subscribes to one or more PubSub paths for the given robot.
Local node — calls `BB.subscribe/2` for each path directly so messages
arrive at `self()`.
Remote node — spawns a relay process on the remote node that subscribes
there and forwards every `{:bb, _, _}` message back to `self()`.
"""
@spec subscribe(module(), [list()], maybe_node()) :: :ok
def subscribe(robot, paths, nil) when is_list(paths) do
Enum.each(paths, &BB.subscribe(robot, &1))
:ok
end
def subscribe(robot, paths, node) when is_list(paths) and is_atom(node) do
parent = self()
Rpc.spawn_link(node, fn ->
Enum.each(paths, &BB.subscribe(robot, &1))
relay_loop(parent)
end)
:ok
end
defp relay_loop(parent) do
receive do
{:bb, _, _} = msg ->
send(parent, msg)
relay_loop(parent)
_ ->
relay_loop(parent)
end
end
# ── Read calls ─────────────────────────────────────────────
@doc "Returns the safety state of the robot."
@spec safety_state(module(), maybe_node()) :: atom()
def safety_state(robot, nil), do: BB.Safety.state(robot)
def safety_state(robot, node), do: rpc(node, BB.Safety, :state, [robot])
@doc "Returns the runtime state machine state."
@spec runtime_state(module(), maybe_node()) :: atom()
def runtime_state(robot, nil), do: Runtime.state(robot)
def runtime_state(robot, node), do: rpc(node, BB.Robot.Runtime, :state, [robot])
@doc "Returns the runtime robot struct (joints, actuators, etc.)."
@spec get_robot(module(), maybe_node()) :: term()
def get_robot(robot, nil), do: Runtime.get_robot(robot)
def get_robot(robot, node), do: rpc(node, BB.Robot.Runtime, :get_robot, [robot])
@doc "Returns the latest joint positions known by the runtime."
@spec positions(module(), maybe_node()) :: %{atom() => float()}
def positions(robot, nil), do: Runtime.positions(robot)
def positions(robot, node), do: rpc(node, BB.Robot.Runtime, :positions, [robot])
@doc "Returns the parameter list (with metadata maps) for the robot."
@spec list_parameters(module(), keyword(), maybe_node()) :: [{list(), term()}]
def list_parameters(robot, opts, nil), do: BB.Parameter.list(robot, opts)
def list_parameters(robot, opts, node), do: rpc(node, BB.Parameter, :list, [robot, opts])
@doc """
Returns the list of declared parameter bridges for the robot.
Each bridge is rendered down to `%{name: atom(), simulation: atom()}` for
the UI; the underlying `BB.Dsl.Bridge` struct is not exposed so callers
don't depend on Spark internals. Bridges where `:simulation` is `:omit`
while the robot is in simulation mode are filtered out (matching
`bb_liveview`'s discovery rules).
Returns `[]` when the DSL is unavailable or raises.
"""
@spec list_bridges(module(), maybe_node()) :: [map()]
def list_bridges(robot, nil) do
if Code.ensure_loaded?(Info) and function_exported?(Info, :parameters, 1) do
sim_mode = local_simulation_mode(robot)
robot
|> Info.parameters()
|> filter_bridges(sim_mode)
else
[]
end
rescue
_ -> []
end
def list_bridges(robot, node) do
sim_mode = remote_simulation_mode(robot, node)
case Rpc.call(node, BB.Dsl.Info, :parameters, [robot]) do
{:badrpc, _} -> []
result when is_list(result) -> filter_bridges(result, sim_mode)
_ -> []
end
rescue
_ -> []
end
@doc """
Lists parameters exposed by a remote bridge.
Returns the bridge's flat parameter list (each entry a map carrying
`:id`, `:value`, `:type`, optionally `:min`, `:max`, `:doc`). Returns
`{:error, reason}` when the bridge is unavailable or the call fails.
"""
@spec list_remote_parameters(module(), atom(), maybe_node()) ::
{:ok, [map()]} | {:error, term()}
def list_remote_parameters(robot, bridge_name, nil) do
BB.Parameter.list_remote(robot, bridge_name)
rescue
e -> {:error, Exception.message(e)}
catch
:exit, reason -> {:error, reason}
end
def list_remote_parameters(robot, bridge_name, node) do
case Rpc.call(node, BB.Parameter, :list_remote, [robot, bridge_name]) do
{:badrpc, reason} -> {:error, reason}
result -> result
end
end
@doc """
Sets a parameter value on a remote bridge.
"""
@spec set_remote_parameter(module(), atom(), term(), term(), maybe_node()) ::
:ok | {:error, term()}
def set_remote_parameter(robot, bridge_name, param_id, value, nil) do
BB.Parameter.set_remote(robot, bridge_name, param_id, value)
rescue
e -> {:error, Exception.message(e)}
catch
:exit, reason -> {:error, reason}
end
def set_remote_parameter(robot, bridge_name, param_id, value, node) do
case Rpc.call(node, BB.Parameter, :set_remote, [robot, bridge_name, param_id, value]) do
{:badrpc, reason} -> {:error, reason}
result -> result
end
end
defp local_simulation_mode(robot) do
Runtime.simulation_mode(robot)
rescue
_ -> nil
catch
:exit, _ -> nil
end
defp remote_simulation_mode(robot, node) do
case Rpc.call(node, BB.Robot.Runtime, :simulation_mode, [robot]) do
{:badrpc, _} -> nil
mode when is_atom(mode) -> mode
_ -> nil
end
end
defp filter_bridges(entities, sim_mode) do
entities
|> Enum.filter(&match?(%BB.Dsl.Bridge{}, &1))
|> Enum.reject(fn bridge ->
sim_mode != nil and bridge.simulation == :omit
end)
|> Enum.map(fn bridge ->
%{name: bridge.name, simulation: bridge.simulation}
end)
end
@doc """
Returns the list of declared commands for the robot, normalized for the
UI. Returns `[]` if the command DSL is not available or raises.
Each command map has the shape:
%{
name: atom(),
handler: term(),
timeout: integer() | :infinity,
allowed_states: [atom()],
arguments: [%{name: atom(), type: String.t(), required: boolean(),
default: term(), doc: String.t() | nil}]
}
Argument types are normalized to strings: `"boolean"`, `"integer"`,
`"float"`, `"atom"`, `"string"`, or `"enum:[a, b, c]"`. Mirrors
`BB.LiveView.Components.Command` so both UIs see the same shape.
"""
@spec discover_commands(module(), maybe_node()) :: [map()]
def discover_commands(robot, nil) do
if Code.ensure_loaded?(Info) and function_exported?(Info, :commands, 1) do
robot |> Info.commands() |> normalize_commands()
else
[]
end
rescue
_ -> []
end
def discover_commands(robot, node) do
case Rpc.call(node, BB.Dsl.Info, :commands, [robot]) do
{:badrpc, _} -> []
result when is_list(result) -> normalize_commands(result)
_ -> []
end
rescue
_ -> []
end
defp normalize_commands(commands) do
commands
|> Enum.map(&format_command/1)
|> Enum.sort_by(& &1.name)
end
defp format_command(cmd) do
%{
name: cmd.name,
handler: Map.get(cmd, :handler),
timeout: Map.get(cmd, :timeout, :infinity),
allowed_states: Map.get(cmd, :allowed_states, []),
arguments: cmd |> Map.get(:arguments, []) |> Enum.map(&format_argument/1)
}
end
defp format_argument(arg) do
raw_type = Map.get(arg, :type, :string)
%{
name: arg.name,
type: format_type(raw_type),
enum_values: enum_values(raw_type),
required: Map.get(arg, :required, false),
default: Map.get(arg, :default),
doc: Map.get(arg, :doc)
}
end
defp format_type(type) when is_atom(type), do: Atom.to_string(type)
defp format_type({:in, values}), do: "enum:#{inspect(values)}"
defp format_type(other), do: inspect(other)
defp enum_values({:in, values}) when is_list(values), do: values
defp enum_values(_), do: nil
# ── Write calls ────────────────────────────────────────────
@doc "Arms the robot."
@spec arm(module(), maybe_node()) :: term()
def arm(robot, nil), do: BB.Safety.arm(robot)
def arm(robot, node), do: rpc(node, BB.Safety, :arm, [robot])
@doc "Disarms the robot."
@spec disarm(module(), maybe_node()) :: term()
def disarm(robot, nil), do: BB.Safety.disarm(robot)
def disarm(robot, node), do: rpc(node, BB.Safety, :disarm, [robot])
@doc "Force-disarms the robot from an error state."
@spec force_disarm(module(), maybe_node()) :: term()
def force_disarm(robot, nil), do: BB.Safety.force_disarm(robot)
def force_disarm(robot, node), do: rpc(node, BB.Safety, :force_disarm, [robot])
@doc "Commands an actuator to a position."
@spec set_actuator(module(), atom(), number(), maybe_node()) :: term()
def set_actuator(robot, actuator, position, nil) do
BB.Actuator.set_position!(robot, actuator, position)
end
def set_actuator(robot, actuator, position, node) do
rpc(node, BB.Actuator, :set_position!, [robot, actuator, position])
end
@doc "Publishes a PubSub message under the robot's topic."
@spec publish(module(), list(), term(), maybe_node()) :: term()
def publish(robot, path, msg, nil), do: BB.publish(robot, path, msg)
def publish(robot, path, msg, node), do: rpc(node, BB, :publish, [robot, path, msg])
@doc "Sets a parameter value."
@spec set_parameter(module(), list(), term(), maybe_node()) :: term()
def set_parameter(robot, path, value, nil) do
BB.Parameter.set(robot, path, value)
end
def set_parameter(robot, path, value, node) do
rpc(node, BB.Parameter, :set, [robot, path, value])
end
@doc """
Executes a command on the runtime.
Returns whatever the runtime returns — typically `{:ok, pid}` for the
command process, or `{:error, reason}`. Cross-node pids are tracked
transparently by the Erlang distribution layer.
"""
@spec execute_command(module(), atom(), map(), maybe_node()) ::
{:ok, pid()} | {:error, term()}
def execute_command(robot, name, args, nil) do
Runtime.execute(robot, name, args)
end
def execute_command(robot, name, args, node) do
rpc(node, BB.Robot.Runtime, :execute, [robot, name, args])
end
# ── Internal ───────────────────────────────────────────────
defp rpc(node, mod, fun, args) do
case Rpc.call(node, mod, fun, args) do
{:badrpc, reason} ->
raise "BB.TUI.Robot: remote call #{inspect(mod)}.#{fun}/#{length(args)} " <>
"on #{inspect(node)} failed: #{inspect(reason)}"
result ->
result
end
end
end