# SPDX-FileCopyrightText: 2025 James Harton # # SPDX-License-Identifier: Apache-2.0 defmodule BB.LiveView.Components.Command do @moduledoc """ LiveComponent for executing robot commands. Displays available commands in tabs with: - Dynamic form for each command's arguments - Execute button with loading state - Result/error display - State-aware command availability """ use Phoenix.LiveComponent alias BB.Dsl.Info, as: DslInfo alias BB.Robot.Runtime, as: RobotRuntime @impl Phoenix.LiveComponent def mount(socket) do {:ok, assign(socket, commands: [], active_tab: nil, state: :disarmed, executing: nil, result: nil, error: nil, form_values: %{} )} end @impl Phoenix.LiveComponent def update(%{event: {:state_changed, new_state}}, socket) do {:ok, assign(socket, :state, new_state)} end def update(%{event: {:command_result, result}}, socket) do {:ok, socket |> assign(:executing, nil) |> assign(:result, result) |> assign(:error, nil)} end def update(%{event: {:command_error, error}}, socket) do {:ok, socket |> assign(:executing, nil) |> assign(:result, nil) |> assign(:error, error)} end def update(%{robot_module: robot_module} = assigns, socket) do socket = if socket.assigns[:robot_module] != robot_module do initialize_for_robot(socket, robot_module) else socket end {:ok, assign(socket, assigns)} end def update(assigns, socket) do {:ok, assign(socket, assigns)} end defp initialize_for_robot(socket, robot_module) do case load_robot_state(robot_module) do {:ok, commands, state} -> active_tab = if commands != [], do: hd(commands).name, else: nil socket |> assign(:robot_module, robot_module) |> assign(:commands, commands) |> assign(:state, state) |> assign(:active_tab, active_tab) :error -> assign(socket, :robot_module, robot_module) end end defp load_robot_state(robot_module) do if valid_robot?(robot_module) do try do commands = discover_commands(robot_module) state = RobotRuntime.state(robot_module) {:ok, commands, state} rescue ArgumentError -> :error end else :error end end @impl Phoenix.LiveComponent def render(assigns) do ~H"""

No commands available

<% cmd = Enum.find(@commands, fn c -> c.name == @active_tab end) %> <% can_execute = @state in cmd.allowed_states %>
{cmd.name} {if can_execute, do: "Ready", else: "Requires: #{Enum.join(cmd.allowed_states, ", ")}"}
No arguments required
<%= render_input(arg, @form_values) %> {arg.doc}
{@result}
{@error}
""" end defp render_input(arg, form_values) do value = Map.get(form_values, arg.name, arg.default) case arg.type do "boolean" -> assigns = %{arg: arg, checked: value == true or value == "true"} ~H""" """ "enum:" <> values_str -> values = parse_enum_values(values_str) assigns = %{arg: arg, values: values, value: value} ~H""" """ type when type in ["integer", "float"] -> step = if type == "float", do: "0.01", else: "1" assigns = %{arg: arg, value: value, step: step} ~H""" """ _ -> assigns = %{arg: arg, value: value} ~H""" """ end end defp parse_enum_values(values_str) do values_str |> String.trim_leading("[") |> String.trim_trailing("]") |> String.split(", ") |> Enum.map(&String.trim/1) end @impl Phoenix.LiveComponent def handle_event("select_tab", %{"tab" => tab}, socket) do tab_atom = String.to_existing_atom(tab) {:noreply, assign(socket, active_tab: tab_atom, result: nil, error: nil)} end def handle_event("execute", %{"command" => cmd_name, "args" => args}, socket) do execute_command(socket, cmd_name, args) end def handle_event("execute", %{"command" => cmd_name}, socket) do execute_command(socket, cmd_name, %{}) end defp execute_command(socket, cmd_name, args) do cmd_atom = String.to_existing_atom(cmd_name) if valid_robot?(socket.assigns.robot_module) do parsed_args = parse_args(args) spawn_command_task(socket, cmd_atom, parsed_args) {:noreply, socket |> assign(:executing, cmd_atom) |> assign(:result, nil) |> assign(:error, nil)} else {:noreply, assign(socket, :error, "No valid robot connected")} end end defp parse_args(args) do args |> Enum.map(fn {k, v} -> {String.to_existing_atom(k), parse_value(v)} end) |> Map.new() end defp spawn_command_task(socket, cmd_atom, parsed_args) do parent_pid = self() component_id = socket.assigns.id robot_module = socket.assigns.robot_module Task.start(fn -> case RobotRuntime.execute(robot_module, cmd_atom, parsed_args) do {:ok, pid} -> result = BB.Command.await(pid, 30_000) handle_command_result(result, parent_pid, component_id) {:error, _} = error -> handle_command_result(error, parent_pid, component_id) end end) end defp handle_command_result({:ok, result}, parent_pid, component_id) do send_update(parent_pid, __MODULE__, id: component_id, event: {:command_result, inspect(result)} ) end defp handle_command_result({:error, reason}, parent_pid, component_id) do send_update(parent_pid, __MODULE__, id: component_id, event: {:command_error, inspect(reason)} ) end defp parse_value("true"), do: true defp parse_value("false"), do: false defp parse_value(":" <> rest = value) when byte_size(rest) > 0 do String.to_existing_atom(rest) rescue ArgumentError -> value end defp parse_value(value) when is_binary(value) do case Integer.parse(value) do {int, ""} -> int _ -> case Float.parse(value) do {float, ""} -> float _ -> value end end end defp parse_value(value), do: value defp discover_commands(robot_module) do robot_module |> DslInfo.commands() |> Enum.map(&format_command/1) |> Enum.sort_by(& &1.name) end defp format_command(cmd) do %{ name: cmd.name, handler: cmd.handler, timeout: cmd.timeout, allowed_states: cmd.allowed_states, arguments: Enum.map(cmd.arguments, &format_argument/1) } end defp format_argument(arg) do %{ name: arg.name, type: format_type(arg.type), required: arg.required, default: arg.default, doc: 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(type), do: inspect(type) defp valid_robot?(robot_module) when is_atom(robot_module) do function_exported?(robot_module, :robot, 0) and function_exported?(robot_module, :spark_dsl_config, 0) end defp valid_robot?(_), do: false end