From ca9784af3f4b233a3895abb43c68dad38d8cd3d0 Mon Sep 17 00:00:00 2001 From: James Harton Date: Sat, 20 Dec 2025 14:32:17 +1300 Subject: [PATCH] feat: add GenServer behaviours with options_schema callbacks Add `BB.Controller`, `BB.Sensor`, and `BB.Bridge` behaviours (and extend `BB.Actuator`) with optional `options_schema/0` callbacks for compile-time validation of GenServer configuration. Key changes: - Each behaviour's `__using__/1` macro now includes `use GenServer` - Pass `options_schema: [...]` to auto-define the callback - Callbacks are overridable with `defoverridable` - Add `ValidateChildSpecs` verifier for compile-time option validation - Merge `BB.Parameter.Protocol` into `BB.Bridge` (single behaviour) - Move `disarm/1` callback from `BB.Safety` to `BB.Actuator`/`BB.Controller` Usage example: defmodule MyActuator do use BB.Actuator, options_schema: [ pin: [type: :pos_integer, required: true], frequency: [type: :pos_integer, default: 50] ] @impl BB.Actuator def disarm(opts), do: :ok @impl GenServer def init(opts), do: {:ok, opts} end --- documentation/dsls/DSL-BB.md | 2 +- documentation/topics/safety.md | 11 +- .../tutorials/08-parameter-bridges.md | 39 ++- lib/bb/actuator.ex | 135 +++++++++- lib/bb/{parameter/protocol.ex => bridge.ex} | 113 ++++++++- lib/bb/controller.ex | 114 +++++++++ lib/bb/dsl.ex | 21 +- lib/bb/dsl/verifiers/validate_child_specs.ex | 234 ++++++++++++++++++ lib/bb/parameter.ex | 6 +- lib/bb/safety.ex | 31 +-- lib/bb/sensor.ex | 128 ++++++++++ lib/bb/sensor/open_loop_position_estimator.ex | 74 +++--- .../protocol_test.exs => bridge_test.exs} | 2 +- test/bb/controller_test.exs | 8 +- test/bb/dsl/parameter_test.exs | 15 +- test/bb/supervisor_test.exs | 47 +++- test/support/failing_actuator.ex | 5 +- test/support/mock_actuator.ex | 91 ++++++- test/support/test_parameter_bridge.ex | 21 +- test/support/test_sensor.ex | 105 ++++++++ 20 files changed, 1059 insertions(+), 143 deletions(-) rename lib/bb/{parameter/protocol.ex => bridge.ex} (66%) create mode 100644 lib/bb/controller.ex create mode 100644 lib/bb/dsl/verifiers/validate_child_specs.ex create mode 100644 lib/bb/sensor.ex rename test/bb/{parameter/protocol_test.exs => bridge_test.exs} (99%) create mode 100644 test/support/test_sensor.ex diff --git a/documentation/dsls/DSL-BB.md b/documentation/dsls/DSL-BB.md index 2f04f1f..cf5553d 100644 --- a/documentation/dsls/DSL-BB.md +++ b/documentation/dsls/DSL-BB.md @@ -1354,7 +1354,7 @@ bridge name, child_spec A parameter protocol bridge for remote access. Bridges expose robot parameters to remote clients (GCS, web UI, etc.) -and receive parameter updates from them. They implement `BB.Parameter.Protocol`. +and receive parameter updates from them. They implement `BB.Bridge`. #### Example diff --git a/documentation/topics/safety.md b/documentation/topics/safety.md index 4469d80..a551277 100644 --- a/documentation/topics/safety.md +++ b/documentation/topics/safety.md @@ -8,15 +8,16 @@ SPDX-License-Identifier: Apache-2.0 ## Overview -BeamBots provides a software safety system through the `BB.Safety` behaviour and +BeamBots provides a software safety system through the `BB.Safety` module and `BB.Safety.Controller`. This document explains how the system works and its limitations. ## How Safety Works The safety system has four key components: -1. **BB.Safety behaviour**: Actuators, sensors, and controllers can implement the - `disarm/1` callback to handle hardware shutdown +1. **Behaviour callbacks**: Actuators and controllers implement the `disarm/1` callback + via their behaviours (`BB.Actuator`, `BB.Controller`) to handle hardware shutdown. + Sensors can optionally implement `disarm/1` if they control hardware. 2. **Registration**: Processes register with the safety controller on startup, providing hardware-specific options needed for stateless disarm 3. **State management**: The controller tracks safety state per robot (`:disarmed`, @@ -54,9 +55,9 @@ hardware safety controls for critical applications. ```elixir defmodule MyServo do use GenServer - @behaviour BB.Safety + use BB.Actuator - @impl BB.Safety + @impl BB.Actuator def disarm(opts) do # This callback can be called even if the GenServer process is dead pin = Keyword.fetch!(opts, :pin) diff --git a/documentation/tutorials/08-parameter-bridges.md b/documentation/tutorials/08-parameter-bridges.md index 4a1c982..cd4a768 100644 --- a/documentation/tutorials/08-parameter-bridges.md +++ b/documentation/tutorials/08-parameter-bridges.md @@ -21,7 +21,7 @@ Parameter bridges provide bidirectional access between BB and remote systems: > **For Roboticists:** Bridges work like MAVLink's parameter protocol or ROS2's parameter services. They let you enumerate, read, write, and subscribe to parameters over any transport. -> **For Elixirists:** Bridges are GenServers that implement the `BB.Parameter.Protocol` behaviour. They're supervised by the robot and integrate with PubSub for change notifications. +> **For Elixirists:** Bridges are GenServers that implement the `BB.Bridge` behaviour. They're supervised by the robot and integrate with PubSub for change notifications. ## Defining Bridges in the DSL @@ -49,9 +49,9 @@ Each bridge takes: Bridges are started as part of the robot's supervision tree. -## The Protocol Behaviour +## The Bridge Behaviour -Bridges implement `BB.Parameter.Protocol`. There are two directions: +Bridges implement `BB.Bridge`. There are two directions: ### Outbound Callback @@ -88,8 +88,7 @@ Here's a bridge that logs parameter changes: ```elixir defmodule MyDebugBridge do - use GenServer - @behaviour BB.Parameter.Protocol + use BB.Bridge def start_link(opts) do GenServer.start_link(__MODULE__, opts) @@ -108,7 +107,7 @@ defmodule MyDebugBridge do end # Handle local parameter changes - @impl BB.Parameter.Protocol + @impl BB.Bridge def handle_change(_robot, changed, state) do IO.puts("[DEBUG] Parameter #{inspect(changed.path)} changed:") IO.puts(" Old: #{inspect(changed.old_value)}") @@ -151,8 +150,7 @@ Add the inbound callbacks to your bridge: ```elixir defmodule MyFlightControllerBridge do - use GenServer - @behaviour BB.Parameter.Protocol + use BB.Bridge # Define a message type for remote param changes defmodule ParamValue do @@ -177,7 +175,7 @@ defmodule MyFlightControllerBridge do }} end - @impl BB.Parameter.Protocol + @impl BB.Bridge def handle_change(_robot, changed, state) do # Optionally sync local changes to FC send_param_to_fc(state.conn, changed) @@ -185,7 +183,7 @@ defmodule MyFlightControllerBridge do end # List all parameters on the flight controller - @impl BB.Parameter.Protocol + @impl BB.Bridge def list_remote(state) do params = fetch_all_fc_params(state.conn) |> Enum.map(fn {id, value} -> @@ -202,7 +200,7 @@ defmodule MyFlightControllerBridge do end # Get a specific parameter from the FC - @impl BB.Parameter.Protocol + @impl BB.Bridge def get_remote(param_id, state) do case fetch_fc_param(state.conn, param_id) do {:ok, value} -> {:ok, value, state} @@ -211,14 +209,14 @@ defmodule MyFlightControllerBridge do end # Set a parameter on the FC - @impl BB.Parameter.Protocol + @impl BB.Bridge def set_remote(param_id, value, state) do :ok = send_fc_param_set(state.conn, param_id, value) {:ok, state} end # Subscribe to FC parameter changes - @impl BB.Parameter.Protocol + @impl BB.Bridge def subscribe_remote(param_id, state) do state = %{state | subscriptions: MapSet.put(state.subscriptions, param_id)} {:ok, state} @@ -334,8 +332,7 @@ Here's a complete example with a simulated flight controller: defmodule MockFCBridge do @moduledoc "Simulates a flight controller with tunable parameters." - use GenServer - @behaviour BB.Parameter.Protocol + use BB.Bridge defmodule ParamValue do defstruct [:value] @@ -370,10 +367,10 @@ defmodule MockFCBridge do }} end - @impl BB.Parameter.Protocol + @impl BB.Bridge def handle_change(_robot, _changed, state), do: {:ok, state} - @impl BB.Parameter.Protocol + @impl BB.Bridge def list_remote(state) do params = Enum.map(state.params, fn {id, value} -> %{id: id, value: value, type: :float, doc: nil, path: id_to_path(id)} @@ -381,7 +378,7 @@ defmodule MockFCBridge do {:ok, params, state} end - @impl BB.Parameter.Protocol + @impl BB.Bridge def get_remote(param_id, state) do case Map.fetch(state.params, param_id) do {:ok, value} -> {:ok, value, state} @@ -389,7 +386,7 @@ defmodule MockFCBridge do end end - @impl BB.Parameter.Protocol + @impl BB.Bridge def set_remote(param_id, value, state) do if Map.has_key?(state.params, param_id) do state = %{state | params: Map.put(state.params, param_id, value)} @@ -407,7 +404,7 @@ defmodule MockFCBridge do end end - @impl BB.Parameter.Protocol + @impl BB.Bridge def subscribe_remote(param_id, state) do {:ok, %{state | subscriptions: MapSet.put(state.subscriptions, param_id)}} end @@ -472,7 +469,7 @@ Parameter bridges enable: - **Bidirectional sync:** Keep parameters in sync across systems Key points: -- Bridges implement `BB.Parameter.Protocol` +- Bridges implement `BB.Bridge` - Use `init/2` and `handle_change/3` for outbound (local changes) - Use `list_remote/1`, `get_remote/2`, `set_remote/3` for inbound (remote access) - Each bridge is supervised independently for fault isolation diff --git a/lib/bb/actuator.ex b/lib/bb/actuator.ex index 35f2b16..70efd79 100644 --- a/lib/bb/actuator.ex +++ b/lib/bb/actuator.ex @@ -4,12 +4,91 @@ defmodule BB.Actuator do @moduledoc """ - Interface for sending commands to actuators. + Behaviour and API for actuator GenServers in the BB framework. + + This module serves two purposes: + + 1. **Behaviour** - Defines callbacks for actuator implementations + 2. **API** - Provides functions for sending commands to actuators + + ## Behaviour + + Actuators receive position/velocity/effort commands and drive hardware. + They must implement the `disarm/1` callback for safety. + + ## Usage + + The `use BB.Actuator` macro: + - Adds `use GenServer` (you must implement GenServer callbacks) + - Adds `@behaviour BB.Actuator` + - Optionally defines `options_schema/0` if you pass the `:options_schema` option + + ### Required Callbacks + + - `disarm/1` - Called when the robot is disarmed or crashes. Must work without + GenServer state (the process may have crashed). + + ### Options Schema + + If your actuator accepts configuration options, pass them via `:options_schema`: + + defmodule MyServoActuator do + use BB.Actuator, + options_schema: [ + channel: [type: {:in, 0..15}, required: true, doc: "PWM channel"], + controller: [type: :atom, required: true, doc: "Controller name"] + ] + + @impl BB.Actuator + def disarm(opts) do + # Make hardware safe - called without GenServer state + MyHardware.disable(opts[:controller], opts[:channel]) + :ok + end + + @impl GenServer + def init(opts) do + channel = Keyword.fetch!(opts, :channel) + bb = Keyword.fetch!(opts, :bb) + + BB.Safety.register(__MODULE__, + robot: bb.robot, + path: bb.path, + opts: [channel: channel, controller: opts[:controller]] + ) + + {:ok, %{channel: channel, bb: bb}} + end + end + + For actuators that don't need configuration, omit `:options_schema`: + + defmodule SimpleActuator do + use BB.Actuator + + # Must be used as bare module in DSL: actuator :motor, SimpleActuator + + @impl BB.Actuator + def disarm(_opts), do: :ok + + @impl GenServer + def init(opts) do + bb = Keyword.fetch!(opts, :bb) + {:ok, %{bb: bb}} + end + end + + ### Auto-injected Options + + The `:bb` option is automatically provided by the supervisor and should + NOT be included in your `options_schema`. It contains `%{robot: module, path: [atom]}`. + + ## API Supports both pubsub delivery (for orchestration, logging, replay) and direct GenServer delivery (for time-critical control paths). - ## Delivery Methods + ### Delivery Methods - **Pubsub** (`set_position/4`, etc.) - Commands published to `[:actuator | path]`. Enables logging, replay, and multi-subscriber patterns. Actuators receive @@ -21,7 +100,7 @@ defmodule BB.Actuator do - **Synchronous** (`set_position_sync/5`, etc.) - Commands sent via `BB.Process.call`. Returns acknowledgement or error. Actuators respond via `handle_call/3`. - ## Examples + ### Examples # Pubsub delivery (for kinematics/orchestration) BB.Actuator.set_position(MyRobot, [:base_link, :shoulder, :servo], 1.57) @@ -33,6 +112,56 @@ defmodule BB.Actuator do {:ok, :accepted} = BB.Actuator.set_position_sync(MyRobot, :shoulder_servo, 1.57) """ + # ---------------------------------------------------------------------------- + # Behaviour + # ---------------------------------------------------------------------------- + + @doc """ + Returns the options schema for this actuator. + + The schema should NOT include the `:bb` option - it is auto-injected. + If this callback is not implemented, the module cannot accept options + in the DSL (must be used as a bare module). + """ + @callback options_schema() :: Spark.Options.t() + + @doc """ + Make the hardware safe. + + Called with the opts provided at registration. Must work without GenServer state. + This callback is required for actuators since they control physical hardware. + """ + @callback disarm(opts :: keyword()) :: :ok | {:error, term()} + + @optional_callbacks [options_schema: 0] + + @doc false + defmacro __using__(opts) do + schema_opts = opts[:options_schema] + + quote do + use GenServer + @behaviour BB.Actuator + + unquote( + if schema_opts do + quote do + @__bb_options_schema Spark.Options.new!(unquote(schema_opts)) + + @impl BB.Actuator + def options_schema, do: @__bb_options_schema + + defoverridable options_schema: 0 + end + end + ) + end + end + + # ---------------------------------------------------------------------------- + # API + # ---------------------------------------------------------------------------- + alias BB.Message alias BB.Message.Actuator.Command diff --git a/lib/bb/parameter/protocol.ex b/lib/bb/bridge.ex similarity index 66% rename from lib/bb/parameter/protocol.ex rename to lib/bb/bridge.ex index 821de3d..3602afd 100644 --- a/lib/bb/parameter/protocol.ex +++ b/lib/bb/bridge.ex @@ -2,12 +2,15 @@ # # SPDX-License-Identifier: Apache-2.0 -defmodule BB.Parameter.Protocol do +defmodule BB.Bridge do @moduledoc """ - Behaviour for parameter protocol transports (bridges). + Behaviour for parameter bridge GenServers in the BB framework. Bridges provide bidirectional parameter access between BB and remote systems - (flight controllers, GCS, web UIs, etc.). + (GCS, web UIs, flight controllers). + + Bridges do NOT implement safety callbacks - they handle data transport, + not physical hardware control. ## Two Directions @@ -23,6 +26,53 @@ defmodule BB.Parameter.Protocol do - Implement `subscribe_remote/2` to subscribe to remote changes - Publish remote changes via PubSub (path structure up to bridge) + ## Usage + + The `use BB.Bridge` macro: + - Adds `use GenServer` (you must implement GenServer callbacks) + - Adds `@behaviour BB.Bridge` + - Optionally defines `options_schema/0` if you pass the `:options_schema` option + + ## Options Schema + + If your bridge accepts configuration options, pass them via `:options_schema`: + + defmodule MyMavlinkBridge do + use BB.Bridge, + options_schema: [ + port: [type: :string, required: true, doc: "Serial port path"], + baud_rate: [type: :pos_integer, default: 57600, doc: "Baud rate"] + ] + + @impl BB.Bridge + def handle_change(_robot, changed, state) do + send_to_gcs(state.conn, changed) + {:ok, state} + end + + # ... other BB.Bridge callbacks + end + + For bridges that don't need configuration, omit `:options_schema`: + + defmodule SimpleBridge do + use BB.Bridge + + # Must be used as bare module in DSL: bridge :simple, SimpleBridge + end + + ## DSL Usage + + parameters do + bridge :mavlink, {MyMavlinkBridge, port: "/dev/ttyACM0", baud_rate: 115200} + bridge :phoenix, {PhoenixBridge, url: "ws://gcs.local/socket"} + end + + ## Auto-injected Options + + The `:bb` option is automatically provided by the supervisor and should + NOT be included in your `options_schema`. It contains `%{robot: module, path: [atom]}`. + ## IEx Usage ```elixir @@ -48,8 +98,7 @@ defmodule BB.Parameter.Protocol do ```elixir defmodule MyMavlinkBridge do - use GenServer - @behaviour BB.Parameter.Protocol + use BB.Bridge # Define a payload type for remote param change messages defmodule ParamValue do @@ -69,14 +118,14 @@ defmodule BB.Parameter.Protocol do end # Outbound: local param changed, notify remote - @impl BB.Parameter.Protocol + @impl BB.Bridge def handle_change(_robot, changed, state) do send_param_to_gcs(state.conn, changed) {:ok, state} end # Inbound: list remote params - @impl BB.Parameter.Protocol + @impl BB.Bridge def list_remote(state) do # Return params with path for PubSub subscriptions params = Enum.map(fetch_all_params_from_fc(state.conn), fn {id, value} -> @@ -86,21 +135,21 @@ defmodule BB.Parameter.Protocol do end # Inbound: get remote param - @impl BB.Parameter.Protocol + @impl BB.Bridge def get_remote(param_id, state) do value = fetch_param_from_fc(state.conn, param_id) {:ok, value, state} end # Inbound: set remote param - @impl BB.Parameter.Protocol + @impl BB.Bridge def set_remote(param_id, value, state) do :ok = send_param_set_to_fc(state.conn, param_id, value) {:ok, state} end # Inbound: subscribe to remote param changes - @impl BB.Parameter.Protocol + @impl BB.Bridge def subscribe_remote(param_id, state) do {:ok, %{state | subscriptions: MapSet.put(state.subscriptions, param_id)}} end @@ -137,6 +186,19 @@ defmodule BB.Parameter.Protocol do path: [atom()] | nil } + # ========================================================================== + # Configuration + # ========================================================================== + + @doc """ + Returns the options schema for this bridge. + + The schema should NOT include the `:bb` option - it is auto-injected. + If this callback is not implemented, the module cannot accept options + in the DSL (must be used as a bare module). + """ + @callback options_schema() :: Spark.Options.t() + # ========================================================================== # Outbound: local → remote # ========================================================================== @@ -179,5 +241,34 @@ defmodule BB.Parameter.Protocol do """ @callback subscribe_remote(param_id, state) :: {:ok, state} | {:error, term(), state} - @optional_callbacks [list_remote: 1, get_remote: 2, set_remote: 3, subscribe_remote: 2] + @optional_callbacks [ + options_schema: 0, + list_remote: 1, + get_remote: 2, + set_remote: 3, + subscribe_remote: 2 + ] + + @doc false + defmacro __using__(opts) do + schema_opts = opts[:options_schema] + + quote do + use GenServer + @behaviour BB.Bridge + + unquote( + if schema_opts do + quote do + @__bb_options_schema Spark.Options.new!(unquote(schema_opts)) + + @impl BB.Bridge + def options_schema, do: @__bb_options_schema + + defoverridable options_schema: 0 + end + end + ) + end + end end diff --git a/lib/bb/controller.ex b/lib/bb/controller.ex new file mode 100644 index 0000000..bb958ba --- /dev/null +++ b/lib/bb/controller.ex @@ -0,0 +1,114 @@ +# SPDX-FileCopyrightText: 2025 James Harton +# +# SPDX-License-Identifier: Apache-2.0 + +defmodule BB.Controller do + @moduledoc """ + Behaviour for controller GenServers in the BB framework. + + Controllers manage hardware communication (I2C buses, serial ports, etc.) + and are typically shared by multiple actuators. They run at the robot level + and are supervised by `BB.ControllerSupervisor`. + + ## Usage + + The `use BB.Controller` macro: + - Adds `use GenServer` (you must implement GenServer callbacks) + - Adds `@behaviour BB.Controller` + - Optionally defines `options_schema/0` if you pass the `:options_schema` option + + ## Options Schema + + If your controller accepts configuration options, pass them via `:options_schema`: + + defmodule MyI2CController do + use BB.Controller, + options_schema: [ + bus: [type: :string, required: true, doc: "I2C bus name"], + address: [type: :integer, required: true, doc: "I2C device address"] + ] + + @impl GenServer + def init(opts) do + bus = Keyword.fetch!(opts, :bus) + address = Keyword.fetch!(opts, :address) + bb = Keyword.fetch!(opts, :bb) + {:ok, %{bus: bus, address: address, bb: bb}} + end + end + + For controllers that don't need configuration, omit `:options_schema`: + + defmodule SimpleController do + use BB.Controller + + # Must be used as bare module in DSL: controller :foo, SimpleController + + @impl GenServer + def init(opts) do + bb = Keyword.fetch!(opts, :bb) + {:ok, %{bb: bb}} + end + end + + ## Safety + + If your controller manages hardware that needs to be made safe when disarmed, + implement the optional `disarm/1` callback: + + defmodule MyController do + use BB.Controller, options_schema: [bus: [type: :string, required: true]] + + @impl BB.Controller + def disarm(opts), do: disable_hardware(opts[:bus]) + end + + ## Auto-injected Options + + The `:bb` option is automatically provided by the supervisor and should + NOT be included in your `options_schema`. It contains `%{robot: module, path: [atom]}`. + """ + + @doc """ + Returns the options schema for this controller. + + The schema should NOT include the `:bb` option - it is auto-injected. + If this callback is not implemented, the module cannot accept options + in the DSL (must be used as a bare module). + """ + @callback options_schema() :: Spark.Options.t() + + @doc """ + Make the hardware safe. + + Called with the opts provided at registration. Must work without GenServer state. + Only implement this if your controller manages hardware that needs to be disabled + when the robot is disarmed or crashes. + """ + @callback disarm(opts :: keyword()) :: :ok | {:error, term()} + + @optional_callbacks [options_schema: 0, disarm: 1] + + @doc false + defmacro __using__(opts) do + schema_opts = opts[:options_schema] + + quote do + use GenServer + @behaviour BB.Controller + + unquote( + if schema_opts do + quote do + @__bb_options_schema Spark.Options.new!(unquote(schema_opts)) + + @impl BB.Controller + def options_schema, do: @__bb_options_schema + + defoverridable options_schema: 0 + end + end + ) + end + end +end diff --git a/lib/bb/dsl.ex b/lib/bb/dsl.ex index be09c91..7812c36 100644 --- a/lib/bb/dsl.ex +++ b/lib/bb/dsl.ex @@ -228,7 +228,8 @@ defmodule BB.Dsl do doc: "A unique name for the sensor" ], child_spec: [ - type: {:or, [:module, {:tuple, [:module, :keyword_list]}]}, + type: + {:or, [{:behaviour, BB.Sensor}, {:tuple, [{:behaviour, BB.Sensor}, :keyword_list]}]}, required: true, doc: "The child specification for the sensor process. Either a module or `{module, keyword_list}`" @@ -252,7 +253,8 @@ defmodule BB.Dsl do doc: "A unique name for the actuator" ], child_spec: [ - type: {:or, [:module, {:tuple, [:module, :keyword_list]}]}, + type: + {:or, [{:behaviour, BB.Actuator}, {:tuple, [{:behaviour, BB.Actuator}, :keyword_list]}]}, required: true, doc: "The child specification for the actuator process. Either a module or `{module, keyword_list}`" @@ -670,7 +672,9 @@ defmodule BB.Dsl do doc: "A unique name for the controller" ], child_spec: [ - type: {:or, [:module, {:tuple, [:module, :keyword_list]}]}, + type: + {:or, + [{:behaviour, BB.Controller}, {:tuple, [{:behaviour, BB.Controller}, :keyword_list]}]}, required: true, doc: "The child specification for the controller process. Either a module or `{module, keyword_list}`" @@ -804,7 +808,7 @@ defmodule BB.Dsl do A parameter protocol bridge for remote access. Bridges expose robot parameters to remote clients (GCS, web UI, etc.) - and receive parameter updates from them. They implement `BB.Parameter.Protocol`. + and receive parameter updates from them. They implement `BB.Bridge`. ## Example @@ -824,11 +828,7 @@ defmodule BB.Dsl do ], child_spec: [ type: - {:or, - [ - {:behaviour, BB.Parameter.Protocol}, - {:tuple, [{:behaviour, BB.Parameter.Protocol}, :keyword_list]} - ]}, + {:or, [{:behaviour, BB.Bridge}, {:tuple, [{:behaviour, BB.Bridge}, :keyword_list]}]}, required: true, doc: "The child specification for the bridge process. Either a module or `{module, keyword_list}`" @@ -878,5 +878,8 @@ defmodule BB.Dsl do __MODULE__.RobotTransformer, __MODULE__.CommandTransformer, __MODULE__.ParameterTransformer + ], + verifiers: [ + __MODULE__.Verifiers.ValidateChildSpecs ] end diff --git a/lib/bb/dsl/verifiers/validate_child_specs.ex b/lib/bb/dsl/verifiers/validate_child_specs.ex new file mode 100644 index 0000000..d355f42 --- /dev/null +++ b/lib/bb/dsl/verifiers/validate_child_specs.ex @@ -0,0 +1,234 @@ +# SPDX-FileCopyrightText: 2025 James Harton +# +# SPDX-License-Identifier: Apache-2.0 + +defmodule BB.Dsl.Verifiers.ValidateChildSpecs do + @moduledoc """ + Validates that child_spec options match the module's schema. + + Behaviour validation is handled by Spark's schema types (e.g., `{:behaviour, BB.Sensor}`). + This verifier handles the additional validation: + + - If options are provided in the DSL (as `{Module, opts}` tuple), + the module must define `options_schema/0` + - If `options_schema/0` is defined, the provided options are validated + against that schema + """ + + use Spark.Dsl.Verifier + + alias BB.Dsl.{Actuator, Bridge, Controller, Joint, Link, Sensor} + alias Spark.Dsl.Verifier + alias Spark.Error.DslError + + @impl true + def verify(dsl_state) do + module = Verifier.get_persisted(dsl_state, :module) + + with :ok <- verify_controllers(dsl_state, module), + :ok <- verify_robot_sensors(dsl_state, module), + :ok <- verify_topology(dsl_state, module) do + verify_bridges(dsl_state, module) + end + end + + defp verify_controllers(dsl_state, robot_module) do + dsl_state + |> Verifier.get_entities([:controllers]) + |> Enum.reduce_while(:ok, fn %Controller{} = controller, :ok -> + case validate_child_spec( + controller.child_spec, + [:controllers, controller.name], + robot_module + ) do + :ok -> {:cont, :ok} + {:error, error} -> {:halt, {:error, error}} + end + end) + end + + defp verify_robot_sensors(dsl_state, robot_module) do + dsl_state + |> Verifier.get_entities([:sensors]) + |> Enum.reduce_while(:ok, fn %Sensor{} = sensor, :ok -> + case validate_child_spec( + sensor.child_spec, + [:sensors, sensor.name], + robot_module + ) do + :ok -> {:cont, :ok} + {:error, error} -> {:halt, {:error, error}} + end + end) + end + + defp verify_bridges(dsl_state, robot_module) do + dsl_state + |> Verifier.get_entities([:parameters]) + |> Enum.filter(&is_struct(&1, Bridge)) + |> Enum.reduce_while(:ok, fn %Bridge{} = bridge, :ok -> + case validate_child_spec( + bridge.child_spec, + [:parameters, bridge.name], + robot_module + ) do + :ok -> {:cont, :ok} + {:error, error} -> {:halt, {:error, error}} + end + end) + end + + defp verify_topology(dsl_state, robot_module) do + dsl_state + |> Verifier.get_entities([:topology]) + |> verify_topology_entities([], robot_module) + end + + defp verify_topology_entities(entities, path, robot_module) do + Enum.reduce_while(entities, :ok, fn entity, :ok -> + case verify_topology_entity(entity, path, robot_module) do + :ok -> {:cont, :ok} + {:error, error} -> {:halt, {:error, error}} + end + end) + end + + defp verify_topology_entity(%Link{} = link, path, robot_module) do + link_path = path ++ [:topology, :link, link.name] + + with :ok <- verify_link_sensors(link, link_path, robot_module) do + verify_topology_entities(link.joints, link_path, robot_module) + end + end + + defp verify_topology_entity(%Joint{} = joint, path, robot_module) do + joint_path = path ++ [:joint, joint.name] + + with :ok <- verify_joint_sensors(joint, joint_path, robot_module), + :ok <- verify_joint_actuators(joint, joint_path, robot_module) do + verify_nested_link(joint, path, robot_module) + end + end + + defp verify_topology_entity(_entity, _path, _robot_module), do: :ok + + defp verify_link_sensors(%Link{sensors: sensors}, path, robot_module) do + Enum.reduce_while(sensors, :ok, fn %Sensor{} = sensor, :ok -> + sensor_path = path ++ [:sensor, sensor.name] + + case validate_child_spec(sensor.child_spec, sensor_path, robot_module) do + :ok -> {:cont, :ok} + {:error, error} -> {:halt, {:error, error}} + end + end) + end + + defp verify_joint_sensors(%Joint{} = joint, path, robot_module) do + sensors = Map.get(joint, :sensors, []) + + Enum.reduce_while(sensors, :ok, fn %Sensor{} = sensor, :ok -> + sensor_path = path ++ [:sensor, sensor.name] + + case validate_child_spec(sensor.child_spec, sensor_path, robot_module) do + :ok -> {:cont, :ok} + {:error, error} -> {:halt, {:error, error}} + end + end) + end + + defp verify_joint_actuators(%Joint{} = joint, path, robot_module) do + actuators = Map.get(joint, :actuators, []) + + Enum.reduce_while(actuators, :ok, fn %Actuator{} = actuator, :ok -> + actuator_path = path ++ [:actuator, actuator.name] + + case validate_child_spec(actuator.child_spec, actuator_path, robot_module) do + :ok -> {:cont, :ok} + {:error, error} -> {:halt, {:error, error}} + end + end) + end + + defp verify_nested_link(%Joint{} = joint, path, robot_module) do + case Map.get(joint, :link) do + nil -> :ok + nested_link -> verify_topology_entity(nested_link, path, robot_module) + end + end + + defp validate_child_spec(child_spec, path, robot_module) do + {module, opts} = normalize_child_spec(child_spec) + validate_options(module, opts, path, robot_module) + end + + defp normalize_child_spec(module) when is_atom(module), do: {module, []} + defp normalize_child_spec({module, opts}) when is_atom(module), do: {module, opts} + + defp validate_options(module, opts, path, robot_module) do + Code.ensure_loaded(module) + has_schema? = function_exported?(module, :options_schema, 0) + has_opts? = opts != [] + + cond do + has_opts? and not has_schema? -> + {:error, + DslError.exception( + module: robot_module, + path: path, + message: """ + Module #{inspect(module)} does not define options_schema/0 but options were provided. + + Either: + 1. Use the module without options: #{inspect(module)} + 2. Add options_schema/0 to #{inspect(module)} to accept options + """ + )} + + has_schema? -> + schema = module.options_schema() + + case Spark.Options.validate(opts, schema) do + {:ok, _validated} -> + :ok + + {:error, %Spark.Options.ValidationError{} = error} -> + {:error, + DslError.exception( + module: robot_module, + path: path, + message: """ + Invalid options for #{inspect(module)}: + + #{Exception.message(error)} + + Expected schema: + #{format_schema(schema)} + """ + )} + end + + true -> + :ok + end + end + + defp format_schema(%Spark.Options{schema: schema}) do + format_schema(schema) + end + + defp format_schema(schema) when is_list(schema) do + Enum.map_join(schema, "\n", fn {key, opts} -> + type = Keyword.get(opts, :type, :any) + required = if Keyword.get(opts, :required, false), do: " (required)", else: "" + + default = + if Keyword.has_key?(opts, :default), + do: " [default: #{inspect(Keyword.get(opts, :default))}]", + else: "" + + doc = if opts[:doc], do: " - #{opts[:doc]}", else: "" + + " #{key}: #{inspect(type)}#{required}#{default}#{doc}" + end) + end +end diff --git a/lib/bb/parameter.ex b/lib/bb/parameter.ex index f712e87..3d61100 100644 --- a/lib/bb/parameter.ex +++ b/lib/bb/parameter.ex @@ -224,7 +224,7 @@ defmodule BB.Parameter do {:ok, 0.15} = BB.Parameter.get_remote(MyRobot, :mavlink, "PITCH_RATE_P") """ - @spec get_remote(module(), atom(), BB.Parameter.Protocol.param_id()) :: + @spec get_remote(module(), atom(), BB.Bridge.param_id()) :: {:ok, term()} | {:error, term()} def get_remote(robot_module, bridge_name, param_id) when is_atom(robot_module) and is_atom(bridge_name) do @@ -240,7 +240,7 @@ defmodule BB.Parameter do :ok = BB.Parameter.set_remote(MyRobot, :mavlink, "PITCH_RATE_P", 0.15) """ - @spec set_remote(module(), atom(), BB.Parameter.Protocol.param_id(), term()) :: + @spec set_remote(module(), atom(), BB.Bridge.param_id(), term()) :: :ok | {:error, term()} def set_remote(robot_module, bridge_name, param_id, value) when is_atom(robot_module) and is_atom(bridge_name) do @@ -259,7 +259,7 @@ defmodule BB.Parameter do :ok = BB.Parameter.subscribe_remote(MyRobot, :mavlink, "PITCH_RATE_P") """ - @spec subscribe_remote(module(), atom(), BB.Parameter.Protocol.param_id()) :: + @spec subscribe_remote(module(), atom(), BB.Bridge.param_id()) :: :ok | {:error, term()} def subscribe_remote(robot_module, bridge_name, param_id) when is_atom(robot_module) and is_atom(bridge_name) do diff --git a/lib/bb/safety.ex b/lib/bb/safety.ex index 914ff1a..822abf1 100644 --- a/lib/bb/safety.ex +++ b/lib/bb/safety.ex @@ -4,17 +4,11 @@ defmodule BB.Safety do @moduledoc """ - Safety system behaviour and API. + Safety system API. - Actuators, sensors, and controllers can implement the `BB.Safety` behaviour. - The `disarm/1` callback is called by `BB.Safety.Controller` when: - - - The robot is disarmed via command - - The robot supervisor crashes - - The callback receives the opts provided at registration and must be able to - disable hardware without access to any GenServer state. This ensures safety - even when the actuator process is dead. + This module provides the API for arming/disarming robots and managing safety state. + The `disarm/1` callback that components implement is now defined in `BB.Controller` + and `BB.Actuator` behaviours. ## Safety States @@ -29,13 +23,15 @@ defmodule BB.Safety do Disarm callbacks run concurrently with a timeout. If any callback fails or times out, the robot transitions to `:error` state. - ## Example + ## Implementing Disarm Callbacks + + Controllers and actuators implement the `disarm/1` callback via their behaviours: defmodule MyActuator do use GenServer - @behaviour BB.Safety + use BB.Actuator - @impl BB.Safety + @impl BB.Actuator def disarm(opts) do pin = Keyword.fetch!(opts, :pin) MyHardware.disable(pin) @@ -54,7 +50,7 @@ defmodule BB.Safety do If your actuator doesn't need special disarm logic, you can implement a no-op: - @impl BB.Safety + @impl BB.Actuator def disarm(_opts), do: :ok ## Important Limitations @@ -67,13 +63,6 @@ defmodule BB.Safety do See the Safety documentation topic for detailed recommendations. """ - @doc """ - Make the hardware safe. - - Called with the opts provided at registration. Must work without GenServer state. - """ - @callback disarm(opts :: keyword()) :: :ok | {:error, term()} - # --- API (delegates to Controller) --- @doc """ diff --git a/lib/bb/sensor.ex b/lib/bb/sensor.ex new file mode 100644 index 0000000..cf275f5 --- /dev/null +++ b/lib/bb/sensor.ex @@ -0,0 +1,128 @@ +# SPDX-FileCopyrightText: 2025 James Harton +# +# SPDX-License-Identifier: Apache-2.0 + +defmodule BB.Sensor do + @moduledoc """ + Behaviour for sensor GenServers in the BB framework. + + Sensors read from hardware or other sources and publish messages. They can + be attached at the robot level, to links, or to joints. + + ## Usage + + The `use BB.Sensor` macro: + - Adds `use GenServer` (you must implement GenServer callbacks) + - Adds `@behaviour BB.Sensor` + - Optionally defines `options_schema/0` if you pass the `:options_schema` option + + ## Options Schema + + If your sensor accepts configuration options, pass them via `:options_schema`: + + defmodule MyTemperatureSensor do + use BB.Sensor, + options_schema: [ + bus: [type: :string, required: true, doc: "I2C bus name"], + address: [type: :integer, required: true, doc: "I2C device address"], + poll_interval_ms: [type: :pos_integer, default: 1000, doc: "Poll interval"] + ] + + @impl GenServer + def init(opts) do + # Options already validated at compile time + bus = Keyword.fetch!(opts, :bus) + address = Keyword.fetch!(opts, :address) + bb = Keyword.fetch!(opts, :bb) + {:ok, %{bus: bus, address: address, bb: bb}} + end + end + + You can override the generated `options_schema/0` if needed: + + defmodule MySensor do + use BB.Sensor, options_schema: [frequency: [type: :pos_integer, default: 50]] + + @impl BB.Sensor + def options_schema do + # Custom implementation + Spark.Options.new!([...]) + end + end + + For sensors that don't need configuration, omit `:options_schema`: + + defmodule SimpleSensor do + use BB.Sensor + + # Must be used as bare module in DSL: sensor :temp, SimpleSensor + + @impl GenServer + def init(opts) do + bb = Keyword.fetch!(opts, :bb) + {:ok, %{bb: bb}} + end + end + + ## Safety + + Most sensors don't require safety callbacks since they only read data. + If your sensor controls hardware that needs to be disabled on disarm + (e.g., a spinning LIDAR), implement the optional `disarm/1` callback: + + defmodule MyHardwareSensor do + use BB.Sensor + + @impl BB.Sensor + def disarm(opts), do: stop_hardware(opts) + end + + ## Auto-injected Options + + The `:bb` option is automatically provided by the supervisor and should + NOT be included in your `options_schema`. It contains `%{robot: module, path: [atom]}`. + """ + + @doc """ + Returns the options schema for this sensor. + + The schema should NOT include the `:bb` option - it is auto-injected. + If this callback is not implemented, the module cannot accept options + in the DSL (must be used as a bare module). + """ + @callback options_schema() :: Spark.Options.t() + + @doc """ + Make the hardware safe. + + Called with the opts provided at registration. Must work without GenServer state. + This callback is optional for sensors - only implement it if your sensor + controls hardware that needs to be disabled on disarm (e.g., a spinning LIDAR). + """ + @callback disarm(opts :: keyword()) :: :ok | {:error, term()} + + @optional_callbacks [options_schema: 0, disarm: 1] + + @doc false + defmacro __using__(opts) do + schema_opts = opts[:options_schema] + + quote do + use GenServer + @behaviour BB.Sensor + + unquote( + if schema_opts do + quote do + @__bb_options_schema Spark.Options.new!(unquote(schema_opts)) + + @impl BB.Sensor + def options_schema, do: @__bb_options_schema + + defoverridable options_schema: 0 + end + end + ) + end + end +end diff --git a/lib/bb/sensor/open_loop_position_estimator.ex b/lib/bb/sensor/open_loop_position_estimator.ex index 347b471..e613670 100644 --- a/lib/bb/sensor/open_loop_position_estimator.ex +++ b/lib/bb/sensor/open_loop_position_estimator.ex @@ -81,7 +81,7 @@ defmodule BB.Sensor.OpenLoopPositionEstimator do :ease_out_circular, :ease_in_out_circular ] - use GenServer + use BB.Sensor import BB.Unit import BB.Unit.Option @@ -91,57 +91,55 @@ defmodule BB.Sensor.OpenLoopPositionEstimator do alias BB.Message.Sensor.JointState alias BB.Robot.Units - @options Spark.Options.new!( - bb: [ - type: :map, - doc: "Automatically set by the robot supervisor", - required: true - ], - actuator: [ - type: :atom, - doc: "Name of the actuator to subscribe to", - required: true - ], - easing: [ - type: {:in, @easing_functions}, - doc: "Easing function for position interpolation", - default: :linear - ], - publish_rate: [ - type: unit_type(compatible: :hertz), - doc: "Rate at which to publish position changes during motion", - default: ~u(50 hertz) - ], - max_silence: [ - type: unit_type(compatible: :second), - doc: "Maximum time between publishes when idle (heartbeat)", - default: ~u(5 second) - ] - ) + @impl BB.Sensor + def options_schema do + Spark.Options.new!( + actuator: [ + type: :atom, + doc: "Name of the actuator to subscribe to", + required: true + ], + easing: [ + type: {:in, @easing_functions}, + doc: "Easing function for position interpolation", + default: :linear + ], + publish_rate: [ + type: unit_type(compatible: :hertz), + doc: "Rate at which to publish position changes during motion", + default: ~u(50 hertz) + ], + max_silence: [ + type: unit_type(compatible: :second), + doc: "Maximum time between publishes when idle (heartbeat)", + default: ~u(5 second) + ] + ) + end @impl GenServer def init(opts) do - with {:ok, opts} <- Spark.Options.validate(opts, @options), - {:ok, state} <- build_state(opts) do - BB.subscribe(state.bb.robot, [:actuator | state.actuator_path]) - {:ok, state, state.max_silence_ms} - else - {:error, reason} -> {:stop, reason} - end + {:ok, state} = build_state(opts) + BB.subscribe(state.bb.robot, [:actuator | state.actuator_path]) + {:ok, state, state.max_silence_ms} end defp build_state(opts) do opts = Map.new(opts) [name, joint_name | _] = Enum.reverse(opts.bb.path) + easing = Map.get(opts, :easing, :linear) + publish_rate = Map.get(opts, :publish_rate, ~u(50 hertz)) + max_silence = Map.get(opts, :max_silence, ~u(5 second)) + publish_interval_ms = - opts.publish_rate + publish_rate |> CldrUnit.convert!(:hertz) |> Units.extract_float() |> then(&round(1000 / &1)) max_silence_ms = - opts.max_silence + max_silence |> CldrUnit.convert!(:second) |> Units.extract_float() |> then(&round(&1 * 1000)) @@ -152,7 +150,7 @@ defmodule BB.Sensor.OpenLoopPositionEstimator do bb: opts.bb, actuator: opts.actuator, actuator_path: actuator_path, - easing: opts.easing, + easing: easing, publish_interval_ms: publish_interval_ms, max_silence_ms: max_silence_ms, name: name, diff --git a/test/bb/parameter/protocol_test.exs b/test/bb/bridge_test.exs similarity index 99% rename from test/bb/parameter/protocol_test.exs rename to test/bb/bridge_test.exs index 35a4b8b..2661c6c 100644 --- a/test/bb/parameter/protocol_test.exs +++ b/test/bb/bridge_test.exs @@ -2,7 +2,7 @@ # # SPDX-License-Identifier: Apache-2.0 -defmodule BB.Parameter.ProtocolTest do +defmodule BB.BridgeTest do use ExUnit.Case, async: false alias BB.Parameter diff --git a/test/bb/controller_test.exs b/test/bb/controller_test.exs index 007ebf3..ddf6b7d 100644 --- a/test/bb/controller_test.exs +++ b/test/bb/controller_test.exs @@ -8,12 +8,18 @@ defmodule BB.ControllerTest do alias BB.Process, as: BBProcess defmodule TestGenServer do - use GenServer + use BB.Controller, + options_schema: [ + max_accel: [type: :float, required: false], + value: [type: :integer, required: false] + ] + @impl GenServer def init(opts) do {:ok, opts} end + @impl GenServer def handle_call(:get_state, _from, state) do {:reply, state, state} end diff --git a/test/bb/dsl/parameter_test.exs b/test/bb/dsl/parameter_test.exs index f83c97d..b4c5ad9 100644 --- a/test/bb/dsl/parameter_test.exs +++ b/test/bb/dsl/parameter_test.exs @@ -60,25 +60,34 @@ defmodule BB.Dsl.ParameterTest do defmodule TestSensor do @moduledoc false - use GenServer + use BB.Sensor def start_link(opts), do: GenServer.start_link(__MODULE__, opts) + + @impl GenServer def init(opts), do: {:ok, opts} end defmodule TestActuator do @moduledoc false - use GenServer + use BB.Actuator + + @impl BB.Actuator + def disarm(_opts), do: :ok def start_link(opts), do: GenServer.start_link(__MODULE__, opts) + + @impl GenServer def init(opts), do: {:ok, opts} end defmodule TestController do @moduledoc false - use GenServer + use BB.Controller def start_link(opts), do: GenServer.start_link(__MODULE__, opts) + + @impl GenServer def init(opts), do: {:ok, opts} end diff --git a/test/bb/supervisor_test.exs b/test/bb/supervisor_test.exs index 66b980e..a97676c 100644 --- a/test/bb/supervisor_test.exs +++ b/test/bb/supervisor_test.exs @@ -6,13 +6,38 @@ defmodule BB.SupervisorTest do use ExUnit.Case, async: true alias BB.Process, as: BBProcess - defmodule TestGenServer do - use GenServer + defmodule TestSensor do + @moduledoc """ + Test sensor GenServer for supervisor tests. + """ + use BB.Sensor, options_schema: [frequency: [type: :pos_integer, required: false]] + @impl GenServer def init(opts) do {:ok, opts} end + @impl GenServer + def handle_call(:get_state, _from, state) do + {:reply, state, state} + end + end + + defmodule TestActuator do + @moduledoc """ + Test actuator GenServer for supervisor tests. + """ + use BB.Actuator, options_schema: [pwm_pin: [type: :pos_integer, required: false]] + + @impl BB.Actuator + def disarm(_opts), do: :ok + + @impl GenServer + def init(opts) do + {:ok, opts} + end + + @impl GenServer def handle_call(:get_state, _from, state) do {:reply, state, state} end @@ -57,17 +82,17 @@ defmodule BB.SupervisorTest do use BB sensors do - sensor :camera, BB.SupervisorTest.TestGenServer + sensor :camera, BB.SupervisorTest.TestSensor end topology do link :base_link do - sensor :imu, {BB.SupervisorTest.TestGenServer, frequency: 100} + sensor :imu, {BB.SupervisorTest.TestSensor, frequency: 100} joint :shoulder do type :revolute - sensor :encoder, BB.SupervisorTest.TestGenServer + sensor :encoder, BB.SupervisorTest.TestSensor limit do effort(~u(10 newton_meter)) @@ -115,8 +140,8 @@ defmodule BB.SupervisorTest do joint :shoulder do type :revolute - actuator :motor, {BB.SupervisorTest.TestGenServer, pwm_pin: 12} - actuator :brake, BB.SupervisorTest.TestGenServer + actuator :motor, {BB.SupervisorTest.TestActuator, pwm_pin: 12} + actuator :brake, BB.SupervisorTest.TestActuator limit do effort(~u(10 newton_meter)) @@ -160,7 +185,7 @@ defmodule BB.SupervisorTest do link :base do joint :shoulder do type :revolute - actuator :shoulder_motor, BB.SupervisorTest.TestGenServer + actuator :shoulder_motor, BB.SupervisorTest.TestActuator limit do effort(~u(10 newton_meter)) @@ -170,7 +195,7 @@ defmodule BB.SupervisorTest do link :upper_arm do joint :elbow do type :revolute - actuator :elbow_motor, BB.SupervisorTest.TestGenServer + actuator :elbow_motor, BB.SupervisorTest.TestActuator limit do effort(~u(5 newton_meter)) @@ -180,7 +205,7 @@ defmodule BB.SupervisorTest do link :forearm do joint :wrist do type :revolute - actuator :wrist_motor, BB.SupervisorTest.TestGenServer + actuator :wrist_motor, BB.SupervisorTest.TestActuator limit do effort(~u(2 newton_meter)) @@ -227,7 +252,7 @@ defmodule BB.SupervisorTest do topology do link :base do - sensor :test_sensor, BB.SupervisorTest.TestGenServer + sensor :test_sensor, BB.SupervisorTest.TestSensor end end end diff --git a/test/support/failing_actuator.ex b/test/support/failing_actuator.ex index e2cd948..65e1aea 100644 --- a/test/support/failing_actuator.ex +++ b/test/support/failing_actuator.ex @@ -4,10 +4,9 @@ defmodule BB.Test.FailingActuator do @moduledoc false - use GenServer - @behaviour BB.Safety + use BB.Actuator, options_schema: [fail_mode: [type: :atom, required: false]] - @impl BB.Safety + @impl BB.Actuator def disarm(opts) do case opts[:fail_mode] do :error -> {:error, :hardware_failure} diff --git a/test/support/mock_actuator.ex b/test/support/mock_actuator.ex index 5d16daa..9e32d51 100644 --- a/test/support/mock_actuator.ex +++ b/test/support/mock_actuator.ex @@ -6,7 +6,7 @@ defmodule BB.Test.MockActuator do @moduledoc """ Minimal mock actuator for testing. """ - use GenServer + use BB.Actuator, options_schema: [] def start_link(opts) do GenServer.start_link(__MODULE__, opts, name: via(opts)) @@ -16,6 +16,9 @@ defmodule BB.Test.MockActuator do BB.Process.via(opts[:bb][:robot], opts[:bb][:name]) end + @impl BB.Actuator + def disarm(_opts), do: :ok + @impl GenServer def init(opts) do {:ok, %{opts: opts}} @@ -36,3 +39,89 @@ defmodule BB.Test.MockActuator do {:noreply, state} end end + +# Aliases for various test module names +defmodule ServoMotor do + @moduledoc false + use BB.Actuator, + options_schema: [ + pwm_pin: [type: :pos_integer, required: false], + frequency: [type: :pos_integer, required: false] + ] + + @impl BB.Actuator + def disarm(_opts), do: :ok + + @impl GenServer + def init(opts), do: {:ok, %{bb: Keyword.fetch!(opts, :bb)}} +end + +defmodule MainMotor do + @moduledoc false + use BB.Actuator + + @impl BB.Actuator + def disarm(_opts), do: :ok + + @impl GenServer + def init(opts), do: {:ok, %{bb: Keyword.fetch!(opts, :bb)}} +end + +defmodule BrakeActuator do + @moduledoc false + use BB.Actuator, options_schema: [pin: [type: :pos_integer, required: false]] + + @impl BB.Actuator + def disarm(_opts), do: :ok + + @impl GenServer + def init(opts), do: {:ok, %{bb: Keyword.fetch!(opts, :bb)}} +end + +defmodule ShoulderMotor do + @moduledoc false + use BB.Actuator + + @impl BB.Actuator + def disarm(_opts), do: :ok + + @impl GenServer + def init(opts), do: {:ok, %{bb: Keyword.fetch!(opts, :bb)}} +end + +defmodule ElbowMotor do + @moduledoc false + use BB.Actuator + + @impl BB.Actuator + def disarm(_opts), do: :ok + + @impl GenServer + def init(opts), do: {:ok, %{bb: Keyword.fetch!(opts, :bb)}} +end + +defmodule MyMotor do + @moduledoc false + use BB.Actuator + + @impl BB.Actuator + def disarm(_opts), do: :ok + + @impl GenServer + def init(opts), do: {:ok, %{bb: Keyword.fetch!(opts, :bb)}} +end + +defmodule TestActuator do + @moduledoc false + use BB.Actuator, + options_schema: [ + pin: [type: :pos_integer, required: false], + pwm_frequency: [type: :pos_integer, required: false] + ] + + @impl BB.Actuator + def disarm(_opts), do: :ok + + @impl GenServer + def init(opts), do: {:ok, %{bb: Keyword.fetch!(opts, :bb)}} +end diff --git a/test/support/test_parameter_bridge.ex b/test/support/test_parameter_bridge.ex index 341e094..7f54179 100644 --- a/test/support/test_parameter_bridge.ex +++ b/test/support/test_parameter_bridge.ex @@ -4,7 +4,7 @@ defmodule BB.Test.ParameterBridge do @moduledoc """ - Reference implementation of `BB.Parameter.Protocol` for testing. + Reference implementation of `BB.Bridge` for testing. Records all calls for test assertions and provides controllable responses. @@ -26,8 +26,7 @@ defmodule BB.Test.ParameterBridge do assert_receive {:bridge_change, %BB.Parameter.Changed{}} """ - use GenServer - @behaviour BB.Parameter.Protocol + use BB.Bridge defmodule RemoteParamValue do @moduledoc false @@ -40,8 +39,8 @@ defmodule BB.Test.ParameterBridge do robot: module(), test_pid: pid() | nil, calls: [{atom(), list()}], - remote_params: %{BB.Parameter.Protocol.param_id() => term()}, - subscriptions: MapSet.t(BB.Parameter.Protocol.param_id()) + remote_params: %{BB.Bridge.param_id() => term()}, + subscriptions: MapSet.t(BB.Bridge.param_id()) } # Client API @@ -90,9 +89,9 @@ defmodule BB.Test.ParameterBridge do GenServer.call(pid, {:simulate_remote_change, param_id, value}) end - # BB.Parameter.Protocol callbacks + # BB.Bridge callbacks - @impl BB.Parameter.Protocol + @impl BB.Bridge def handle_change(_robot, changed, state) do state = record_call(state, :handle_change, [changed]) @@ -103,7 +102,7 @@ defmodule BB.Test.ParameterBridge do {:ok, state} end - @impl BB.Parameter.Protocol + @impl BB.Bridge def list_remote(state) do params = Enum.map(state.remote_params, fn {id, value} -> @@ -114,7 +113,7 @@ defmodule BB.Test.ParameterBridge do {:ok, params, state} end - @impl BB.Parameter.Protocol + @impl BB.Bridge def get_remote(param_id, state) do case Map.fetch(state.remote_params, param_id) do {:ok, value} -> {:ok, value, state} @@ -122,13 +121,13 @@ defmodule BB.Test.ParameterBridge do end end - @impl BB.Parameter.Protocol + @impl BB.Bridge def set_remote(param_id, value, state) do state = %{state | remote_params: Map.put(state.remote_params, param_id, value)} {:ok, state} end - @impl BB.Parameter.Protocol + @impl BB.Bridge def subscribe_remote(param_id, state) do state = %{state | subscriptions: MapSet.put(state.subscriptions, param_id)} {:ok, state} diff --git a/test/support/test_sensor.ex b/test/support/test_sensor.ex new file mode 100644 index 0000000..a285536 --- /dev/null +++ b/test/support/test_sensor.ex @@ -0,0 +1,105 @@ +# SPDX-FileCopyrightText: 2025 James Harton +# +# SPDX-License-Identifier: Apache-2.0 + +defmodule MySensor do + @moduledoc """ + A minimal test sensor that implements BB.Sensor behaviour. + Used in DSL tests where a sensor module is required. + """ + use BB.Sensor, + options_schema: [ + frequency: [ + type: :pos_integer, + doc: "Sample frequency", + default: 50 + ] + ] + + @impl GenServer + def init(opts) do + {:ok, %{bb: Keyword.fetch!(opts, :bb)}} + end +end + +# Aliases for various test module names used in sensor_test.exs + +defmodule CameraSensor do + @moduledoc false + use BB.Sensor + + @impl GenServer + def init(opts), do: {:ok, %{bb: Keyword.fetch!(opts, :bb)}} +end + +defmodule ImuSensor do + @moduledoc false + use BB.Sensor + + @impl GenServer + def init(opts), do: {:ok, %{bb: Keyword.fetch!(opts, :bb)}} +end + +defmodule GpsSensor do + @moduledoc false + use BB.Sensor, options_schema: [port: [type: :string, required: false]] + + @impl GenServer + def init(opts), do: {:ok, %{bb: Keyword.fetch!(opts, :bb)}} +end + +defmodule BaseSensor do + @moduledoc false + use BB.Sensor + + @impl GenServer + def init(opts), do: {:ok, %{bb: Keyword.fetch!(opts, :bb)}} +end + +defmodule ChildSensor do + @moduledoc false + use BB.Sensor + + @impl GenServer + def init(opts), do: {:ok, %{bb: Keyword.fetch!(opts, :bb)}} +end + +defmodule Encoder do + @moduledoc false + use BB.Sensor, options_schema: [bus: [type: :atom, required: false]] + + @impl GenServer + def init(opts), do: {:ok, %{bb: Keyword.fetch!(opts, :bb)}} +end + +defmodule RobotSensor do + @moduledoc false + use BB.Sensor + + @impl GenServer + def init(opts), do: {:ok, %{bb: Keyword.fetch!(opts, :bb)}} +end + +defmodule LinkSensor do + @moduledoc false + use BB.Sensor + + @impl GenServer + def init(opts), do: {:ok, %{bb: Keyword.fetch!(opts, :bb)}} +end + +defmodule JointSensor do + @moduledoc false + use BB.Sensor + + @impl GenServer + def init(opts), do: {:ok, %{bb: Keyword.fetch!(opts, :bb)}} +end + +defmodule SomeSensor do + @moduledoc false + use BB.Sensor + + @impl GenServer + def init(opts), do: {:ok, %{bb: Keyword.fetch!(opts, :bb)}} +end