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
18 changes: 9 additions & 9 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,14 +1,14 @@
# VFM
# SFM

Spatial Foraging Platform: node firmware, base-station developer GUI, and
session analysis. Each application lives in its own directory.

# Project structure

```
VFM/
SFM/
├── firmware/ # Arduino library (ESP32-S3 node)
│ ├── src/ # VFM.h / services / pin definitions
│ ├── src/ # SFM.h / services / pin definitions
│ ├── examples/ # Node sketch + hardware bring-up sketches
│ ├── docs/ # Wiring, dispense cycle, firmware tunables
│ └── library.properties
Expand All @@ -27,14 +27,14 @@ VFM/
## Launch the GUI

```
cd Project/VFM/packages/dev_gui
cd Project/SFM/packages/dev_gui
python run.py
```

## Generate a behavior report

```
cd Project/VFM/packages/dev_gui
cd Project/SFM/packages/dev_gui
python run_report.py
```

Expand All @@ -44,9 +44,9 @@ Report generation itself is cross-platform — it doesn't need a Raspberry Pi or

## About

VFM is a firmware library for the Spatial Foraging Platform node. It provides non-blocking, service-oriented stepper-driven pellet dispensing, and talks to the base station over **CAN** (Controller Area Network) — the shared communication bus every node is wired onto. A **CAN event** is a message a node posted on that bus (Loaded, Pellet Taken, Fault, …).
SFM is a firmware library for the Spatial Foraging Platform node. It provides non-blocking, service-oriented stepper-driven pellet dispensing, and talks to the base station over **CAN** (Controller Area Network) — the shared communication bus every node is wired onto. A **CAN event** is a message a node posted on that bus (Loaded, Pellet Taken, Fault, …).

The Arduino library is the `firmware/` folder (not the repo root). Copy or symlink `firmware/` into `Arduino/libraries/VFM`, or zip that folder and add it via *Sketch → Include Library → Add .ZIP Library…*.
The Arduino library is the `firmware/` folder (not the repo root). Copy or symlink `firmware/` into `Arduino/libraries/SFM`, or zip that folder and add it via *Sketch → Include Library → Add .ZIP Library…*.

## Documentation

Expand All @@ -66,9 +66,9 @@ The Arduino library is the `firmware/` folder (not the repo root). Copy or symli
## Project structure

```
VFM/
SFM/
├── firmware/ # Arduino library (ESP32-S3 node)
│ ├── src/ # VFM.h / services / pin definitions
│ ├── src/ # SFM.h / services / pin definitions
│ ├── examples/ # Node sketch + hardware bring-up sketches
│ ├── docs/ # Wiring, dispense cycle, firmware tunables
│ └── library.properties
Expand Down
4 changes: 2 additions & 2 deletions firmware/docs/DISPENSE_CYCLE.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# Dispense cycle

How a VFM node delivers a pellet, how it knows the pellet was taken, and what it reports along the way.
How a SFM node delivers a pellet, how it knows the pellet was taken, and what it reports along the way.
This is the reference for the sensing model and the event vocabulary; tunable timings live in
[HARDCODED_VALUES.md](HARDCODED_VALUES.md). Pin / motor and sensor wiring: [WIRING.md](WIRING.md).

Expand Down Expand Up @@ -145,7 +145,7 @@ trial and the animal can't use sound alone to find the baited one.
**The raise is triggered by another node, not by a timer.** A node in `Dwelling` watches the bus and raises the
instant it sees a `Raising` event (`0x09`) from any other node. Every node's TWAI filter is
`TWAI_FILTER_CONFIG_ACCEPT_ALL`, so peer event frames already arrive in each node's RX queue; `CanService`
surfaces them via `onPeerEvent`, and `VFM` forwards a peer `Raising` to `DispenserService::notifyPeerRaise()`.
surfaces them via `onPeerEvent`, and `SFM` forwards a peer `Raising` to `DispenserService::notifyPeerRaise()`.
End to end the empty plate starts rising about one CAN frame time (~0.5 ms at 250 kbps) after the fed one.

This replaced a commanded dwell, which could not do the job: a fed cycle's hold lasts however long M1 takes to
Expand Down
12 changes: 6 additions & 6 deletions firmware/docs/HARDCODED_VALUES.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ Living note of firmware constants that may need changing after bench or field tw

GUI / experiment / report defaults: [BASE_STATION_HARDCODED_VALUES.md](../../packages/dev_gui/docs/BASE_STATION_HARDCODED_VALUES.md).

Pins (`VFMPins.h`) and CAN ID opcodes (`ServiceTypes.h`) are omitted unless they carry timing or motion meaning.
Pins (`SFMPins.h`) and CAN ID opcodes (`ServiceTypes.h`) are omitted unless they carry timing or motion meaning.
For what these timers guard and where they sit in the cycle, see [DISPENSE_CYCLE.md](DISPENSE_CYCLE.md).

