# SPDX-FileCopyrightText: 2025 James Harton # # SPDX-License-Identifier: Apache-2.0 defmodule BB.LiveView.Components.Parameters do @moduledoc """ LiveComponent for viewing and editing robot parameters. Displays parameters in a tab-based interface with: - Local parameter groups as tabs - Remote bridge parameters in separate tabs - Appropriate input controls based on parameter type - Real-time updates via PubSub """ use Phoenix.LiveComponent alias BB.Dsl.Info, as: DslInfo alias BB.Parameter alias BB.Robot.Runtime, as: RobotRuntime @impl Phoenix.LiveComponent def mount(socket) do {:ok, assign(socket, tabs: [], parameters: %{}, active_tab: nil )} end @impl Phoenix.LiveComponent def update(%{event: {:parameter_changed, path, new_value}}, socket) do tab_id = get_tab_id_for_path(path) updated_params = update_in(socket.assigns.parameters, [tab_id, path], fn param -> if param, do: %{param | value: new_value}, else: param end) {:ok, assign(socket, :parameters, updated_params)} 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_parameters(robot_module) do {:ok, tabs, parameters, active_tab} -> socket |> assign(:robot_module, robot_module) |> assign(:tabs, tabs) |> assign(:parameters, parameters) |> assign(:active_tab, active_tab) :error -> assign(socket, :robot_module, robot_module) end end defp load_parameters(robot_module) do if valid_robot?(robot_module) do try do {tabs, parameters} = discover_local_parameters(robot_module) {bridge_tabs, bridge_params} = discover_bridge_parameters(robot_module) all_tabs = tabs ++ bridge_tabs all_params = Map.merge(parameters, bridge_params) active_tab = case all_tabs do [first | _] -> first.id [] -> nil end {:ok, all_tabs, all_params, active_tab} rescue ArgumentError -> :error end else :error end end @impl Phoenix.LiveComponent def render(assigns) do ~H"""

No parameters defined

<% tab = Enum.find(@tabs, fn t -> t.id == @active_tab end) %> <% tab_params = Map.get(@parameters, @active_tab, %{}) %>

{tab_params.__error__}

No parameters in this group

