Add IMU data logger to SD card with remote start/stop

Streams BHI260AP accelerometer (50Hz), gyroscope (50Hz), and
magnetometer (25Hz) as timestamped CSV to SD card. Ring buffer
in PSRAM (512 samples) flushed to SD from the main loop.

Remote debug command 'L' toggles logging. Python tool:
  ./scripts/screenshot.py log

CSV format: ms,ax,ay,az,gx,gy,gz,mx,my,mz (raw int16 units)
Measured throughput: ~43Hz actual sample rate.

Also fixed: BHI260AP init no longer gated on hw_ready (radio
provisioning) — IMU sensors should work regardless of radio state.

Foundation for compass calibration (PCA on magnetometer data),
gesture recognition training, and activity classification.
This commit is contained in:
GlassOnTin 2026-03-28 17:05:33 +00:00
commit b594284060
4 changed files with 234 additions and 1 deletions

17
Gui.h
View file

@ -145,6 +145,10 @@ uint16_t *gui_screenshot_buf = NULL;
void display_unblank();
extern float pmu_temperature;
extern volatile uint32_t imu_step_count;
// IMU logger toggle — set by .ino after IMULogger.h is included
typedef bool (*gui_log_toggle_fn_t)();
static gui_log_toggle_fn_t gui_log_toggle_fn = NULL;
static bool gui_imu_logging = false;
#ifndef PMU_TEMP_MIN
#define PMU_TEMP_MIN -30
#endif
@ -770,6 +774,7 @@ bool gui_init() {
// 'N' (0x4E) — Navigate: reads 2 bytes (u8 col, u8 row) — jump to tile
// 'M' (0x4D) — Metrics: responds RWSM + JSON stats
// 'I' (0x49) — Invalidate: force full screen redraw
// 'L' (0x4C) — Log toggle: start/stop IMU logging to SD card
#define GUI_CMD_PREFIX_LEN 3
static const uint8_t gui_cmd_prefix[] = {0x52, 0x57, 0x53}; // "RWS"
@ -905,6 +910,18 @@ static void gui_cmd_execute() {
if (display_blanked) display_unblank();
break;
}
case 'L': { // Toggle IMU logging
Serial.write(hdr, 4);
if (gui_log_toggle_fn) {
gui_imu_logging = gui_log_toggle_fn();
Serial.printf("{\"logging\":%s}\n", gui_imu_logging ? "true" : "false");
} else {
Serial.println("{\"logging\":false,\"error\":\"not_available\"}");
}
Serial.flush();
break;
}
}
}

175
IMULogger.h Normal file
View file

