From 4b10c5ab8ee381db5d4cae17fda19fb75a9def2c Mon Sep 17 00:00:00 2001 From: James Harton Date: Sat, 16 May 2026 17:13:23 +1200 Subject: [PATCH] improvement: installer registers arm_commands handlers in generated robot MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `mix igniter.install bb_so101` now adds the `arm_commands` example package as a sparse git dep against `beam-bots/bb_examples`, and registers the three demo command handlers — `BB.Examples.ArmCommands.{Home, MoveToPose, DemoCircle}` — as commands on the generated robot. Each command is registered with full DSL `argument` metadata, so the bb_liveview dashboard immediately renders proper input widgets for every goal field: - `:position` (float) for home - `:ee_link` (atom) for move_to_pose and demo_circle - `:plane` (`{:in, [:xy, :xz, :yz]}` enum), `:radius` (float), `:points` (integer), `:settle_tolerance_m` (float), `:settle_timeout_ms` (integer) for demo_circle The user gets a fully demo-ready arm out of a single `mix igniter.install bb_so101` — including IK-driven moves to a Cartesian target and a configurable demo circle. ## Why a sparse git dep? `arm_commands` lives in the `bb_examples` monorepo, distributed as a git dep rather than via Hex on the grounds that example/demo code doesn't need versioned releases. The `sparse:` checkout means consumers only pull the `arm_commands/` sub-directory, not the entire monorepo. ## Implementation note There's no `BB.Igniter.add_command/4` helper in `bb` yet, so the installer carries its own small `add_command/4` private function that wraps `Spark.Igniter.update_dsl` the same way `BB.Igniter.add_controller/4` already does internally. If/when more installers need this, it can be promoted into `BB.Igniter`. ## Test plan - [x] `mix check --no-retry` green (16 installer tests, +4 new covering the arm_commands dep + each command registration) - [x] Idempotency test passes — running the installer twice produces no further changes. --- lib/mix/tasks/bb_so101.install.ex | 107 ++++++++++++++++++++++- test/mix/tasks/bb_so101.install_test.exs | 68 ++++++++++++++ 2 files changed, 174 insertions(+), 1 deletion(-) diff --git a/lib/mix/tasks/bb_so101.install.ex b/lib/mix/tasks/bb_so101.install.ex index 13df5f1..507d5ac 100644 --- a/lib/mix/tasks/bb_so101.install.ex +++ b/lib/mix/tasks/bb_so101.install.ex @@ -38,7 +38,10 @@ if Code.ensure_loaded?(Igniter) do use Igniter.Mix.Task alias Igniter.Code.{Common, Function} - alias Igniter.Project.{Formatter, Module} + alias Igniter.Project.{Deps, Formatter, Module} + + @arm_commands_dep {:arm_commands, + git: "https://github.com/beam-bots/bb_examples.git", sparse: "arm_commands"} @impl Igniter.Mix.Task def info(_argv, _parent) do @@ -72,6 +75,108 @@ if Code.ensure_loaded?(Igniter) do "feetech_controller" ]) |> lift_robot_opts_to_function(robot_module, device) + |> add_arm_commands(robot_module) + end + + # Adds the `arm_commands` example package (sparse git dep against + # bb_examples) and registers its three handlers — Home, MoveToPose, + # DemoCircle — as commands on the user's robot, with DSL argument + # metadata so the bb_liveview dashboard renders proper input widgets + # for each goal field. + defp add_arm_commands(igniter, robot_module) do + igniter + |> Deps.add_dep(@arm_commands_dep) + |> add_command(robot_module, :home, home_command_body()) + |> add_command(robot_module, :move_to_pose, move_to_pose_command_body()) + |> add_command(robot_module, :demo_circle, demo_circle_command_body()) + end + + # Adds a `command :name do … end` entry to the robot's `commands` + # section. Idempotent on command name. Reuses the public + # `Spark.Igniter.update_dsl` mechanism that BB.Igniter's own helpers + # use; no equivalent helper exists on BB.Igniter (yet — it could be + # promoted there in a follow-up). + defp add_command(igniter, robot_module, name, body_code) do + Spark.Igniter.update_dsl(igniter, robot_module, [{:section, :commands}], nil, fn zipper -> + if command_exists?(zipper, name) do + {:ok, zipper} + else + code = "command :#{name} do\n#{indent(body_code)}\nend\n" + {:ok, Common.add_code(zipper, code)} + end + end) + end + + defp command_exists?(zipper, name) do + case Function.move_to_function_call_in_current_scope( + zipper, + :command, + [2, 3], + &Function.argument_equals?(&1, 0, name) + ) do + {:ok, _} -> true + _ -> false + end + end + + defp indent(text) do + text + |> String.split("\n") + |> Enum.map_join("\n", fn + "" -> "" + line -> " " <> line + end) + end + + defp home_command_body do + """ + handler(BB.Examples.ArmCommands.Home) + allowed_states([:idle]) + + argument(:position, :float, default: 0.0, doc: "Position (radians) for every movable joint") + """ + end + + defp move_to_pose_command_body do + """ + handler(BB.Examples.ArmCommands.MoveToPose) + allowed_states([:idle]) + + argument(:ee_link, :atom, + default: :ee_link, + doc: "End-effector link name in the topology" + ) + """ + end + + defp demo_circle_command_body do + """ + handler(BB.Examples.ArmCommands.DemoCircle) + allowed_states([:idle]) + + argument(:ee_link, :atom, + default: :ee_link, + doc: "End-effector link name in the topology" + ) + + argument(:plane, {:in, [:xy, :xz, :yz]}, + default: :xz, + doc: "Plane the circle is traced in" + ) + + argument(:radius, :float, default: 0.03, doc: "Circle radius (metres)") + argument(:points, :integer, default: 16, doc: "Number of waypoints around the circle") + + argument(:settle_tolerance_m, :float, + default: 5.0e-3, + doc: "EE distance from target to consider arrived (metres)" + ) + + argument(:settle_timeout_ms, :integer, + default: 1500, + doc: "Max wait per waypoint before continuing (milliseconds)" + ) + """ end # Replaces the inline robot child-spec opts in `application.ex` with a call diff --git a/test/mix/tasks/bb_so101.install_test.exs b/test/mix/tasks/bb_so101.install_test.exs index 19da802..0cfb358 100644 --- a/test/mix/tasks/bb_so101.install_test.exs +++ b/test/mix/tasks/bb_so101.install_test.exs @@ -168,6 +168,74 @@ defmodule Mix.Tasks.BbSo101.InstallTest do refute mix_exs =~ ":bb_servo_feetech" refute mix_exs =~ ":feetech," end + + test "adds arm_commands as a sparse git dep" do + igniter = + test_project() + |> Igniter.compose_task("bb_so101.install") + |> apply_igniter!() + + mix_exs = + igniter.rewrite + |> Rewrite.source!("mix.exs") + |> Rewrite.Source.get(:content) + + assert mix_exs =~ ":arm_commands" + assert mix_exs =~ ~s|git: "https://github.com/beam-bots/bb_examples.git"| + assert mix_exs =~ ~s|sparse: "arm_commands"| + end + end + + describe "arm_commands" do + test "registers the home command with a :position argument" do + igniter = + test_project() + |> Igniter.compose_task("bb_so101.install") + |> apply_igniter!() + + robot = + igniter.rewrite + |> Rewrite.source!("lib/test/robot.ex") + |> Rewrite.Source.get(:content) + + assert robot =~ "command :home" + assert robot =~ "BB.Examples.ArmCommands.Home" + assert robot =~ "argument(:position" + end + + test "registers the move_to_pose command with an :ee_link argument" do + igniter = + test_project() + |> Igniter.compose_task("bb_so101.install") + |> apply_igniter!() + + robot = + igniter.rewrite + |> Rewrite.source!("lib/test/robot.ex") + |> Rewrite.Source.get(:content) + + assert robot =~ "command :move_to_pose" + assert robot =~ "BB.Examples.ArmCommands.MoveToPose" + assert robot =~ "argument(:ee_link" + end + + test "registers the demo_circle command with its plane / radius / points arguments" do + igniter = + test_project() + |> Igniter.compose_task("bb_so101.install") + |> apply_igniter!() + + robot = + igniter.rewrite + |> Rewrite.source!("lib/test/robot.ex") + |> Rewrite.Source.get(:content) + + assert robot =~ "command :demo_circle" + assert robot =~ "BB.Examples.ArmCommands.DemoCircle" + assert robot =~ "argument(:plane, {:in, [:xy, :xz, :yz]}" + assert robot =~ "argument(:radius, :float" + assert robot =~ "argument(:points, :integer" + end end describe "formatter" do