Captures the integrated system architecture for the platform.
The Spatial Foraging Platform is a modular, network-connected behavioral apparatus consisting of a Raspberry Pi 5 base station and up to N foraging modules (reference deployment: 9 modules) connected over a CAN bus in a linear daisy-chain. Each foraging module is an independent unit housing an ESP32S3, two stepper-motor pellet dispensers, a sensor suite comprising a pellet presence sensor, home position sensor, and spring-loaded access port trigger, and status LEDs. The base station orchestrates node discovery, session management, event logging, HTML behavior reports from those logs, and synchronization with external recording systems via BNC I/O. All modules are powered from a single 12 V supply routed through the daisy-chain; the base station is powered separately via USB-C.
flowchart LR
subgraph bs [Base Station]
RPi5[Raspberry Pi 5]
CANHAT[CAN HAT]
BNC_IN1[BNC In 1]
BNC_IN2[BNC In 2]
BNC_OUT[BNC Out]
RPi5 --- CANHAT
end
subgraph pwr [12 V Power]
PSU[8 A Supply]
end
PSU -->|12 V daisy-chain| M1
PSU -->|12 V daisy-chain| M2
PSU -->|12 V daisy-chain| MN
CANHAT -->|CAN bus RJ45| M1
subgraph M1 [Module 1]
ESP1[ESP32S3 + CAN XCVR]
TERM1[CAN termination OFF]
end
M1 -->|CAN bus RJ45| M2
subgraph M2 [Module 2]
ESP2[ESP32S3 + CAN XCVR]
TERM2[CAN termination OFF]
end
M2 -->|CAN bus RJ45| MN
subgraph MN ["Module N (last)"]
ESPN[ESP32S3 + CAN XCVR]
TERMN[CAN termination ON]
end
ExtSys[External System] -->|TTL/BNC| BNC_IN1
ExtSys -->|TTL/BNC| BNC_IN2
BNC_OUT -->|TTL/BNC| ExtSys
CAN bus was selected as the module network protocol for the following reasons:
- Noise immunity — differential signalling is robust over the cable lengths typical in a home-cage arena.
- Bus topology — all nodes share a single cable run; individual node wiring is minimised.
- Built-in error handling — CAN hardware provides automatic retransmission, error frames, and bus-off detection without requiring application-layer redundancy.
- Peer-to-peer broadcasting — any node can broadcast events to all other nodes and to the base station simultaneously.
Bitrate is 250 kbps (firmware TWAI and GUI --bitrate default).
Linear bus topology. RJ45/Ethernet connectors carry CAN signals (CAN_H, CAN_L), 12 V power, GND, and addressing GPIOs (AEO/AEI) in a daisy-chain from the base station through every module.
CAN requires a 120 Ω termination resistor at each end of the bus.
| End | Termination method |
|---|---|
| Base station | Fixed 120 Ω resistor on the CAN HAT |
| Last node | Automatic via hardware logic |
The automatic termination on the last node works as follows: when the node's CAN-out RJ45 port is unplugged, hardware logic (a physical switch or pull) asserts the termination resistor. When the port is plugged in and continues the chain, termination is disabled. No firmware intervention is required.
Each node's UUID is its ESP32S3 MAC address. The base station assigns a logical Node ID (1-based integer) during discovery and stores the UUID↔ID mapping in its registry. Each node stores its assigned ID in ESP32S3 NVS (non-volatile storage) so it persists across power cycles.
Discovery uses a daisy-chain Address Enable signal. Each module has:
- AEO — Address Enable Out (drives the next node's AEI).
- AEI — Address Enable In (gates whether the node participates in the current discovery step).
Base Node A (NVS: empty) Node B (NVS: empty) Node C (NVS: empty)
│ │ │ │
│ ════════════════ FIRST BOOT (all nodes are new) ═══════════════ │
│ │ │ │
├──AEO HIGH────────►│ AEI=HIGH │ AEI=LOW │ AEI=LOW
│ │ │ (blocked) │ (blocked)
│ │ "I have no saved ID" │ │
│ │ │ │
│◄──ANNOUNCE(UUID_A)│ │ │
│ │ │ │
├──ASSIGN(UUID_A,1)►│ │ │
│ │ save {UUID_A,1} to NVS │ │
│◄──ACK(1)──────────│ │ │
│ │ │ │
│ ├──AEO HIGH─────────────►│ AEI=HIGH │ AEI=LOW
│ │ │ "I have no saved ID" │ (blocked)
│ │ │ │
│◄──────────────────────ANNOUNCE(UUID_B)─────│ │
│ │ │ │
├────────────────────────ASSIGN(UUID_B,2)───►│ │
│ │ │ save {UUID_B,2} to NVS │
│◄──────────────────────ACK(2)───────────────│ │
│ │ │ │
│ │ ├──AEO HIGH─────────────►│ AEI=HIGH
│ │ │ │ ... same ...
│ │ │ │
│ ... silence ... (AEO of Node C goes nowhere — CAN-out unplugged) │
│ │
│ ⏱ TIMEOUT (e.g., 2 seconds of no new ANNOUNCE) │
│ │
│ "Discovery complete: 3 nodes assigned" │
Base Node A (NVS: ID=1) Node B (NVS: ID=2) Node C (NVS: ID=3)
│ │ │ │
│ ════════════════ NORMAL BOOT (all nodes have saved IDs) ══════ │
│ │ │ │
├──AEO HIGH────────►│ AEI=HIGH │ │
│ │ │ │
│ │ "I have saved ID=1" │ │
│ │ → AEO HIGH immediately │ │
│ ├──AEO HIGH─────────────►│ AEI=HIGH │
│ │ │ → AEO HIGH immediately │
│◄──REJOIN(UUID_A,1)│ ├──AEO HIGH─────────────►│
│ │ │ │
│◄──────────────────────REJOIN(UUID_B,2)─────│ │
│ │ │ │
│◄─────────────────────────────────────REJOIN(UUID_C,3)───────────────│
│ │ │ │
│ (base station verifies all UUIDs match registry) │
│ │ │ │
│ ... silence ... (AEO of Node C goes nowhere — CAN-out unplugged) │
│ │
│ ⏱ TIMEOUT (e.g., 2 seconds of no new REJOIN) │
│ │
│ "Discovery complete: 3 nodes verified" │
After discovery, the user maps logical Node IDs to physical positions (arena layout). Both the base station registry and each node's NVS stay in sync; a node that loses its NVS falls back to first-boot behaviour on the next power cycle.
SFM uses 11-bit standard CAN identifiers (0x000–0x7FF, 2048 IDs) with software
filtering. The identifier carries message class and target node only; opcodes, event
types, and payload (dispense state, pellet counts, etc.) live in the separate 0–8 byte
data field.
Operational messages use identifier = BASE + nodeId. The low 8 bits are nodeId
(0–255); the upper bits select message class (0x1xx commands, 0x2xx heartbeat,
0x3xx events). Node IDs are 1-based; nodeId == 0 on commands means broadcast
(0x100).
| Region | Range | Role |
|---|---|---|
| Discovery | 0x080–0x083 |
Boot-time ANNOUNCE, ASSIGN, ACK, REJOIN (node identified by MAC in data) |
| Commands | 0x100–0x1FF |
Base → node (0x100 = all nodes) |
| Heartbeat/status | 0x200–0x2FF |
Node → base periodic status |
| Events | 0x300–0x3FF |
Node → base immediate events |
| Reserved | everything else | Unused in v1 (~1276 IDs, including 0x400–0x7FF) |
Each assigned node consumes 3 operational IDs — command, heartbeat, and event. With
N nodes: 3×N + 4 discovery + 1 broadcast (e.g. node 4 → 0x104, 0x204, 0x304).
Maximum 255 nodes (nodeId 1…255). About 772 IDs are allocated by design; the
rest of the 11-bit space is free for future message types.
| Component | Detail |
|---|---|
| MCU | ESP32S3-Mini-1 N8 |
| CAN interface | On-board CAN transceiver |
| Connectivity | RJ45 in + RJ45 out (daisy-chain); carries CAN_H, CAN_L, 12 V, GND, AEO, AEI |
| Termination | Automated hardware logic — resistor switches in when CAN-out is unplugged |
| Actuation | 2 × stepper motor + driver (pellet dispensing) |
| Pellet sensing | 1 × pellet presence sensor (direct verification at presentation port) |
| Access sensing | 1 × pellet catch trigger (instant catch detection) |
| Position sensing | 1 × home position sensor (actuator limit) |
| Status LED | 1 × upward-facing LED (node status visibility from above) |
| General-purpose LEDs | 2 × on-board LEDs (user configurable) |
| User GPIOs | 2 × general-purpose GPIO pins |
| Power input | 12 V from daisy-chain supply |
LED colors available: red, green, yellow. Specific state assignments
(boot, fault, active, reward, etc.) are defined in
[failure-modes.md](failure-modes.md).
| Component | Detail |
|---|---|
| Compute | Raspberry Pi 5 |
| CAN interface | Custom CAN HAT |
| Power | USB-C |
| CAN output | Connects to Module 1 (first node) via RJ45 |
| Sync inputs | 2 × BNC — external system to base station |
| Sync output | 1 × BNC — base station to external system |
| BNC isolation | 5 V isolated rail for noise immunity |
| Status LEDs | 2 × user-configurable LEDs |
| BNC indicator LEDs | 3 × LEDs (one per BNC channel) |
Synchronization is owned by the base station. On session start the base broadcasts
CanCmd::SyncFlash: every node holds its upward-facing status LED solid ON for
500 ms so overhead cameras can mark the same instant. A coincident BNC OUT
pulse goes to recording systems.
The three BNC connectors handle TTL I/O. BNC OUT is in use today (session-start
pulse, optional pulse on a chosen CAN event). BNC IN 1 / IN 2 log edges and can
drive experiment callbacks; using them to start and stop the experiment from an
external TTL is planned — see sync-and-recording.md.
| Connector | Direction | Purpose |
|---|---|---|
| BNC In 1 | External → Base station | External trigger or clock input |
| BNC In 2 | External → Base station | Secondary external input |
| BNC Out | Base station → External | Platform event or trigger output |
BNC signals run on an isolated 5 V rail to minimise ground loops and noise coupling to electrophysiology systems.
Each module carries three LEDs (red, green, yellow):
| LED | Placement | Notes |
|---|---|---|
| Status LED | Upward-facing | Visible from above; 500 ms solid ON at session start (camera sync) |
| General LED 1 | On-board | User configurable |
| General LED 2 | On-board | User configurable |
State assignments (boot, fault, active, reward, idle, etc.) are not yet finalised.
See [failure-modes.md](failure-modes.md) for fault code cross-reference.
| LED | Count | Purpose |
|---|---|---|
| User-configurable | 2 | General status |
| BNC indicator | 3 | One per BNC channel — shows signal activity |
| Parameter | Value |
|---|---|
| Module supply voltage | 12 V |
| Distribution | Daisy-chained through RJ45 connectors |
| Per-module worst-case current | ~450 mA |
| 9-module total | ~4 A |
| Recommended supply rating | 8 A (supports up to ~16 modules) |
| Base station supply | USB-C (independent of module rail) |
A single 8 A 12 V supply is the minimum recommended for a full 9-module deployment. Scaling to 16 modules remains within the same supply rating at this per-module budget.
The base station appends every CAN frame, heartbeat, BNC edge, and experiment
row to one session CSV. sfm-analysis
(pip install sfm-analysis, CLI sfm-report) renders that CSV as printable
HTML (generic metrics plus per-template designs: free feeding, fixed/random,
probability delivery, two-armed bandit). On the Pi,
run_report.py
is a thin wrapper around the same CLI. Schema and commands: user-api.md.
flowchart TD
subgraph external ["External Recording System (optional)"]
EXT[External System]
end
subgraph basestation [Base Station]
RPi5[Raspberry Pi 5]
CANHAT[CAN HAT]
BNC1[BNC In 1]
BNC2[BNC In 2]
BNCOUT[BNC Out]
USBC[USB-C Power]
RPi5 --- CANHAT
RPi5 --- BNC1
RPi5 --- BNC2
RPi5 --- BNCOUT
end
subgraph power [Power Distribution]
PSU["12 V / 8 A Supply"]
end
subgraph M1 [Module 1]
M1ESP[ESP32S3]
M1CAN[CAN XCVR]
M1STEP[2x Stepper]
M1PRES[Pellet Presence Sensor]
M1ACC[Spring Access Sensor]
M1LED[3x LED]
end
subgraph M2 [Module 2]
M2ESP[ESP32S3 ...]
end
subgraph MN ["Module N (last node)"]
MNESP[ESP32S3 ...]
MNTERM["120 ohm CAN termination"]
end
EXT -->|TTL| BNC1
EXT -->|TTL| BNC2
BNCOUT -->|TTL| EXT
CANHAT -->|RJ45 CAN + AEO/AEI| M1
M1 -->|RJ45 CAN + AEO/AEI| M2
M2 -->|RJ45 CAN + AEO/AEI| MN
PSU -->|12 V daisy-chain| M1
PSU -->|12 V daisy-chain| M2
PSU -->|12 V daisy-chain| MN