---
Expand Down Expand Up @@ -164,16 +164,16 @@ the reading did not change, the decision boundary did.



## UI / LED / button (`VFM`)
## UI / LED / button (`SFM`)


| Value | Where | Notes |
| -------------- | ------------------------------------- | ---------------------------------------------------------------------------------- |
| 3 s | `VFM` ctor → `btnHoldMs_(3000)` | Hold to arm NVS clear (`VFM.h` in-class default `1000` is overridden by ctor) |
| 50 ms | `kBtnClickMinMs` (`VFM.h`) | Minimum press for a click to count as "recalibrate presence"; shorter = bounce |
| 100 ms | `VFM.cpp` LED9 blink while hold armed | Rapid blink warning |
| 3 s | `SFM` ctor → `btnHoldMs_(3000)` | Hold to arm NVS clear (`SFM.h` in-class default `1000` is overridden by ctor) |
| 50 ms | `kBtnClickMinMs` (`SFM.h`) | Minimum press for a click to count as "recalibrate presence"; shorter = bounce |
| 100 ms | `SFM.cpp` LED9 blink while hold armed | Rapid blink warning |
| 1.5 s / 150 ms | `kPingBlinkMs` / `kPingBlinkPeriodMs` | Status LED “which node” blink on Ping |
| 500 ms (50–5000 ms clamp) | `kDefaultSyncFlashMs` / `kMinSyncFlashMs` / `kMaxSyncFlashMs` (`VFM.h`) | Status LED solid-ON hold on `CanCmd::SyncFlash` (camera sync at session start) |
| 500 ms (50–5000 ms clamp) | `kDefaultSyncFlashMs` / `kMinSyncFlashMs` / `kMaxSyncFlashMs` (`SFM.h`) | Status LED solid-ON hold on `CanCmd::SyncFlash` (camera sync at session start) |
| 500 ms | LED9 blink at boot | Fast blink = booting |
| 1 s | LED9 / status blink | Slow = waiting for discovery |
| — | LED9 during presence calibration | Solid ON for the whole capture; yields back to the dome mirror when done |
Expand Down
2 changes: 1 addition & 1 deletion firmware/docs/WIRING.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# Wiring map

Firmware pin names → physical job. Source of truth: `[src/hardware/VFMPins.h](../src/hardware/VFMPins.h)`.
Firmware pin names → physical job. Source of truth: `[src/hardware/SFMPins.h](../src/hardware/SFMPins.h)`.
Cycle behaviour for the sensors: [DISPENSE_CYCLE.md](DISPENSE_CYCLE.md).

## Symbol → job
Expand Down
114 changes: 57 additions & 57 deletions firmware/examples/Node/Node.ino
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
// Node – Full VFM node sketch.
// Node – Full SFM node sketch.
//
// Bring-up checklist:
// 1. Flash to an ESP32-S3-MINI-1 with the VFM hardware attached.
// 1. Flash to an ESP32-S3-MINI-1 with the SFM hardware attached.
// 2. Open Serial Monitor at 115200 baud.
// 3. On first boot the node has no ID; it will print "WaitAEI" until the
// base station (or the test bench) drives GPIO14 HIGH.
Expand All @@ -24,9 +24,9 @@
// Events node->base : 0x300 + nodeId on OnPlate/Loaded/DomeOpened/PelletTaken/Fault
// Discovery node<->base: 0x080-0x083

#include <VFM.h>
#include <SFM.h>

vfm::VFM gVfm;
sfm::SFM gSfm;

// ---------------------------------------------------------------------------
// Serial command helpers
Expand All @@ -45,70 +45,70 @@ static void printHelp() {
Serial.println(F(" clr clear NVS node ID (forces first-boot next reset)"));
}

