Current section

Files

Jump to
bb lib bb dsl.ex
Raw

lib/bb/dsl.ex

# SPDX-FileCopyrightText: 2025 James Harton
#
# SPDX-License-Identifier: Apache-2.0
defmodule BB.Dsl do
@moduledoc """
The DSL extension for describing robot properties and topologies.
"""
alias Spark.Dsl.Entity
alias Spark.Dsl.Section
import BB.Unit
import BB.Unit.Option
@origin %Entity{
name: :origin,
target: BB.Dsl.Origin,
identifier: {:auto, :unique_integer},
imports: [BB.Unit, BB.Dsl.ParamRef],
schema: [
roll: [
type: unit_type(compatible: :degree),
doc: "rotation around the `x` axis",
required: false,
default: ~u(0 degree)
],
pitch: [
type: unit_type(compatible: :degree),
doc: "rotation around the `y` axis",
required: false,
default: ~u(0 degree)
],
yaw: [
type: unit_type(compatible: :degree),
doc: "rotation around the `z` axis",
required: false,
default: ~u(0 degree)
],
x: [
type: unit_type(compatible: :meter),
doc: "translation along the `x` axis",
required: false,
default: ~u(0 meter)
],
y: [
type: unit_type(compatible: :meter),
doc: "translation along the `y` axis",
required: false,
default: ~u(0 meter)
],
z: [
type: unit_type(compatible: :meter),
doc: "translation along the `z` axis",
required: false,
default: ~u(0 meter)
]
]
}
@axis %Entity{
name: :axis,
describe: """
Joint axis orientation specified as Euler angles.
The axis defines the direction of rotation (for revolute joints) or
translation (for prismatic joints). By default, the axis points along
the Z direction. Use roll, pitch, and yaw to rotate it to the desired
orientation.
""",
target: BB.Dsl.Axis,
identifier: {:auto, :unique_integer},
imports: [BB.Unit, BB.Dsl.ParamRef],
schema: [
roll: [
type: unit_type(compatible: :degree),
doc: "rotation around the X axis",
required: false,
default: ~u(0 degree)
],
pitch: [
type: unit_type(compatible: :degree),
doc: "rotation around the Y axis",
required: false,
default: ~u(0 degree)
],
yaw: [
type: unit_type(compatible: :degree),
doc: "rotation around the Z axis",
required: false,
default: ~u(0 degree)
]
]
}
@dynamics %Entity{
name: :dynamics,
describe: """
An element specifying physical properties of the joint. These values are used to specify modeling properties of the joint, particularly useful for simulation.
""",
target: BB.Dsl.Dynamics,
identifier: {:auto, :unique_integer},
imports: [BB.Unit, BB.Dsl.ParamRef],
schema: [
damping: [
type:
{:or,
[
unit_type(compatible: :newton_second_per_meter),
unit_type(compatible: :newton_meter_second_per_degree)
]},
doc: "The physical damping value of the joint",
required: false
],
friction: [
type: {:or, [unit_type(compatible: :newton), unit_type(compatible: :newton_meter)]},
doc: "The physical static friction value of the joint",
required: false
]
]
}
@limit %Entity{
name: :limit,
describe: "Limits applied to joint movement",
target: BB.Dsl.Limit,
imports: [BB.Unit, BB.Dsl.ParamRef],
schema: [
lower: [
type: {:or, [unit_type(compatible: :degree), unit_type(compatible: :meter)]},
doc: "The lower joint limit",
required: false
],
upper: [
type: {:or, [unit_type(compatible: :degree), unit_type(compatible: :meter)]},
doc: "The upper joint limit",
required: false
],
effort: [
type: {:or, [unit_type(compatible: :newton), unit_type(compatible: :newton_meter)]},
doc:
"The maximum effort - both positive and negative - that can be commanded to the joint",
required: true
],
velocity: [
type:
{:or,
[
unit_type(compatible: :degree_per_second),
unit_type(compatible: :meter_per_second)
]},
doc: "Maximum velocity - both positive and negative - that can be commanded to the joint",
required: true
],
acceleration: [
type:
{:or,
[
unit_type(compatible: :degree_per_square_second),
unit_type(compatible: :meter_per_square_second)
]},
doc:
"Maximum acceleration - both positive and negative - that can be commanded to the joint. Optional; when omitted, motion timing assumes a rectangular velocity profile.",
required: false
]
]
}
@param %Entity{
name: :param,
describe: "A runtime-adjustable parameter.",
target: BB.Dsl.Param,
identifier: :name,
args: [:name],
imports: [BB.Unit],
schema: [
name: [
type: :atom,
required: true,
doc: "A unique name for the parameter"
],
type: [
type: {:custom, BB.Parameter.Type, :validate, []},
required: true,
doc:
"The parameter value type (:float, :integer, :boolean, :string, :atom, or {:unit, unit_type})"
],
default: [
type: :any,
required: false,
doc: "Default value for the parameter"
],
min: [
type: :number,
required: false,
doc: "Minimum value for numeric parameters"
],
max: [
type: :number,
required: false,
doc: "Maximum value for numeric parameters"
],
doc: [
type: :string,
required: false,
doc: "Documentation for the parameter"
]
]
}
@transmission %Entity{
name: :transmission,
describe: """
A mechanical transmission between this attachment (actuator or sensor)
and its joint.
Captures the relationship between joint-space and this attachment's
own coordinate space: gear reduction, zero-offset, and polarity.
Actuators with a non-identity transmission have URDF equivalents via
`<transmission>` (the actuator name and joint name name the pairing).
Sensor-side transmissions are a BB extension and do not round-trip
through URDF.
""",
target: BB.Dsl.Transmission,
identifier: {:auto, :unique_integer},
imports: [BB.Unit, BB.Dsl.ParamRef],
schema: [
reduction: [
type: {:or, [:float, {:struct, BB.Dsl.ParamRef}]},
doc:
"Gear ratio between attachment and joint. A reduction of `n` means the attachment rotates `n` times for one rotation of the joint. Defaults to `1.0` (direct drive). May be a `param/1` reference.",
required: false,
default: 1.0
],
offset: [
type: {:or, [unit_type(compatible: :degree), unit_type(compatible: :meter)]},
doc:
"Zero-point offset between joint frame and attachment frame: the joint angle (or linear position) corresponding to the attachment's zero. May be a `param/1` reference.",
required: false
],
reversed?: [
type: {:or, [:boolean, {:struct, BB.Dsl.ParamRef}]},
doc:
"Whether the attachment's motion is reversed relative to joint motion. May be a `param/1` reference.",
required: false,
default: false
]
]
}
@estimator_input %Entity{
name: :input,
describe: """
Declares an input source for a cross-sensor estimator.
Multi-input estimators must mark exactly one input as the driver
(`driver?: true`). The driver's arrival triggers fan-in: the framework
snapshots the most recent non-driver inputs and dispatches them
together. Stale non-drivers (older than the estimator's `sync_tolerance`)
cause the dispatch to be dropped instead.
Single-input link-nested estimators may declare one `input` without
`driver?:`. Sensor-nested estimators omit `input` entirely - the parent
sensor's output is implicitly consumed.
""",
target: BB.Dsl.Estimator.Input,
identifier: :name,
args: [:name, :path],
schema: [
name: [
type: :atom,
required: true,
doc: "A name used to key this input within `handle_input/2`'s payload map"
],
path: [
type: {:wrap_list, :atom},
required: true,
doc: "The full pubsub path of the source (e.g. `[:sensor, :base_link, :imu]`)"
],
driver?: [
type: :boolean,
required: false,
default: false,
doc:
"When `true`, this input drives dispatch: the framework fires `handle_input/2` whenever a message arrives on this path, fanning in the most-recent value of every other input."
]
]
}
@estimator_output %Entity{
name: :output,
describe: """
Declares a named output on a multi-output estimator.
Single-output estimators default to a conventional `:out` output that
routes to the estimator's natural path - no `output` block is needed.
Multi-output estimators (e.g. a Kalman filter emitting both a pose and
a velocity) declare each one explicitly so subscribers can address them
by name.
""",
target: BB.Dsl.Estimator.Output,
identifier: :name,
args: [:name],
schema: [
name: [
type: :atom,
required: true,
doc: "The output name. Match this in `{:reply, [{name, message}], state}` replies."
],
path: [
type: {:wrap_list, :atom},
required: false,
doc:
"Override the auto-derived output path. Defaults to the estimator's natural path suffixed with the output name."
]
]
}
@estimator_health_schema [
latency_budget: [
type: unit_type(compatible: :second),
required: false,
doc:
"Time budget per input dispatch. If `handle_input/2` takes longer than this, the estimator transitions to `:degraded` and (if configured) the `on_degraded` command fires."
],
lost_after: [
type: unit_type(compatible: :second),
required: false,
doc:
"If no input arrives within this duration, the estimator transitions to `:lost` and (if configured) the `on_lost` command fires."
],
recover_after: [
type: :pos_integer,
required: false,
default: 1,
doc:
"Number of consecutive in-budget completions required before transitioning from `:degraded` back to `:healthy`. Hysteresis to prevent flapping."
],
on_degraded: [
type: :atom,
required: false,
doc:
"Name of a command to fire when the estimator transitions into `:degraded`. The command receives `%{estimator: name, reason: atom, source_path: [atom] | nil, previous_state: atom, new_state: :degraded}`."
],
on_lost: [
type: :atom,
required: false,
doc:
"Name of a command to fire when the estimator transitions into `:lost`. Receives the same metadata shape as `on_degraded`."
],
on_recovered: [
type: :atom,
required: false,
doc:
"Name of a command to fire when the estimator transitions back to `:healthy`. Receives the same metadata shape as `on_degraded`."
]
]
@sensor_nested_estimator %Entity{
name: :estimator,
describe: """
A state estimator that consumes its parent sensor's output and publishes
derived state in the sensor's own frame.
Within-sensor fusion form. The parent sensor's output is the implicit
input - no `input` blocks are allowed here. To consume multiple
sources, declare the estimator at the link level instead.
See `BB.Estimator` for the behaviour contract.
""",
target: BB.Dsl.Estimator,
identifier: :name,
args: [:name, :child_spec],
imports: [BB.Unit, BB.Dsl.ParamRef],
entities: [outputs: [@estimator_output]],
schema:
[
name: [
type: :atom,
required: true,
doc: "A unique name for the estimator"
],
child_spec: [
type:
{:or,
[{:behaviour, BB.Estimator}, {:tuple, [{:behaviour, BB.Estimator}, :keyword_list]}]},
required: true,
doc:
"The child specification for the estimator process. Either a module or `{module, keyword_list}`"
]
] ++ @estimator_health_schema
}
@link_nested_estimator %Entity{
name: :estimator,
describe: """
A state estimator that consumes one or more explicitly declared input
streams and publishes derived state in the parent link's frame.
Cross-sensor fusion form. Each `input` block names a source path; for
multi-input estimators exactly one input must be marked `driver?: true`
to drive dispatch.
See `BB.Estimator` for the behaviour contract.
""",
target: BB.Dsl.Estimator,
identifier: :name,
args: [:name, :child_spec],
imports: [BB.Unit, BB.Dsl.ParamRef],
entities: [inputs: [@estimator_input], outputs: [@estimator_output]],
schema:
[
name: [
type: :atom,
required: true,
doc: "A unique name for the estimator"
],
child_spec: [
type:
{:or,
[{:behaviour, BB.Estimator}, {:tuple, [{:behaviour, BB.Estimator}, :keyword_list]}]},
required: true,
doc:
"The child specification for the estimator process. Either a module or `{module, keyword_list}`"
],
sync_tolerance: [
type: unit_type(compatible: :second),
required: false,
doc:
"For multi-input estimators, the maximum age a non-driver input may have relative to the driver before its dispatch is dropped. Omit for unbounded tolerance (always dispatch with the latest snapshot)."
]
] ++ @estimator_health_schema
}
@sensor %Entity{
name: :sensor,
describe: "A sensor attached to the robot, a link, or a joint.",
target: BB.Dsl.Sensor,
identifier: :name,
args: [:name, :child_spec],
imports: [BB.Dsl.ParamRef],
entities: [transmission: [@transmission], estimators: [@sensor_nested_estimator]],
singleton_entity_keys: [:transmission],
schema: [
name: [
type: :atom,
required: true,
doc: "A unique name for the sensor"
],
child_spec: [
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}`"
]
]
}
@actuator %Entity{
name: :actuator,
describe: "An actuator attached to a joint.",
target: BB.Dsl.Actuator,
identifier: :name,
args: [:name, :child_spec],
imports: [BB.Dsl.ParamRef],
entities: [transmission: [@transmission]],
singleton_entity_keys: [:transmission],
schema: [
name: [
type: :atom,
required: true,
doc: "A unique name for the actuator"
],
child_spec: [
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}`"
]
]
}
@joint %Entity{
name: :joint,
describe: """
A kinematic joint between a parent link and a child link.
""",
target: BB.Dsl.Joint,
identifier: :name,
imports: [BB.Unit, BB.Dsl.ParamRef],
args: [:name],
entities: [
link: [],
origin: [
%{
@origin
| describe: """
This is the transform from the parent link to the child link. The joint is located at the origin of the child link, as shown in the figure above
"""
}
],
axis: [
%{
@axis
| describe: """
The joint axis specified in the joint frame. This is the axis of rotation for revolute joints, the axis of translation for prismatic joints, and the surface normal for planar joints. The axis is specified in the joint frame of reference. Fixed and floating joints do not use the axis field
"""
}
],
dynamics: [@dynamics],
limit: [@limit],
sensors: [@sensor],
actuators: [@actuator]
],
recursive_as: :joints,
singleton_entity_keys: [:dynamics, :origin, :axis, :link, :limit],
schema: [
name: [
type: :atom,
required: false,
doc: "A unique name for the joint"
],
type: [
type: {:in, [:revolute, :continuous, :prismatic, :fixed, :floating, :planar]},
doc: "Specifies the type of joint"
]
]
}
@box %Entity{
name: :box,
describe: "Box geometry",
target: BB.Dsl.Box,
imports: [BB.Unit],
schema: [
x: [
type: unit_type(compatible: :meter),
doc: "The length of the X axis side",
required: true
],
y: [
type: unit_type(compatible: :meter),
doc: "The length of the Y axis side",
required: true
],
z: [
type: unit_type(compatible: :meter),
doc: "The length of the Z axis side",
required: true
]
]
}
@cylinder %Entity{
name: :cylinder,
describe: """
A cylindrical geometry
The origin of the cylinder is the center.
""",
target: BB.Dsl.Cylinder,
identifier: {:auto, :unique_integer},
imports: [BB.Unit],
schema: [
radius: [
type: unit_type(compatible: :meter),
doc: "The distance from the center to the circumference",
required: true
],
height: [
type: unit_type(compatible: :meter),
doc: "The height of the cylinder",
required: true
]
]
}
@sphere %Entity{
name: :sphere,
describe: """
A spherical geometry
The origin of the sphere is its center.
""",
target: BB.Dsl.Sphere,
identifier: {:auto, :unique_integer},
imports: [BB.Unit],
schema: [
radius: [
type: unit_type(compatible: :meter),
doc: "The distance from the center of the sphere to your edge",
required: true
]
]
}
@capsule %Entity{
name: :capsule,
describe: """
A capsule geometry (cylinder with hemispherical caps).
The origin of the capsule is the centre of the cylindrical portion.
The height is the distance between the centres of the hemispherical caps.
Total extent is height + 2 * radius.
""",
target: BB.Dsl.Capsule,
identifier: {:auto, :unique_integer},
imports: [BB.Unit],
schema: [
radius: [
type: unit_type(compatible: :meter),
doc: "The radius of the capsule (cylinder and hemispherical caps)",
required: true
],
height: [
type: unit_type(compatible: :meter),
doc: "The height of the cylindrical portion (between cap centres)",
required: true
]
]
}
@mesh %Entity{
name: :mesh,
describe: """
A mesh object specified by a filename
""",
target: BB.Dsl.Mesh,
identifier: {:auto, :unique_integer},
imports: [BB.Unit],
schema: [
filename: [
type: :string,
doc: "The path to the 3D model",
required: true
],
scale: [
type: :number,
doc: "A scale factor for the mest",
required: false,
default: 1
]
]
}
@color %Entity{
name: :color,
describe: """
The color of the meterial
""",
target: BB.Dsl.Color,
identifier: {:auto, :unique_integer},
schema: [
red: [
type: {:custom, BB.Dsl.Color, :validate, []},
doc: "The red element of the color",
required: true
],
green: [
type: {:custom, BB.Dsl.Color, :validate, []},
doc: "The green element of the color",
required: true
],
blue: [
type: {:custom, BB.Dsl.Color, :validate, []},
doc: "The blue element of the color",
required: true
],
alpha: [
type: {:custom, BB.Dsl.Color, :validate, []},
doc: "The alpha element of the color",
required: false,
default: 1
]
]
}
@texture %Entity{
name: :texture,
describe: """
A texture to apply to the material
""",
target: BB.Dsl.Texture,
identifier: {:auto, :unique_integer},
schema: [
filename: [
type: :string,
doc: "The image file to use",
required: true
]
]
}
@material %Entity{
name: :material,
describe: """
The material of the visual element
""",
target: BB.Dsl.Material,
identifier: {:auto, :unique_integer},
entities: [color: [@color], texture: [@texture]],
singleton_entity_keys: [:color, :texture],
schema: [
name: [
type: :atom,
doc: "The name of the material",
required: false
]
]
}
@visual %Entity{
name: :visual,
describe: """
Visual attributes for a link.
""",
target: BB.Dsl.Visual,
identifier: {:auto, :unique_integer},
imports: [BB.Unit],
entities: [
geometry: [@box, @cylinder, @sphere, @capsule, @mesh],
material: [@material],
origin: [
%{
@origin
| describe:
"The refrence frame of the visual element with respect to the reference frame of the link"
}
]
],
singleton_entity_keys: [:geometry, :material, :origin]
}
@inertia %Entity{
name: :inertia,
describe: """
How the link resists rotational motion.
""",
identifier: {:auto, :unique_integer},
imports: [BB.Unit, BB.Dsl.ParamRef],
target: BB.Dsl.Inertia,
schema: [
ixx: [
type: unit_type(compatible: :kilogram_square_meter),
doc: "Resistance to rotation around the x-axis",
required: true
],
iyy: [
type: unit_type(compatible: :kilogram_square_meter),
doc: "Resistance to rotation around the y-axis",
required: true
],
izz: [
type: unit_type(compatible: :kilogram_square_meter),
doc: "Resistance to rotation around the z-axis",
required: true
],
ixy: [
type: unit_type(compatible: :kilogram_square_meter),
doc: "Coupling between the x and y axes",
required: true
],
ixz: [
type: unit_type(compatible: :kilogram_square_meter),
doc: "Coupling between the x and z axes",
required: true
],
iyz: [
type: unit_type(compatible: :kilogram_square_meter),
doc: "Coupling between the y and z axes",
required: true
]
]
}
@inertial %Entity{
name: :inertial,
describe: """
A link's mass, position of it's center of mass and it's central inertia properties
""",
target: BB.Dsl.Inertial,
identifier: {:auto, :unique_integer},
imports: [BB.Unit, BB.Dsl.ParamRef],
entities: [
origin: [
%{
@origin
| describe:
"Specifies where the link's center of mass is located, relative to the link's reference frame"
}
],
inertia: [@inertia]
],
singleton_entity_keys: [:origin, :inertia],
schema: [
mass: [
type: unit_type(compatible: :kilogram),
doc: "The mass of the link",
required: true
]
]
}
@collision %Entity{
name: :collision,
describe: """
The collision properties of a link.
""",
target: BB.Dsl.Collision,
imports: [BB.Unit],
entities: [
origin: [
%{
@origin
| describe:
"The refrence frame of the collision element, relative to the reference frame of the link"
}
],
geometry: [@box, @cylinder, @sphere, @capsule, @mesh]
],
singleton_entity_keys: [:origin, :geometry],
schema: [
name: [
type: :atom,
doc: "An optional name of the link geometry",
required: false
]
]
}
@link %Entity{
name: :link,
describe: """
A kinematic link (ie solid body).
""",
target: BB.Dsl.Link,
identifier: :name,
imports: [BB.Unit, BB.Dsl.ParamRef],
args: [:name],
recursive_as: :link,
entities: [
joints: [],
inertial: [@inertial],
visual: [@visual],
collisions: [@collision],
sensors: [@sensor],
estimators: [@link_nested_estimator]
],
singleton_entity_keys: [:visual, :inertial],
schema: [
name: [
type: :atom,
doc: "The name of the link"
]
]
}
@settings %Section{
name: :settings,
describe: "System-wide settings",
schema: [
name: [
type: :atom,
required: false,
doc: "The name of the robot, defaults to the name of the defining module"
],
registry_module: [
type: :module,
doc: "The registry module to use",
required: false,
default: Registry
],
registry_options: [
type: :keyword_list,
doc:
"Options passed to Registry.start_link/1. Defaults to `[partitions: System.schedulers_online()]` at runtime.",
required: false
],
supervisor_module: [
type: :module,
doc: "The supervisor module to use",
required: false,
default: Supervisor
],
parameter_store: [
type:
{:or,
[
{:behaviour, BB.Parameter.Store},
{:tuple, [{:behaviour, BB.Parameter.Store}, :keyword_list]}
]},
doc: "Optional parameter persistence backend. Use a module or `{Module, opts}` tuple.",
required: false
],
topology_max_restarts: [
type: :non_neg_integer,
doc:
"Maximum restarts allowed in the topology supervisor before it gives up and the robot force-disarms.",
required: false,
default: 3
],
topology_max_seconds: [
type: :pos_integer,
doc: "Time window (in seconds) over which `topology_max_restarts` is counted.",
required: false,
default: 5
]
]
}
@sensors %Section{
name: :sensors,
describe: "Robot-level sensors",
entities: [@sensor]
}
@controller %Entity{
name: :controller,
describe: "A controller process at the robot level.",
target: BB.Dsl.Controller,
identifier: :name,
args: [:name, :child_spec],
imports: [BB.Dsl.ParamRef, BB.Controller.Action],
schema: [
name: [
type: :atom,
required: true,
doc: "A unique name for the controller"
],
child_spec: [
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}`"
],
simulation: [
type: {:in, [:omit, :mock, :start]},
default: :omit,
doc:
"Behaviour in simulation mode: :omit (don't start), :mock (start no-op mock), :start (start real controller)"
]
]
}
@controllers %Section{
name: :controllers,
describe: "Robot-level controllers",
entities: [@controller],
imports: [BB.Dsl.ParamRef]
}
@command_argument %Entity{
name: :argument,
describe: "An argument for the command.",
target: BB.Dsl.Command.Argument,
identifier: :name,
args: [:name, :type],
docs: """
Command arguments support flexible type specifications:
- Simple types: `:float`, `:integer`, `:boolean`, `:atom`, `:string`
- Enums: `{:in, [:value1, :value2]}`
- Maps: `{:map, [x: [type: :float, required: true]]}`
- Modules: `MyModule`
""",
schema: [
name: [
type: :atom,
required: true,
doc: "A unique name for the argument"
],
type: [
type: :any,
required: true,
doc: "The type of the argument"
],
required: [
type: :boolean,
required: false,
default: false,
doc: "Whether this argument is required"
],
default: [
type: :any,
required: false,
doc: "Default value if not provided"
],
doc: [
type: :string,
required: false,
doc: "Documentation for the argument"
]
]
}
@command %Entity{
name: :command,
describe: """
A command that can be executed on the robot.
Commands follow the Goal → Feedback → Result pattern and integrate with
the robot's state machine to control when they can run.
""",
target: BB.Dsl.Command,
identifier: :name,
args: [:name],
imports: [BB.Dsl.ParamRef],
entities: [arguments: [@command_argument]],
schema: [
name: [
type: :atom,
required: true,
doc: "A unique name for the command"
],
handler: [
type:
{:or, [{:behaviour, BB.Command}, {:tuple, [{:behaviour, BB.Command}, :keyword_list]}]},
required: true,
doc:
"The handler module implementing the `BB.Command` behaviour. Either a module or `{module, keyword_list}` for parameterised options"
],
timeout: [
type: {:or, [:pos_integer, {:in, [:infinity]}]},
required: false,
default: :infinity,
doc: "Timeout for command execution in milliseconds"
],
allowed_states: [
type: {:wrap_list, :atom},
required: false,
default: [:idle],
doc:
"Robot states in which this command can run. Use `:*` for all states (except `:disarmed`). Use `:disarmed` explicitly if the command should run when disarmed."
],
category: [
type: :atom,
required: false,
default: :default,
doc:
"The command category for concurrency control. Commands in the same category are limited by that category's concurrency_limit."
],
cancel: [
type: {:wrap_list, :atom},
required: false,
default: [],
doc:
"Categories of commands this command can cancel when starting. Use `:*` to cancel all running commands, or a list of specific categories. Empty list (default) means the command will error if its category is at capacity."
],
arm: [
type: :boolean,
required: false,
default: false,
doc:
"If `true`, this command becomes the canonical arming command — `BB.Safety.arm/1` dispatches it via the runtime instead of flipping state directly. Only one command per robot may set this flag. Implicit for commands using `BB.Command.Arm` as the handler."
],
disarm: [
type: :boolean,
required: false,
default: false,
doc:
"If `true`, this command becomes the canonical disarming command — `BB.Safety.disarm/2` dispatches it via the runtime instead of flipping state directly. Only one command per robot may set this flag. Implicit for commands using `BB.Command.Disarm` as the handler."
]
]
}
@category %Entity{
name: :category,
describe: """
A command category for grouping commands with concurrent execution limits.
Categories define logical groups of commands (e.g., `:motion`, `:sensing`,
`:auxiliary`) with configurable concurrency limits. Commands in different
categories can run concurrently, while commands in the same category are
limited to the category's `concurrency_limit`.
""",
target: BB.Dsl.Category,
identifier: :name,
args: [:name],
schema: [
name: [
type: :atom,
required: true,
doc: "A unique name for the category"
],
doc: [
type: :string,
required: false,
doc: "Documentation describing this category"
],
concurrency_limit: [
type: :pos_integer,
required: false,
default: 1,
doc: "Maximum number of commands in this category that can run concurrently"
]
]
}
@commands %Section{
name: :commands,
describe: "Robot commands with Goal → Feedback → Result semantics",
entities: [@category, @command]
}
@state %Entity{
name: :state,
describe: """
A custom operational state for the robot.
States define the operational context the robot can be in. Commands specify
which states they can run in via `allowed_states`, and can transition to new
states via `next_state:` in their result.
""",
target: BB.Dsl.State,
identifier: :name,
args: [:name],
schema: [
name: [
type: :atom,
required: true,
doc: "A unique name for the state"
],
doc: [
type: :string,
required: false,
doc: "Documentation describing this state"
]
]
}
@states %Section{
name: :states,
describe: """
Custom operational states for the robot.
The built-in `:idle` state is always available. Define additional states
here and use commands to transition between them. Commands can specify
which states they're allowed to run in via `allowed_states`.
""",
entities: [@state],
schema: [
initial_state: [
type: :atom,
required: false,
default: :idle,
doc: "The initial operational state when the robot starts"
]
]
}
@param_group %Entity{
name: :group,
describe: "A group of runtime-adjustable parameters.",
target: BB.Dsl.ParamGroup,
identifier: :name,
args: [:name],
recursive_as: :groups,
entities: [
params: [@param],
groups: []
],
schema: [
name: [
type: :atom,
required: true,
doc: "A unique name for the parameter group"
],
doc: [
type: :string,
required: false,
doc: "Documentation for the parameter group"
]
]
}
@bridge %Entity{
name: :bridge,
describe: """
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.Bridge`.
## Example
parameters do
bridge :mavlink, {BBMavLink.ParameterBridge, conn: "/dev/ttyACM0"}
bridge :phoenix, {BBPhoenix.ParameterBridge, url: "ws://gcs.local/socket"}
end
""",
target: BB.Dsl.Bridge,
identifier: :name,
args: [:name, :child_spec],
schema: [
name: [
type: :atom,
required: true,
doc: "A unique name for the bridge"
],
child_spec: [
type:
{: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}`"
],
simulation: [
type: {:in, [:omit, :mock, :start]},
default: :omit,
doc:
"Behaviour in simulation mode: :omit (don't start), :mock (start no-op mock), :start (start real bridge)"
]
]
}
@parameters %Section{
name: :parameters,
describe: """
Runtime-adjustable parameters for the robot.
Parameters provide a way to configure robot behaviour at runtime without
recompilation. They support validation, change notifications via PubSub,
and optional persistence.
## Example
parameters do
group :motion do
param :max_linear_speed, type: :float, default: 1.0,
min: 0.0, max: 10.0, doc: "Max velocity in m/s"
param :max_angular_speed, type: :float, default: 0.5
end
group :safety do
param :collision_distance, type: :float, default: 0.3
end
end
""",
entities: [@param_group, @param, @bridge]
}
@topology %Section{
name: :topology,
describe: "Robot topology",
entities: [@link, @joint]
}
use Spark.Dsl.Extension,
sections: [@topology, @settings, @sensors, @controllers, @commands, @parameters, @states],
transformers: [
__MODULE__.DefaultNameTransformer,
__MODULE__.TopologyTransformer,
__MODULE__.ValidateLimitUnitsTransformer,
__MODULE__.SupervisorTransformer,
__MODULE__.UniquenessTransformer,
__MODULE__.RobotTransformer,
__MODULE__.CommandTransformer,
__MODULE__.ParameterTransformer,
__MODULE__.StateTransformer,
__MODULE__.CategoryTransformer,
__MODULE__.WildcardExpansionTransformer
],
verifiers: [
__MODULE__.Verifiers.ValidateChildSpecs,
__MODULE__.Verifiers.ValidateEstimators,
__MODULE__.Verifiers.ValidateParamRefs,
__MODULE__.Verifiers.ValidateStateRefs,
__MODULE__.Verifiers.ValidateCategoryRefs
]
end