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
168 changes: 168 additions & 0 deletions documentation/topics/reactive-controllers.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,168 @@
<!--
SPDX-FileCopyrightText: 2026 James Harton

SPDX-License-Identifier: Apache-2.0
-->

# Reactive Controllers

## Overview

Reactive controllers monitor PubSub messages and trigger actions when conditions are met. They provide a declarative way to implement common reactive patterns like threshold monitoring and event-driven responses without writing custom controller code.

## Controller Types

BB provides two reactive controller types:

| Controller | Purpose |
|------------|---------|
| `BB.Controller.PatternMatch` | Triggers when a message matches a predicate function |
| `BB.Controller.Threshold` | Triggers when a numeric field exceeds min/max bounds |

`Threshold` is a convenience wrapper around `PatternMatch` - internally it generates a match function from the field and bounds configuration.

## Actions

When a condition is met, the controller executes an action. Two action types are available:

### Command Action

Invokes a robot command:

```elixir
action: command(:disarm)
action: command(:move_to, target: :home)
```

### Callback Action

Calls an arbitrary function with the triggering message and context:

```elixir
action: handle_event(fn msg, ctx ->
Logger.warning("Threshold exceeded: #{inspect(msg.payload)}")
# ctx contains: robot_module, robot, robot_state, controller_name
:ok
end)
```

The callback receives:
- `msg` - The `BB.Message` that triggered the action
- `ctx` - A `BB.Controller.Action.Context` struct with robot references

## Configuration

### PatternMatch Options

| Option | Type | Required | Description |
|--------|------|----------|-------------|
| `:topic` | `[atom]` | Yes | PubSub topic path to subscribe to |
| `:match` | `fn msg -> boolean` | Yes | Predicate that returns true when action should trigger |
| `:action` | action | Yes | Action to execute (see Actions above) |
| `:cooldown_ms` | integer | No | Minimum ms between triggers (default: 1000) |

### Threshold Options

| Option | Type | Required | Description |
|--------|------|----------|-------------|
| `:topic` | `[atom]` | Yes | PubSub topic path to subscribe to |
| `:field` | atom or `[atom]` | Yes | Field path to extract from message payload |
| `:min` | float | One required | Minimum acceptable value |
| `:max` | float | One required | Maximum acceptable value |
| `:action` | action | Yes | Action to execute when threshold exceeded |
| `:cooldown_ms` | integer | No | Minimum ms between triggers (default: 1000) |

At least one of `:min` or `:max` must be provided for Threshold.

## Examples

### Current Limiting

Disarm the robot if servo current exceeds safe limits:

```elixir
defmodule MyRobot do
use BB

controller :over_current, {BB.Controller.Threshold,
topic: [:sensor, :servo_status],
field: :current,
max: 1.21,
action: command(:disarm)
}
end
```

### Collision Detection

React to proximity sensor readings:

```elixir
controller :collision, {BB.Controller.PatternMatch,
topic: [:sensor, :proximity],
match: fn msg -> msg.payload.distance < 0.05 end,
action: command(:disarm)
}
```

### Temperature Monitoring with Callback

Log warnings when temperature is outside safe range:

```elixir
controller :temp_monitor, {BB.Controller.Threshold,
topic: [:sensor, :temperature],
field: :value,
min: 10.0,
max: 45.0,
cooldown_ms: 5000,
action: handle_event(fn msg, ctx ->
Logger.warning("[#{ctx.controller_name}] Temperature out of range: #{msg.payload.value}°C")
:ok
end)
}
```

### Nested Field Access

Access nested fields in message payloads:

```elixir
controller :voltage_monitor, {BB.Controller.Threshold,
topic: [:sensor, :power],
field: [:battery, :voltage], # Accesses msg.payload.battery.voltage
min: 11.0,
action: command(:disarm)
}
```

## Cooldown Behaviour

The `:cooldown_ms` option prevents rapid repeated triggering. After an action executes, the controller ignores matching messages until the cooldown period elapses. This is useful for:

- Preventing command spam from noisy sensors
- Allowing time for the triggered action to take effect
- Reducing log noise from callback actions

The first matching message always triggers immediately (no initial delay).

## Integration with Commands

Reactive controllers work alongside the command system. When a controller triggers `command(:disarm)`, it's equivalent to calling `MyRobot.disarm([])` - the command goes through the normal command execution flow with state machine validation.