p.display_name end)} class="bb-param-row">
{param.display_name} {param.doc}
{render_param_input(assigns, param, tab)}
""" end defp render_param_input(assigns, param, tab) do has_limits = param.min != nil and param.max != nil is_remote = tab.type == :remote path_str = format_path_string(param[:path] || param[:id]) assigns = assigns |> Map.put(:param, param) |> Map.put(:tab, tab) |> Map.put(:has_limits, has_limits) |> Map.put(:is_remote, is_remote) |> Map.put(:path_str, path_str) input_type = determine_input_type(param.type, has_limits) render_input_by_type(input_type, assigns) end defp determine_input_type("boolean", _has_limits), do: :boolean defp determine_input_type("atom", _has_limits), do: :atom defp determine_input_type("float", true), do: :slider defp determine_input_type("float", false), do: :number defp determine_input_type("integer", true), do: :slider defp determine_input_type("integer", false), do: :number defp determine_input_type("unit:" <> _, true), do: :slider defp determine_input_type("unit:" <> _, false), do: :number defp determine_input_type(_, _has_limits), do: :text defp render_input_by_type(:boolean, assigns), do: render_boolean_input(assigns) defp render_input_by_type(:slider, assigns), do: render_slider_input(assigns) defp render_input_by_type(:number, assigns), do: render_number_input(assigns) defp render_input_by_type(:atom, assigns), do: render_atom_input(assigns) defp render_input_by_type(:text, assigns), do: render_text_input(assigns) defp render_boolean_input(assigns) do ~H""" """ end defp render_slider_input(assigns) do step = if assigns.param.type == "integer", do: "1", else: to_string((assigns.param.max - assigns.param.min) / 100) unit = extract_unit(assigns.param.type) assigns = assigns |> Map.put(:step, step) |> Map.put(:unit, unit) ~H"""
{@unit}
""" end defp render_number_input(assigns) do step = if assigns.param.type == "integer", do: "1", else: "0.01" unit = extract_unit(assigns.param.type) assigns = assigns |> Map.put(:step, step) |> Map.put(:unit, unit) ~H"""
{@unit}
""" end defp render_atom_input(assigns) do display_value = if assigns.param.value, do: ":#{assigns.param.value}", else: "" assigns = Map.put(assigns, :display_value, display_value) ~H""" """ end defp render_text_input(assigns) do ~H""" """ end defp extract_unit("unit:" <> unit), do: unit defp extract_unit(_), do: nil @impl Phoenix.LiveComponent def handle_event("select_tab", %{"tab" => tab_str}, socket) do tab_id = parse_tab_id(tab_str) {:noreply, assign(socket, :active_tab, tab_id)} end def handle_event("toggle_boolean", params, socket) do path_str = params["path"] is_remote = params["remote"] == "true" current_value = get_current_value(socket, path_str) new_value = not (current_value == true) apply_parameter_change(socket, path_str, new_value, is_remote, params["bridge"]) end def handle_event("set_parameter", params, socket) do path_str = params["path"] value = params["value"] is_remote = params["remote"] == "true" bridge = params["bridge"] param_type = get_param_type(socket, path_str) parsed_value = parse_value(value, param_type) apply_parameter_change(socket, path_str, parsed_value, is_remote, bridge) end def handle_event("slider_change", params, socket) do path_str = params["path"] value = params["value"] is_remote = params["remote"] == "true" bridge = params["bridge"] param_type = get_param_type(socket, path_str) parsed_value = parse_value(value, param_type) apply_parameter_change(socket, path_str, parsed_value, is_remote, bridge) end def handle_event("refresh_remote", %{"bridge" => bridge_str}, socket) do bridge_atom = String.to_existing_atom(bridge_str) params = fetch_remote_params(socket.assigns.robot_module, bridge_atom) tab_id = {:bridge, bridge_atom} updated_params = Map.put(socket.assigns.parameters, tab_id, params) {:noreply, assign(socket, :parameters, updated_params)} end defp apply_parameter_change(socket, path_str, value, is_remote, bridge) do result = if is_remote do bridge_atom = String.to_existing_atom(bridge) Parameter.set_remote(socket.assigns.robot_module, bridge_atom, path_str, value) else path = String.split(path_str, ".") |> Enum.map(&String.to_existing_atom/1) Parameter.set(socket.assigns.robot_module, path, value) end case result do :ok -> {:noreply, socket} {:error, _reason} -> {:noreply, socket} end end defp get_current_value(socket, path_str) do path = String.split(path_str, ".") |> Enum.map(&String.to_existing_atom/1) tab_id = get_tab_id_for_path(path) socket.assigns.parameters |> Map.get(tab_id, %{}) |> Map.get(path, %{}) |> Map.get(:value) end defp get_param_type(socket, path_str) do path = String.split(path_str, ".") |> Enum.map(&String.to_existing_atom/1) tab_id = get_tab_id_for_path(path) socket.assigns.parameters |> Map.get(tab_id, %{}) |> Map.get(path, %{}) |> Map.get(:type, "string") end defp get_tab_id_for_path(path) do case path do [single] when is_atom(single) -> :general [group | _] -> group end end # Parameter discovery defp discover_local_parameters(robot_module) do params = Parameter.list(robot_module) organise_into_tabs(params) end defp organise_into_tabs(params) do grouped = params |> Enum.group_by(fn {path, _meta} -> case path do [single] when is_atom(single) -> :general [group | _rest] -> group end end) tabs = grouped |> Map.keys() |> Enum.sort_by(fn :general -> {0, ""} name -> {1, Atom.to_string(name)} end) |> Enum.map(fn group -> %{ id: group, label: format_tab_label(group), type: :local } end) parameters = grouped |> Enum.map(fn {group, params_list} -> formatted = params_list |> Enum.map(&format_local_param/1) |> Enum.sort_by(& &1.display_name) |> Map.new(fn p -> {p.path, p} end) {group, formatted} end) |> Map.new() {tabs, parameters} end defp format_tab_label(:general), do: "General" defp format_tab_label(name), do: name |> Atom.to_string() |> String.capitalize() defp format_local_param({path, meta}) do display_name = case path do [_single] -> Atom.to_string(hd(path)) [_group | rest] -> Enum.map_join(rest, ".", &Atom.to_string/1) end %{ path: path, display_name: display_name, value: meta[:value], type: format_type(meta[:type]), min: meta[:min], max: meta[:max], doc: meta[:doc] } end defp format_type(nil), do: "string" defp format_type(type) when is_atom(type), do: Atom.to_string(type) defp format_type({:unit, unit}), do: "unit:#{unit}" defp format_type(other), do: inspect(other) defp discover_bridge_parameters(robot_module) do simulation_mode = RobotRuntime.simulation_mode(robot_module) bridges = robot_module |> DslInfo.parameters() |> Enum.filter(&is_struct(&1, BB.Dsl.Bridge)) |> Enum.reject(fn bridge -> simulation_mode != nil and bridge.simulation == :omit end) tabs = Enum.map(bridges, fn bridge -> %{ id: {:bridge, bridge.name}, label: bridge.name |> Atom.to_string() |> String.capitalize(), type: :remote, bridge_name: bridge.name } end) parameters = bridges |> Enum.map(fn bridge -> params = fetch_remote_params(robot_module, bridge.name) {{:bridge, bridge.name}, params} end) |> Map.new() {tabs, parameters} end defp fetch_remote_params(robot_module, bridge_name) do case Parameter.list_remote(robot_module, bridge_name) do {:ok, params} -> params |> Enum.map(fn p -> id = p[:id] || p["id"] %{ id: id, display_name: id, value: p[:value] || p["value"], type: format_type(p[:type] || p["type"]), min: p[:min] || p["min"], max: p[:max] || p["max"], doc: p[:doc] || p["doc"] } end) |> Map.new(fn p -> {p.id, p} end) {:error, _reason} -> %{__error__: "Failed to load remote parameters"} end end # Tab ID formatting defp format_tab_id(:general), do: "general" defp format_tab_id({:bridge, name}), do: "bridge:#{name}" defp format_tab_id(name) when is_atom(name), do: Atom.to_string(name) defp parse_tab_id("general"), do: :general defp parse_tab_id("bridge:" <> name), do: {:bridge, String.to_existing_atom(name)} defp parse_tab_id(name), do: String.to_existing_atom(name) defp format_path_string(path) when is_list(path), do: Enum.map_join(path, ".", &Atom.to_string/1) defp format_path_string(id), do: to_string(id) # Value parsing defp parse_value(value, "boolean"), do: value == true or value == "true" defp parse_value(value, "integer") do case Integer.parse(to_string(value)) do {int, _} -> int :error -> value end end defp parse_value(value, "float"), do: parse_float_value(value) defp parse_value(value, "unit:" <> _unit), do: parse_float_value(value) defp parse_value(value, "atom") do case to_string(value) do ":" <> rest -> String.to_existing_atom(rest) rest -> String.to_existing_atom(rest) end end defp parse_value(value, _type), do: value defp parse_float_value(value) do case Float.parse(to_string(value)) do {float, _} -> float :error -> value end end 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