# SPDX-FileCopyrightText: 2025 James Harton # # SPDX-License-Identifier: Apache-2.0 defmodule BB.LiveView.DashboardLive do @moduledoc """ Main LiveView for the BB Dashboard. Displays robot status and control widgets including: - Safety status and controls - Joint positions and sliders - Event stream monitor - Command execution - 3D visualisation - Parameter editor """ use Phoenix.LiveView, layout: {BB.LiveView.Layouts, :app} import BB.LiveView.Components alias BB.LiveView.Components.Command alias BB.LiveView.Components.EventStream alias BB.LiveView.Components.JointControl alias BB.LiveView.Components.Parameters alias BB.LiveView.Components.Safety alias BB.LiveView.Components.Visualisation @joint_state_throttle_ms 33 @impl Phoenix.LiveView def mount(_params, _session, socket) do robot_module = socket.assigns.robot_module socket = socket |> assign(:robot_name, get_robot_name(robot_module)) |> assign(:connected, connected?(socket)) |> assign(:loading, true) |> assign(:joint_positions, %{}) |> assign(:latest_joint_message, nil) |> assign(:joint_state_flush_scheduled, false) if connected?(socket) do subscribe_to_robot(robot_module) send(self(), :load_robot_data) end {:ok, socket} end @impl Phoenix.LiveView def handle_info(:load_robot_data, socket) do {:noreply, assign(socket, :loading, false)} end def handle_info({:bb, [:state_machine], %BB.Message{payload: payload} = message}, socket) do new_state = payload.to in_error = new_state == :error armed = new_state in [:armed, :idle, :executing] send_update(Safety, id: "safety", event: {:state_changed, new_state, in_error} ) send_update(JointControl, id: "joint_control", event: {:armed_changed, armed} ) send_update(Command, id: "command", event: {:state_changed, new_state} ) send_update(EventStream, id: "event_stream", event: {:new_message, [:state_machine], message} ) {:noreply, socket} end def handle_info( {:bb, [:sensor | _rest] = path, %BB.Message{payload: %BB.Message.Sensor.JointState{} = js} = message}, socket ) do # Each joint's estimator publishes its own single-joint JointState on its # own topic. Accumulate them into one running pose so a flush updates every # joint together — pushing only the latest single message per throttle # window makes the visualisation update one joint at a time and look jerky. positions = js.names |> Enum.zip(js.positions) |> Map.new() socket = socket |> assign(:joint_positions, Map.merge(socket.assigns.joint_positions, positions)) |> assign(:latest_joint_message, {path, message}) |> schedule_joint_state_flush() {:noreply, socket} end def handle_info(:flush_joint_state, %{assigns: %{latest_joint_message: nil}} = socket) do {:noreply, assign(socket, :joint_state_flush_scheduled, false)} end def handle_info(:flush_joint_state, socket) do {path, message} = socket.assigns.latest_joint_message positions = socket.assigns.joint_positions send_update(JointControl, id: "joint_control", event: {:positions_updated, positions} ) send_update(Visualisation, id: "visualisation", event: {:positions_updated, positions} ) send_update(EventStream, id: "event_stream", event: {:new_message, path, message} ) {:noreply, socket |> assign(:latest_joint_message, nil) |> assign(:joint_state_flush_scheduled, false)} end def handle_info( {:bb, [:param | path], %BB.Message{payload: %BB.Parameter.Changed{} = changed} = message}, socket ) do send_update(Parameters, id: "parameters", event: {:parameter_changed, path, changed.new_value} ) send_update(EventStream, id: "event_stream", event: {:new_message, [:param | path], message} ) {:noreply, socket} end def handle_info({:bb, path, %BB.Message{} = message}, socket) do send_update(EventStream, id: "event_stream", event: {:new_message, path, message} ) {:noreply, socket} end # Direct notification from JointControl for immediate visualisation update def handle_info({:joint_position_changed, positions}, socket) do send_update(Visualisation, id: "visualisation", event: {:positions_updated, positions} ) {:noreply, socket} end # Command completion notification from Task def handle_info({:command_complete, component_id, {:ok, result}}, socket) do send_update(Command, id: component_id, event: {:command_result, result} ) {:noreply, socket} end def handle_info({:command_complete, component_id, {:error, reason}}, socket) do send_update(Command, id: component_id, event: {:command_error, reason} ) {:noreply, socket} end def handle_info(_msg, socket) do {:noreply, socket} end @impl Phoenix.LiveView def render(assigns) do ~H"""
<.header robot_name={@robot_name} connected={@connected} />
<.spinner />

Loading robot data...

<.widget title="Safety"> <.live_component module={Safety} id="safety" robot_module={@robot_module} /> <.widget title="Commands"> <.live_component module={Command} id="command" robot_module={@robot_module} />
<.widget title="3D Visualisation" class="bb-widget-visualisation"> <.live_component module={Visualisation} id="visualisation" robot_module={@robot_module} /> <.widget title="Joint Control"> <.live_component module={JointControl} id="joint_control" robot_module={@robot_module} />
<.widget title="Event Stream"> <.live_component module={EventStream} id="event_stream" robot_module={@robot_module} /> <.widget title="Parameters"> <.live_component module={Parameters} id="parameters" robot_module={@robot_module} />
""" end defp header(assigns) do ~H"""

{@robot_name}

{if @connected, do: "Connected", else: "Connecting..."}
""" end defp get_robot_name(robot_module) do robot_module |> Module.split() |> List.last() end defp schedule_joint_state_flush(%{assigns: %{joint_state_flush_scheduled: true}} = socket), do: socket defp schedule_joint_state_flush(socket) do Process.send_after(self(), :flush_joint_state, @joint_state_throttle_ms) assign(socket, :joint_state_flush_scheduled, true) end defp subscribe_to_robot(robot_module) do if valid_robot?(robot_module) do try do BB.subscribe(robot_module, [:state_machine]) BB.subscribe(robot_module, [:sensor]) BB.subscribe(robot_module, [:param]) rescue ArgumentError -> :ok end 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