@ -0,0 +1,175 @@
// IMU Data Logger — streams BHI260AP sensor data to SD card as CSV
// Start/stop via remote debug command 'L' or long-press button
//
// CSV format: timestamp_ms,ax,ay,az,gx,gy,gz,mx,my,mz
// Accelerometer/gyro/magnetometer in raw int16 units
// Timestamp is millis() at time of callback
#ifndef IMULOGGER_H
#define IMULOGGER_H
#if BOARD_MODEL == BOARD_TWATCH_ULT && HAS_SD
#include <SD.h>
// Ring buffer for sensor samples (stored in PSRAM)
struct imu_sample_t {
uint32_t timestamp;
int16_t ax, ay, az; // accelerometer
int16_t gx, gy, gz; // gyroscope
int16_t mx, my, mz; // magnetometer
};
#define IMU_LOG_BUF_SIZE 512 // samples before flush (~10s at 50Hz)
static imu_sample_t *imu_log_buf = NULL;
static volatile uint32_t imu_log_head = 0; // write position
static volatile uint32_t imu_log_tail = 0; // read position
static bool imu_logging = false;
static File imu_log_file;
static uint32_t imu_log_samples = 0;
static uint32_t imu_log_start_ms = 0;
// Latest raw values (written by callbacks, read by flush)
static volatile int16_t imu_raw_ax = 0, imu_raw_ay = 0, imu_raw_az = 0;
static volatile int16_t imu_raw_gx = 0, imu_raw_gy = 0, imu_raw_gz = 0;
static volatile int16_t imu_raw_mx = 0, imu_raw_my = 0, imu_raw_mz = 0;
static volatile bool imu_accel_new = false;
// Sensor callbacks — store latest values
void imu_log_accel_cb(uint8_t sensor_id, uint8_t *data, uint32_t size, uint64_t *timestamp, void *user_data) {
if (size >= 6) {
imu_raw_ax = (int16_t)(data[0] | (data[1] << 8));
imu_raw_ay = (int16_t)(data[2] | (data[3] << 8));
imu_raw_az = (int16_t)(data[4] | (data[5] << 8));
imu_accel_new = true;
// Push combined sample to ring buffer when accel fires (it's the "clock")
if (imu_logging && imu_log_buf) {
uint32_t next = (imu_log_head + 1) % IMU_LOG_BUF_SIZE;
if (next != imu_log_tail) { // not full
imu_sample_t &s = imu_log_buf[imu_log_head];
s.timestamp = millis();
s.ax = imu_raw_ax; s.ay = imu_raw_ay; s.az = imu_raw_az;
s.gx = imu_raw_gx; s.gy = imu_raw_gy; s.gz = imu_raw_gz;
s.mx = imu_raw_mx; s.my = imu_raw_my; s.mz = imu_raw_mz;
imu_log_head = next;
}
}
}
}
void imu_log_gyro_cb(uint8_t sensor_id, uint8_t *data, uint32_t size, uint64_t *timestamp, void *user_data) {
if (size >= 6) {
imu_raw_gx = (int16_t)(data[0] | (data[1] << 8));
imu_raw_gy = (int16_t)(data[2] | (data[3] << 8));
imu_raw_gz = (int16_t)(data[4] | (data[5] << 8));
}
}
void imu_log_mag_cb(uint8_t sensor_id, uint8_t *data, uint32_t size, uint64_t *timestamp, void *user_data) {
if (size >= 6) {
imu_raw_mx = (int16_t)(data[0] | (data[1] << 8));
imu_raw_my = (int16_t)(data[2] | (data[3] << 8));
imu_raw_mz = (int16_t)(data[4] | (data[5] << 8));
}
}
// Forward declaration
void imu_log_flush();
bool imu_log_start(SensorBHI260AP *bhi) {
if (imu_logging || !bhi) return false;
// Allocate ring buffer in PSRAM
if (!imu_log_buf) {
imu_log_buf = (imu_sample_t *)heap_caps_malloc(
IMU_LOG_BUF_SIZE * sizeof(imu_sample_t), MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT);
if (!imu_log_buf) return false;
}
imu_log_head = 0;
imu_log_tail = 0;
// Init SD
SPI.begin(SD_CLK, SD_MISO, SD_MOSI, SD_CS);
if (!SD.begin(SD_CS, SPI)) {
Serial.println("[imu_log] SD init failed");
return false;
}
// Create timestamped filename
char fname[32];
snprintf(fname, sizeof(fname), "/imu_%lu.csv", millis() / 1000);
imu_log_file = SD.open(fname, FILE_WRITE);
if (!imu_log_file) {
Serial.println("[imu_log] file open failed");
SD.end(); SPI.end();
return false;
}
// Write CSV header
imu_log_file.println("ms,ax,ay,az,gx,gy,gz,mx,my,mz");
// Configure sensors at 50Hz
bhi->configure(SensorBHI260AP::ACCEL_PASSTHROUGH, 50.0, 0);
bhi->onResultEvent(SensorBHI260AP::ACCEL_PASSTHROUGH, imu_log_accel_cb);
bhi->configure(SensorBHI260AP::GYRO_PASSTHROUGH, 50.0, 0);
bhi->onResultEvent(SensorBHI260AP::GYRO_PASSTHROUGH, imu_log_gyro_cb);
bhi->configure(SensorBHI260AP::MAGNETOMETER_PASSTHROUGH, 25.0, 0);
bhi->onResultEvent(SensorBHI260AP::MAGNETOMETER_PASSTHROUGH, imu_log_mag_cb);
imu_logging = true;
imu_log_samples = 0;
imu_log_start_ms = millis();
Serial.printf("[imu_log] started: %s\n", fname);
return true;
}
void imu_log_stop(SensorBHI260AP *bhi) {
if (!imu_logging) return;
// Disable sensor streams
if (bhi) {
bhi->configure(SensorBHI260AP::ACCEL_PASSTHROUGH, 0, 0);
bhi->configure(SensorBHI260AP::GYRO_PASSTHROUGH, 0, 0);
bhi->configure(SensorBHI260AP::MAGNETOMETER_PASSTHROUGH, 0, 0);
}
// Flush remaining samples
imu_log_flush();
uint32_t duration = (millis() - imu_log_start_ms) / 1000;
Serial.printf("[imu_log] stopped: %lu samples in %lus (%.1f Hz)\n",
imu_log_samples, duration,
duration > 0 ? (float)imu_log_samples / duration : 0);
imu_log_file.close();
SD.end();
SPI.end();
imu_logging = false;
}
// Flush ring buffer to SD — call from main loop
void imu_log_flush() {
if (!imu_logging || !imu_log_buf) return;
char line[80];
uint32_t flushed = 0;
while (imu_log_tail != imu_log_head) {
imu_sample_t &s = imu_log_buf[imu_log_tail];
int len = snprintf(line, sizeof(line), "%lu,%d,%d,%d,%d,%d,%d,%d,%d,%d\n",
s.timestamp, s.ax, s.ay, s.az,
s.gx, s.gy, s.gz, s.mx, s.my, s.mz);
imu_log_file.write((uint8_t *)line, len);
imu_log_tail = (imu_log_tail + 1) % IMU_LOG_BUF_SIZE;
imu_log_samples++;
flushed++;
}
if (flushed > 0) {
imu_log_file.flush();
}
}
#endif // BOARD_MODEL == BOARD_TWATCH_ULT && HAS_SD
#endif // IMULOGGER_H