static const char *discStr(vfm::DiscoveryState s) {
static const char *discStr(sfm::DiscoveryState s) {
switch (s) {
case vfm::DiscoveryState::WaitAEI: return "WaitAEI";
case vfm::DiscoveryState::CheckNVS: return "CheckNVS";
case vfm::DiscoveryState::Announce: return "Announce";
case vfm::DiscoveryState::WaitAssign: return "WaitAssign";
case vfm::DiscoveryState::Rejoin: return "Rejoin";
case vfm::DiscoveryState::Enabled: return "Enabled";
case sfm::DiscoveryState::WaitAEI: return "WaitAEI";
case sfm::DiscoveryState::CheckNVS: return "CheckNVS";
case sfm::DiscoveryState::Announce: return "Announce";
case sfm::DiscoveryState::WaitAssign: return "WaitAssign";
case sfm::DiscoveryState::Rejoin: return "Rejoin";
case sfm::DiscoveryState::Enabled: return "Enabled";
}
return "?";
}

static const char *stateStr(vfm::DispenseState s) {
static const char *stateStr(sfm::DispenseState s) {
switch (s) {
case vfm::DispenseState::Idle: return "Idle";
case vfm::DispenseState::Seeking: return "Seeking";
case vfm::DispenseState::Lowering: return "Lowering";
case vfm::DispenseState::Loading: return "Loading";
case vfm::DispenseState::Raising: return "Raising";
case vfm::DispenseState::Loaded: return "Loaded";
case vfm::DispenseState::Dwelling: return "Dwelling";
case vfm::DispenseState::Fault: return "Fault";
case sfm::DispenseState::Idle: return "Idle";
case sfm::DispenseState::Seeking: return "Seeking";
case sfm::DispenseState::Lowering: return "Lowering";
case sfm::DispenseState::Loading: return "Loading";
case sfm::DispenseState::Raising: return "Raising";
case sfm::DispenseState::Loaded: return "Loaded";
case sfm::DispenseState::Dwelling: return "Dwelling";
case sfm::DispenseState::Fault: return "Fault";
}
return "?";
}

static void printStatus() {
Serial.print(F("[VFM] nodeId=")); Serial.print(gVfm.identity().nodeId());
Serial.print(F(" discovery=")); Serial.print(discStr(gVfm.identity().discoveryState()));
Serial.print(F(" dispense=")); Serial.print(stateStr(gVfm.dispenser().state()));
Serial.print(F(" pellets=")); Serial.print(gVfm.dispenser().pelletCount());
Serial.print(F(" presence=")); Serial.print(gVfm.mousePresent());
Serial.print(F(" pellet=")); Serial.print(gVfm.dispenser().pelletOnPlate());
Serial.print(F(" load_position=")); Serial.print(gVfm.dispenser().atLoadPosition());
Serial.print(F(" dome_open=")); Serial.println(gVfm.dispenser().domeOpen());
Serial.print(F("[SFM] nodeId=")); Serial.print(gSfm.identity().nodeId());
Serial.print(F(" discovery=")); Serial.print(discStr(gSfm.identity().discoveryState()));
Serial.print(F(" dispense=")); Serial.print(stateStr(gSfm.dispenser().state()));
Serial.print(F(" pellets=")); Serial.print(gSfm.dispenser().pelletCount());
Serial.print(F(" presence=")); Serial.print(gSfm.mousePresent());
Serial.print(F(" pellet=")); Serial.print(gSfm.dispenser().pelletOnPlate());
Serial.print(F(" load_position=")); Serial.print(gSfm.dispenser().atLoadPosition());
Serial.print(F(" dome_open=")); Serial.println(gSfm.dispenser().domeOpen());
}

static void printPresence() {
Serial.print(F("[PRESENCE] raw="));
Serial.print(gVfm.presenceRaw());
Serial.print(gSfm.presenceRaw());
Serial.print(F(" thr="));
Serial.print(gVfm.presenceThreshold());
Serial.print(gSfm.presenceThreshold());
Serial.print(F(" factor="));
Serial.print(gVfm.presence().factor(), 2);
if (gVfm.presence().hasCalStats()) {
Serial.print(gSfm.presence().factor(), 2);
if (gSfm.presence().hasCalStats()) {
Serial.print(F(" mean="));
Serial.print(gVfm.presence().calMean(), 1);
Serial.print(gSfm.presence().calMean(), 1);
Serial.print(F(" σ="));
Serial.print(gVfm.presence().calStdDev(), 1);
Serial.print(gSfm.presence().calStdDev(), 1);
}
Serial.print(F(" -> "));
Serial.println(gVfm.mousePresent() ? F("PRESENT") : F("clear"));
Serial.println(gSfm.mousePresent() ? F("PRESENT") : F("clear"));
}

// Report the outcome of a calibration started by serial or by the button.
static void reportPresenceEvents() {
switch (gVfm.takePresenceEvent()) {
case vfm::PresenceEvent::CalibrationStarted:
switch (gSfm.takePresenceEvent()) {
case sfm::PresenceEvent::CalibrationStarted:
Serial.print(F("[PRESENCE] CAL START - keep pad CLEAR for 5 s (LED9 solid) factor="));
Serial.println(gVfm.presence().factor(), 2);
Serial.println(gSfm.presence().factor(), 2);
break;

case vfm::PresenceEvent::CalibrationDone: {
const vfm::PresenceCalibration &c = gVfm.presence().lastCalibration();
case sfm::PresenceEvent::CalibrationDone: {
const sfm::PresenceCalibration &c = gSfm.presence().lastCalibration();
Serial.print(F("[PRESENCE] CAL DONE samples=")); Serial.print(c.samples);
Serial.print(F(" mean=")); Serial.print(c.mean, 1);
Serial.print(F(" std_dev=")); Serial.print(c.stdDev, 1);
Expand All @@ -119,7 +119,7 @@ static void reportPresenceEvents() {
break;
}

case vfm::PresenceEvent::CalibrationFailed:
case sfm::PresenceEvent::CalibrationFailed:
Serial.println(F("[PRESENCE] CAL FAILED - not enough samples; threshold unchanged"));
break;

Expand All @@ -138,28 +138,28 @@ static void handleSerialLine(const char *line) {
if (strncmp(line, "id ", 3) == 0) {
uint8_t id = (uint8_t)atoi(line + 3);
if (id > 0) {
gVfm.identity().assignId(id);
gSfm.identity().assignId(id);
Serial.print(F("Node ID set to ")); Serial.println(id);
}
} else if (strcmp(line, "d") == 0) {
if (gVfm.dispenser().dispense()) Serial.println(F("Dispense started."));
else { Serial.print(F("Cannot dispense - ")); Serial.println(stateStr(gVfm.dispenser().state())); }
if (gSfm.dispenser().dispense()) Serial.println(F("Dispense started."));
else { Serial.print(F("Cannot dispense - ")); Serial.println(stateStr(gSfm.dispenser().state())); }
} else if (strcmp(line, "a") == 0) {
gVfm.dispenser().recover();
gSfm.dispenser().recover();
Serial.println(F("Recovered."));
} else if (strcmp(line, "s") == 0) {
printStatus();
} else if (strcmp(line, "cal") == 0) {
if (!gVfm.startPresenceCalibration()) {
if (!gSfm.startPresenceCalibration()) {
Serial.println(F("[PRESENCE] Calibration already running"));
}
} else if (strcmp(line, "thr") == 0) {
printPresence();
} else if (strcmp(line, "factor") == 0) {
Serial.print(F("[PRESENCE] factor="));
Serial.println(gVfm.presence().factor(), 2);
Serial.println(gSfm.presence().factor(), 2);
Serial.println(F("Usage: factor <n> (e.g. factor 3 or factor 2.5)"));
if (!gVfm.presence().hasCalStats()) {
if (!gSfm.presence().hasCalStats()) {
Serial.println(F("No cal stats yet – run 'cal' or press button first to re-apply."));
}
} else if (strncmp(line, "factor ", 7) == 0) {
Expand All @@ -168,22 +168,22 @@ static void handleSerialLine(const char *line) {
float f = strtof(p, &end);
if (end == p || f <= 0.0f) {
Serial.println(F("Invalid factor. Usage: factor <n>"));
} else if (!gVfm.presence().setFactor(f)) {
} else if (!gSfm.presence().setFactor(f)) {
Serial.println(F("Invalid factor."));
} else {
if (gVfm.presence().hasCalStats()) {
if (gSfm.presence().hasCalStats()) {
Serial.println(F("[PRESENCE] Factor saved; threshold re-applied from last cal."));
} else {
Serial.println(F("[PRESENCE] Factor saved; run 'cal' to apply (no cal stats yet)."));
}
printPresence();
}
} else if (strcmp(line, "thrclr") == 0) {
gVfm.presence().clearStoredThreshold();
gSfm.presence().clearStoredThreshold();
Serial.println(F("Saved presence cal cleared; using compile-time defaults."));
printPresence();
} else if (strcmp(line, "clr") == 0) {
gVfm.identity().clearId();
gSfm.identity().clearId();
Serial.println(F("NVS id cleared. Node waits for AEI / discovery to ANNOUNCE."));
} else if (strcmp(line, "h") == 0 || strcmp(line, "help") == 0) {
printHelp();
Expand All @@ -196,24 +196,24 @@ static void handleSerialLine(const char *line) {
void setup() {
Serial.begin(115200);
while (!Serial && millis() < 3000) {}
Serial.println(F("\n===== VFM Node ====="));
Serial.println(F("\n===== SFM Node ====="));

if (!gVfm.begin()) {
if (!gSfm.begin()) {
Serial.println(F("WARNING: one or more services failed to initialise"));
}

// Print MAC UUID
const uint8_t *m = gVfm.identity().mac();
const uint8_t *m = gSfm.identity().mac();
Serial.printf("MAC UUID: %02X:%02X:%02X:%02X:%02X:%02X\n",
m[0], m[1], m[2], m[3], m[4], m[5]);
Serial.printf("Saved nodeId: %d\n", gVfm.identity().nodeId());
Serial.printf("Saved nodeId: %d\n", gSfm.identity().nodeId());
printPresence();
Serial.println(F("BTN: click = presence calibration, 3 s hold = clear node ID"));
Serial.println(F("Type 'h' for help."));
}

void loop() {
gVfm.update();
gSfm.update();
reportPresenceEvents();

// Non-blocking serial line reader
Expand Down
Loading
Loading