Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
83 changes: 83 additions & 0 deletions lib/bb/message/actuator/end_motion.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
# SPDX-FileCopyrightText: 2025 James Harton
#
# SPDX-License-Identifier: Apache-2.0

defmodule BB.Message.Actuator.EndMotion do
@moduledoc """
Message published by actuators when motion ends.

Optional counterpart to `BeginMotion`. Useful for actuators with partial
feedback (limit switches, stall detection) that can report when motion
completes but may not have continuous position sensing.

## Fields

- `position` - Position when motion ended (radians or metres)
- `reason` - Why motion ended (`:completed`, `:cancelled`, `:limit_reached`, `:fault`)
- `detail` - Optional atom with additional context (e.g. `:end_stop`, `:stall`)
- `message` - Optional human-readable information for operators

## Examples

alias BB.Message
alias BB.Message.Actuator.EndMotion

# Simple completion
{:ok, msg} = Message.new(EndMotion, :shoulder,
position: 1.57,
reason: :completed
)

# Limit reached with detail
{:ok, msg} = Message.new(EndMotion, :shoulder,
position: 0.0,
reason: :limit_reached,
detail: :end_stop
)

# Fault with message
{:ok, msg} = Message.new(EndMotion, :shoulder,
position: 0.52,
reason: :fault,
detail: :stall,
message: "Motor stall detected at 30% travel"
)
"""

@reasons [:completed, :cancelled, :limit_reached, :fault]

defstruct [:position, :reason, :detail, :message]

use BB.Message,
schema: [
position: [
type: :float,
required: true,
doc: "Position when motion ended (radians or metres)"
],
reason: [
type: {:in, @reasons},
required: true,
doc: "Why motion ended"
],
detail: [
type: :atom,
required: false,
doc: "Additional context about the reason"
],
message: [
type: :string,
required: false,
doc: "Human-readable information for operators"
]
]

@type reason :: :completed | :cancelled | :limit_reached | :fault

@type t :: %__MODULE__{
position: float(),
reason: reason(),
detail: atom() | nil,
message: String.t() | nil
}
end
88 changes: 88 additions & 0 deletions test/bb/message/actuator/end_motion_test.exs
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
# SPDX-FileCopyrightText: 2025 James Harton
#
# SPDX-License-Identifier: Apache-2.0

defmodule BB.Message.Actuator.EndMotionTest do
use ExUnit.Case, async: true

alias BB.Message
alias BB.Message.Actuator.EndMotion

describe "EndMotion" do
test "creates an end motion message with required fields only" do
{:ok, msg} =
Message.new(EndMotion, :shoulder,
position: 1.57,
reason: :completed
)

assert msg.frame_id == :shoulder
assert msg.payload.position == 1.57
assert msg.payload.reason == :completed
assert msg.payload.detail == nil
assert msg.payload.message == nil
end

test "creates an end motion message with detail" do
{:ok, msg} =
Message.new(EndMotion, :shoulder,
position: 0.0,
reason: :limit_reached,
detail: :end_stop
)

assert msg.payload.reason == :limit_reached
assert msg.payload.detail == :end_stop
end

test "creates an end motion message with message" do
{:ok, msg} =
Message.new(EndMotion, :shoulder,
position: 0.52,
reason: :fault,
detail: :stall,
message: "Motor stall detected at 30% travel"
)

assert msg.payload.reason == :fault
assert msg.payload.detail == :stall
assert msg.payload.message == "Motor stall detected at 30% travel"
end

test "requires position" do
assert {:error, _} =
Message.new(EndMotion, :shoulder, reason: :completed)
end

test "requires reason" do
assert {:error, _} =
Message.new(EndMotion, :shoulder, position: 1.57)
end

test "validates reason is one of the allowed values" do
assert {:error, _} =
Message.new(EndMotion, :shoulder,
position: 1.57,
reason: :invalid_reason
)
end

test "accepts all valid reasons" do
for reason <- [:completed, :cancelled, :limit_reached, :fault] do
{:ok, msg} =
Message.new(EndMotion, :shoulder,
position: 1.57,
reason: reason
)

assert msg.payload.reason == reason
end
end

test "new!/3 raises on validation error" do
assert_raise Spark.Options.ValidationError, fn ->
Message.new!(EndMotion, :shoulder, position: 1.57, reason: :invalid)
end
end
end
end