View file

@ -47,6 +47,9 @@
#include "Speaker.h"
#include "Microphone.h"
// IMU data logger to SD card
#include "IMULogger.h"
// CST9217 capacitive touch panel
#include <touch/TouchDrvCST92xx.h>
TouchDrvCST92xx touch;
@ -2091,7 +2094,7 @@ void loop() {
// Deferred BHI260AP init — runs once after boot is complete
// Firmware upload takes ~10s and blocks, so we do it after radio is up
#if BOARD_MODEL == BOARD_TWATCH_ULT
if (!bhi260_ready && bhi260 == NULL && hw_ready && millis() > 5000) {
if (!bhi260_ready && bhi260 == NULL && millis() > 5000) {
Wire.setClock(1000000UL);
bhi260 = new SensorBHI260AP();
bhi260->setPins(-1);
@ -2105,6 +2108,18 @@ void loop() {
bhi260->configure(SensorBHI260AP::WRIST_TILT_GESTURE, 1.0, 0);
bhi260->onResultEvent(SensorBHI260AP::WRIST_TILT_GESTURE, imu_wrist_tilt_cb);
// Register IMU log toggle for remote debug
#if HAS_SD && HAS_DISPLAY
gui_log_toggle_fn = []() -> bool {
if (!imu_logging) {
return imu_log_start(bhi260);
} else {
imu_log_stop(bhi260);
return false;
}
};
#endif
// Enable step counter (low power, always-on)
bhi260->configure(SensorBHI260AP::STEP_COUNTER, 1.0, 0);
bhi260->onResultEvent(SensorBHI260AP::STEP_COUNTER, imu_step_cb);
@ -2124,6 +2139,9 @@ void loop() {
}
#endif
}
#if HAS_SD
if (imu_logging) imu_log_flush();
#endif
}
#endif

View file

@ -158,6 +158,24 @@ def cmd_invalidate(s):
print("Invalidated — full redraw requested")
def cmd_log(s):
send_cmd(s, ord('L'))
buf = b""
deadline = time.time() + 5
while time.time() < deadline:
chunk = s.read(max(1, s.in_waiting or 1))
if chunk:
buf += chunk
magic = PREFIX + b"L"
idx = buf.find(magic)
if idx >= 0:
nl = buf.find(b"\n", idx + 4)
if nl >= 0:
print(buf[idx + 4:nl].decode())
return
print(f"Timeout ({len(buf)} bytes)")
def main():
parser = argparse.ArgumentParser(description="R-Watch remote debug")
parser.add_argument("-p", "--port", default=DEFAULT_PORT)
@ -181,6 +199,9 @@ def main():
sub.add_parser("invalidate", aliases=["inv"])
sub.add_parser("log", aliases=["l"],
help="Toggle IMU logging to SD card")
args = parser.parse_args()
if not args.command:
parser.print_help()
@ -200,6 +221,8 @@ def main():
cmd_navigate(s, args.screen)
elif args.command in ("invalidate", "inv"):
cmd_invalidate(s)
elif args.command in ("log", "l"):
cmd_log(s)
finally:
s.close()