Current section

Files

Jump to
bb_kino lib bb kino examples test_robot.ex
Raw

lib/bb/kino/examples/test_robot.ex

# SPDX-FileCopyrightText: 2025 James Harton
#
# SPDX-License-Identifier: Apache-2.0
defmodule BB.Kino.Examples.TestRobot do
@moduledoc """
An example robot for testing BB.Kino widgets.
This robot has a simple 2-joint arm topology with commands for:
- Arming/disarming
- Cycling joints through their range of motion
- Moving to home position
## Usage in Livebook
{:ok, _pid} = BB.Kino.Examples.TestRobot.start_link()
# Create the management widget
BB.Kino.safety(BB.Kino.Examples.TestRobot)
"""
use BB
import BB.Unit
# Command handler for cycling a joint through its range
defmodule CycleJoint do
@moduledoc false
use BB.Command
alias BB.Message
alias BB.Robot.Runtime, as: RobotRuntime
@impl BB.Command
def handle_command(goal, context, state) do
joint_name = Map.get(goal, :joint, :shoulder_joint)
duration_ms = Map.get(goal, :duration, 2000)
steps = Map.get(goal, :steps, 20)
robot = context.robot_module
robot_struct = RobotRuntime.get_robot(robot)
case Map.get(robot_struct.joints, joint_name) do
nil ->
{:stop, :normal, %{state | result: {:error, {:unknown_joint, joint_name}}}}
joint ->
lower = joint.limits[:lower] || -:math.pi()
upper = joint.limits[:upper] || :math.pi()
positions = RobotRuntime.positions(robot)
start_pos = Map.get(positions, joint_name, 0.0)
step_delay = div(duration_ms, steps * 2)
cycle_positions =
interpolate(start_pos, lower, steps) ++
interpolate(lower, upper, steps) ++
interpolate(upper, start_pos, steps)
state =
Map.merge(state, %{
robot: robot,
joint_name: joint_name,
remaining: cycle_positions,
step_delay: step_delay
})
send(self(), :next_position)
{:noreply, state}
end
end
@impl BB.Command
def handle_info(:next_position, state) do
case state.remaining do
[] ->
{:stop, :normal, %{state | result: %{joint: state.joint_name, cycled: true}}}
[pos | rest] ->
publish_joint_position(state.robot, state.joint_name, pos)
Process.send_after(self(), :next_position, state.step_delay)
{:noreply, %{state | remaining: rest}}
end
end
def handle_info(_msg, state), do: {:noreply, state}
@impl BB.Command
def result(%{result: {:error, _} = error}), do: error
def result(%{result: result}), do: {:ok, result}
defp interpolate(from, to, steps) do
step_size = (to - from) / steps
for i <- 1..steps do
from + step_size * i
end
end
defp publish_joint_position(robot, joint_name, position) do
{:ok, msg} =
Message.new(BB.Message.Sensor.JointState, :simulated,
names: [joint_name],
positions: [position * 1.0],
velocities: [0.0],
efforts: [0.0]
)
BB.publish(robot, [:sensor, :simulated], msg)
end
end
# Command handler for moving to home position
defmodule GoHome do
@moduledoc false
use BB.Command
alias BB.Robot.Runtime, as: RobotRuntime
@impl BB.Command
def handle_command(goal, context, state) do
duration_ms = Map.get(goal, :duration, 1000)
steps = Map.get(goal, :steps, 20)
robot = context.robot_module
robot_struct = RobotRuntime.get_robot(robot)
positions = RobotRuntime.positions(robot)
step_delay = div(duration_ms, steps)
joint_names = Map.keys(robot_struct.joints)
state =
Map.merge(state, %{
robot: robot,
joint_names: joint_names,
start_positions: positions,
current_step: 1,
total_steps: steps,
step_delay: step_delay
})
send(self(), :next_step)
{:noreply, state}
end
@impl BB.Command
def handle_info(:next_step, state) do
if state.current_step > state.total_steps do
{:stop, :normal, %{state | result: %{homed: true}}}
else
fraction = state.current_step / state.total_steps
joint_positions =
for joint_name <- state.joint_names do
current = Map.get(state.start_positions, joint_name, 0.0)
target = current * (1 - fraction)
{joint_name, target}
end
publish_joint_positions(state.robot, joint_positions)
Process.send_after(self(), :next_step, state.step_delay)
{:noreply, %{state | current_step: state.current_step + 1}}
end
end
def handle_info(_msg, state), do: {:noreply, state}
@impl BB.Command
def result(%{result: result}), do: {:ok, result}
defp publish_joint_positions(robot, joint_positions) do
{names, positions} = Enum.unzip(joint_positions)
{:ok, msg} =
BB.Message.new(BB.Message.Sensor.JointState, :simulated,
names: names,
positions: positions,
velocities: List.duplicate(0.0, length(names)),
efforts: List.duplicate(0.0, length(names))
)
BB.publish(robot, [:sensor, :simulated], msg)
end
end
# Command handler for waving (demo animation)
defmodule Wave do
@moduledoc false
use BB.Command
alias BB.Robot.Runtime, as: RobotRuntime
@wave_shoulder :math.pi() / 4
@wave_elbow :math.pi() / 2
@wave_amplitude :math.pi() / 4
@impl BB.Command
def handle_command(goal, context, state) do
cycles = Map.get(goal, :cycles, 3)
speed = Map.get(goal, :speed, 1.0)
robot = context.robot_module
positions = RobotRuntime.positions(robot)
shoulder_start = Map.get(positions, :shoulder_joint, 0.0)
elbow_start = Map.get(positions, :elbow_joint, 0.0)
steps = round(20 / speed)
step_delay = round(30 / speed)
state =
Map.merge(state, %{
robot: robot,
cycles: cycles,
steps: steps,
step_delay: step_delay,
shoulder_start: shoulder_start,
elbow_start: elbow_start,
phase: :move_to_wave,
current_step: 1,
current_cycle: 1,
wave_direction: :out
})
send(self(), :tick)
{:noreply, state}
end
@impl BB.Command
def handle_info(:tick, %{phase: :move_to_wave} = state) do
if state.current_step > state.steps do
Process.send_after(self(), :tick, state.step_delay)
{:noreply, %{state | phase: :waving, current_step: 1}}
else
fraction = state.current_step / state.steps
shoulder = state.shoulder_start + (@wave_shoulder - state.shoulder_start) * fraction
elbow = state.elbow_start + (@wave_elbow - state.elbow_start) * fraction
publish_positions(state.robot, shoulder, elbow)
Process.send_after(self(), :tick, state.step_delay)
{:noreply, %{state | current_step: state.current_step + 1}}
end
end
def handle_info(:tick, %{phase: :waving} = state) do
if state.current_step > state.steps do
case {state.wave_direction, state.current_cycle} do
{:out, _} ->
Process.send_after(self(), :tick, state.step_delay)
{:noreply, %{state | wave_direction: :back, current_step: 1}}
{:back, cycle} when cycle >= state.cycles ->
Process.send_after(self(), :tick, state.step_delay)
{:noreply, %{state | phase: :return_home, current_step: 1}}
{:back, cycle} ->
Process.send_after(self(), :tick, state.step_delay)
{:noreply, %{state | wave_direction: :out, current_step: 1, current_cycle: cycle + 1}}
end
else
fraction = state.current_step / state.steps
elbow =
case state.wave_direction do
:out -> @wave_elbow - @wave_amplitude * fraction
:back -> @wave_elbow - @wave_amplitude + @wave_amplitude * fraction
end
publish_positions(state.robot, @wave_shoulder, elbow)
Process.send_after(self(), :tick, state.step_delay)
{:noreply, %{state | current_step: state.current_step + 1}}
end
end
def handle_info(:tick, %{phase: :return_home} = state) do
if state.current_step > state.steps do
{:stop, :normal, %{state | result: %{waved: state.cycles}}}
else
fraction = state.current_step / state.steps
shoulder = @wave_shoulder + (state.shoulder_start - @wave_shoulder) * fraction
elbow = @wave_elbow + (state.elbow_start - @wave_elbow) * fraction
publish_positions(state.robot, shoulder, elbow)
Process.send_after(self(), :tick, state.step_delay)
{:noreply, %{state | current_step: state.current_step + 1}}
end
end
def handle_info(_msg, state), do: {:noreply, state}
@impl BB.Command
def result(%{result: result}), do: {:ok, result}
defp publish_positions(robot, shoulder, elbow) do
joint_names =
robot
|> RobotRuntime.get_robot()
|> Map.get(:joints)
|> Map.keys()
{names, positions} =
[{:shoulder_joint, shoulder}, {:elbow_joint, elbow}]
|> Enum.filter(fn {name, _} -> name in joint_names end)
|> Enum.unzip()
unless Enum.empty?(names) do
count = length(names)
{:ok, msg} =
BB.Message.new(BB.Message.Sensor.JointState, :simulated,
names: names,
positions: positions,
velocities: List.duplicate(0.0, count),
efforts: List.duplicate(0.0, count)
)
BB.publish(robot, [:sensor, :simulated], msg)
end
end
end
settings do
name(:bb_kino_test_robot)
end
commands do
command :arm do
handler(BB.Command.Arm)
allowed_states([:disarmed])
end
command :disarm do
handler(BB.Command.Disarm)
allowed_states([:idle])
end
command :cycle_joint do
handler(CycleJoint)
allowed_states([:idle])
argument :joint, :atom do
required(false)
default(:shoulder_joint)
doc("Joint to cycle (shoulder_joint or elbow_joint)")
end
argument :duration, :integer do
required(false)
default(2000)
doc("Duration in milliseconds")
end
end
command :go_home do
handler(GoHome)
allowed_states([:idle])
argument :duration, :integer do
required(false)
default(1000)
doc("Duration in milliseconds")
end
end
command :wave do
handler(Wave)
allowed_states([:idle])
argument :cycles, :integer do
required(false)
default(3)
doc("Number of wave cycles")
end
argument :speed, :float do
required(false)
default(1.0)
doc("Speed multiplier (0.5 = half speed, 2.0 = double speed)")
end
end
end
topology do
link :base_link do
visual do
cylinder do
radius(~u(0.05 meter))
height(~u(0.1 meter))
end
end
joint :shoulder_joint do
type(:revolute)
origin do
z(~u(0.1 meter))
end
limit do
lower(~u(-180 degree))
upper(~u(180 degree))
effort(~u(10 newton_meter))
velocity(~u(90 degree_per_second))
end
link :upper_arm do
visual do
origin do
z(~u(0.15 meter))
end
cylinder do
radius(~u(0.04 meter))
height(~u(0.3 meter))
end
end
joint :elbow_joint do
type(:revolute)
origin do
z(~u(0.3 meter))
end
axis do
roll(~u(-90 degree))
end
limit do
lower(~u(0 degree))
upper(~u(180 degree))
effort(~u(10 newton_meter))
velocity(~u(90 degree_per_second))
end
link :forearm do
visual do
origin do
z(~u(0.125 meter))
end
cylinder do
radius(~u(0.03 meter))
height(~u(0.25 meter))
end
end
end
end
end
end
end
end
end