This means:
- Commands are logged via telemetry
- State machine rules apply (can't disarm if already disarmed)
- Command results are returned (but typically ignored by the controller)

## When to Use Reactive Controllers

**Good use cases:**
- Safety limits (current, temperature, force thresholds)
- Event-driven responses (collision detection, limit switches)
- Monitoring and alerting (logging unusual conditions)

**Consider alternatives when:**
- You need complex logic spanning multiple messages (use a custom controller)
- You need to modify robot state directly (use a custom controller with `handle_info`)
- You need request/response patterns (use commands instead)
115 changes: 115 additions & 0 deletions lib/bb/controller/action.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
# SPDX-FileCopyrightText: 2026 James Harton
#
# SPDX-License-Identifier: Apache-2.0

defmodule BB.Controller.Action do
@moduledoc """
Action builders and executor for reactive controllers.

Provides two action types:
- `Command` - invokes a robot command
- `Callback` - calls an arbitrary function with the message and context

## DSL Builders

These functions are imported into the controller entity scope:

controller :over_current, {BB.Controller.Threshold,
topic: [:sensor, :servo_status],
field: :current,
max: 1.21,
action: command(:disarm)
}

controller :collision, {BB.Controller.PatternMatch,
topic: [:sensor, :proximity],
match: fn msg -> msg.payload.distance < 0.05 end,
action: handle_event(fn msg, ctx ->
Logger.warning("Collision detected")
:ok
end)
}
"""

alias BB.Controller.Action.{Callback, Command, Context}

defmodule Command do
@moduledoc "Action that invokes a robot command."
defstruct [:command, args: []]

@type t :: %__MODULE__{
command: atom(),
args: keyword()
}
end

defmodule Callback do
@moduledoc "Action that calls an arbitrary function."
defstruct [:handler]

@type t :: %__MODULE__{
handler: (BB.Message.t(), Context.t() -> any())
}
end

defmodule Context do
@moduledoc """
Context provided to action callbacks.

Contains references to the robot module, static topology, dynamic state,
and the controller name that triggered the action.
"""
defstruct [:robot_module, :robot, :robot_state, :controller_name]

@type t :: %__MODULE__{
robot_module: module(),
robot: BB.Robot.t(),
robot_state: BB.Robot.Runtime.robot_state(),
controller_name: atom()
}
end

@type t :: Command.t() | Callback.t()

@doc """
Build a command action that invokes the named robot command.

## Examples

command(:disarm)
command(:move_to, target: pose)
"""
@spec command(atom()) :: Command.t()
def command(name) when is_atom(name), do: %Command{command: name}

@spec command(atom(), keyword()) :: Command.t()
def command(name, args) when is_atom(name) and is_list(args),
do: %Command{command: name, args: args}

@doc """
Build a callback action that calls the given function.

The function receives the triggering message and a context struct.

## Examples

handle_event(fn msg, ctx ->
Logger.info("Received: \#{inspect(msg)}")
:ok
end)
"""
@spec handle_event((BB.Message.t(), Context.t() -> any())) :: Callback.t()
def handle_event(fun) when is_function(fun, 2), do: %Callback{handler: fun}

@doc """
Execute an action with the given message and context.
"""
@spec execute(t(), BB.Message.t(), Context.t()) :: any()
def execute(%Command{command: cmd, args: args}, _message, %Context{robot_module: robot}) do
apply(robot, cmd, [Map.new(args)])
end

def execute(%Callback{handler: fun}, message, %Context{} = context) do
fun.(message, context)
end
end
88 changes: 88 additions & 0 deletions lib/bb/controller/pattern_match.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
# SPDX-FileCopyrightText: 2026 James Harton
#
# SPDX-License-Identifier: Apache-2.0

defmodule BB.Controller.PatternMatch do
@moduledoc """
Controller that triggers an action when a message matches a predicate.

This is the base reactive controller implementation. Other reactive controllers
like `BB.Controller.Threshold` are convenience wrappers around this module.

## Options

- `:topic` - PubSub topic path to subscribe to (required)
- `:match` - Predicate function `fn msg -> boolean end` (required)
- `:action` - Action to trigger on match (required)
- `:cooldown_ms` - Minimum ms between triggers (default: 1000)

## Example

controller :collision, {BB.Controller.PatternMatch,
topic: [:sensor, :proximity],
match: fn msg -> msg.payload.distance < 0.05 end,
action: command(:disarm)
}
"""

use BB.Controller,
options_schema: [
topic: [type: {:list, :atom}, required: true, doc: "PubSub topic path to subscribe to"],
match: [type: {:fun, 1}, required: true, doc: "Predicate function fn msg -> boolean"],
action: [type: :any, required: true, doc: "Action to trigger on match"],
cooldown_ms: [
type: :non_neg_integer,
default: 1000,
doc: "Minimum milliseconds between triggers"
]
]

alias BB.Controller.Action
alias BB.Controller.Action.Context
alias BB.Robot.Runtime

@impl BB.Controller
def init(opts) do
bb = Keyword.fetch!(opts, :bb)
BB.subscribe(bb.robot, opts[:topic])

{:ok,
%{
opts: opts,
last_triggered: :never
}}
end

@impl BB.Controller
def handle_info({:bb, _path, %BB.Message{} = msg}, state) do
opts = state.opts

if opts[:match].(msg) and cooldown_elapsed?(state) do
context = build_context(opts)
Action.execute(opts[:action], msg, context)
{:noreply, %{state | last_triggered: System.monotonic_time(:millisecond)}}
else
{:noreply, state}
end
end

def handle_info(_msg, state), do: {:noreply, state}

defp cooldown_elapsed?(%{last_triggered: :never}), do: true

defp cooldown_elapsed?(state) do
now = System.monotonic_time(:millisecond)
now - state.last_triggered >= state.opts[:cooldown_ms]
end

defp build_context(opts) do
bb = opts[:bb]

%Context{
robot_module: bb.robot,
robot: bb.robot.robot(),
robot_state: Runtime.state(bb.robot),
controller_name: bb.path |> List.last()
}
end
end
Loading