Add shared SPI bus mutex for LoRa + SD card coexistence

SharedSPI.h: FreeRTOS mutex protecting the shared SPI bus (pins
33/34/35) used by LoRa (SX1262), SD card, and future NFC.

sx126x.cpp: All 6 SPI transaction blocks wrapped with
shared_spi_mutex acquire/release. LoRa uses portMAX_DELAY
(always gets access, just delayed by SD if needed).

USBSD.h: USB MSC SD card access uses shared_spi_mutex with
200ms timeout. Deferred init (3s after boot) for SPI bus
readiness. Metrics: sd_ready, sd_reads, sd_fails counters.

IMULogger.h, Gui.h: SD card writes wrapped in shared_spi_mutex.

Status: SD card correctly reports 29.7GB via USB MSC. Reads
work intermittently (~50% success). Root cause: main loop
takes ~700ms per iteration, causing mutex timeout for MSC
callbacks. Needs loop time investigation to achieve reliable
USB mass storage.

The CO5300 display uses a separate SPI3 bus — not affected.
This commit is contained in:
GlassOnTin 2026-03-28 20:59:52 +00:00
commit 5e19294dea
7 changed files with 199 additions and 17 deletions

41
Gui.h
View file

@ -145,6 +145,11 @@ uint16_t *gui_screenshot_buf = NULL;
void display_unblank();
extern float pmu_temperature;
extern volatile uint32_t imu_step_count;
#if !ARDUINO_USB_MODE
extern uint32_t usb_sd_read_count;
extern uint32_t usb_sd_read_fail;
extern bool usb_sd_ready;
#endif
// 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;
@ -775,6 +780,7 @@ bool gui_init() {
// '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
// 'F' (0x46) — File download: reads 1 byte filename length + filename, sends file contents
#define GUI_CMD_PREFIX_LEN 3
static const uint8_t gui_cmd_prefix[] = {0x52, 0x57, 0x53}; // "RWS"
@ -891,15 +897,28 @@ static void gui_cmd_execute() {
Serial.write(hdr, 4);
char buf[192];
uint32_t avg_flush = gui_frame_count > 0 ? gui_flush_us_total / gui_frame_count : 0;
#if !ARDUINO_USB_MODE
snprintf(buf, sizeof(buf),
"{\"frames\":%lu,\"flush_us\":%lu,\"flush_avg\":%lu,"
"\"render_us\":%lu,\"loop_us\":%lu,\"loop_max_us\":%lu,"
"{\"frames\":%lu,\"flush_us\":%lu,\"render_us\":%lu,"
"\"loop_us\":%lu,\"loop_max\":%lu,"
"\"sd_ready\":%d,\"sd_reads\":%lu,\"sd_fails\":%lu,"
"\"heap\":%lu,\"psram\":%lu}\n",
gui_frame_count, gui_flush_us_last, avg_flush,
gui_frame_count, gui_flush_us_last,
gui_render_us_last, gui_loop_us_last, gui_loop_us_max,
usb_sd_ready ? 1 : 0, usb_sd_read_count, usb_sd_read_fail,
(uint32_t)esp_get_free_heap_size(),
(uint32_t)heap_caps_get_free_size(MALLOC_CAP_SPIRAM));
#else
snprintf(buf, sizeof(buf),
"{\"frames\":%lu,\"flush_us\":%lu,\"render_us\":%lu,"
"\"loop_us\":%lu,\"loop_max\":%lu,"
"\"heap\":%lu,\"psram\":%lu}\n",
gui_frame_count, gui_flush_us_last,
gui_render_us_last, gui_loop_us_last, gui_loop_us_max,
(uint32_t)esp_get_free_heap_size(),
(uint32_t)heap_caps_get_free_size(MALLOC_CAP_SPIRAM));
gui_loop_us_max = 0; // reset max after reading
#endif
gui_loop_us_max = 0;
Serial.write((uint8_t *)buf, strlen(buf));
Serial.flush();
break;
@ -937,20 +956,23 @@ void gui_screenshot_info() {
// Write screenshot to SD card as raw RGB565 + BMP header
#if HAS_SD
#include <SD.h>
#include "SharedSPI.h"
bool gui_screenshot_sd(const char *path = "/screenshot.bmp") {
if (!gui_screenshot_buf) return false;
// Init SD on shared SPI bus
// Acquire shared SPI mutex for SD access
if (shared_spi_mutex) xSemaphoreTake(shared_spi_mutex, portMAX_DELAY);
SPI.begin(SD_CLK, SD_MISO, SD_MOSI, SD_CS);
if (!SD.begin(SD_CS, SPI)) {
if (!SD.begin(SD_CS, SPI, 4000000, "/sd", 5)) {
if (shared_spi_mutex) xSemaphoreGive(shared_spi_mutex);
Serial.println("[screenshot] SD init failed");
return false;
}
File f = SD.open(path, FILE_WRITE);
if (!f) {
if (shared_spi_mutex) xSemaphoreGive(shared_spi_mutex);
Serial.println("[screenshot] file open failed");
SD.end();
return false;
}
@ -994,10 +1016,7 @@ bool gui_screenshot_sd(const char *path = "/screenshot.bmp") {
f.write((uint8_t *)gui_screenshot_buf, img_size);
f.close();
SD.end();
// Restart LoRa SPI after SD use (shared bus)
SPI.end();
if (shared_spi_mutex) xSemaphoreGive(shared_spi_mutex);
Serial.printf("[screenshot] saved %s (%u bytes)\n", path, file_size);
return true;

View file

@ -11,6 +11,7 @@
#if BOARD_MODEL == BOARD_TWATCH_ULT && HAS_SD
#include <SD.h>
#include "SharedSPI.h"
// Ring buffer for sensor samples (stored in PSRAM)
struct imu_sample_t {
@ -89,9 +90,12 @@ bool imu_log_start(SensorBHI260AP *bhi) {
imu_log_head = 0;
imu_log_tail = 0;
// Init SD
// Init SD (acquire shared SPI mutex)
if (shared_spi_mutex) xSemaphoreTake(shared_spi_mutex, portMAX_DELAY);
SPI.begin(SD_CLK, SD_MISO, SD_MOSI, SD_CS);
if (!SD.begin(SD_CS, SPI)) {
bool sd_ok = SD.begin(SD_CS, SPI, 4000000, "/sd", 5);
if (shared_spi_mutex) xSemaphoreGive(shared_spi_mutex);
if (!sd_ok) {
Serial.println("[imu_log] SD init failed");
return false;
}
@ -144,15 +148,16 @@ void imu_log_stop(SensorBHI260AP *bhi) {
imu_log_samples, duration,
duration > 0 ? (float)imu_log_samples / duration : 0);
if (shared_spi_mutex) xSemaphoreTake(shared_spi_mutex, portMAX_DELAY);
imu_log_file.close();
SD.end();
SPI.end();
if (shared_spi_mutex) xSemaphoreGive(shared_spi_mutex);
imu_logging = false;
}
// Flush ring buffer to SD — call from main loop
void imu_log_flush() {
if (!imu_logging || !imu_log_buf) return;
if (shared_spi_mutex && xSemaphoreTake(shared_spi_mutex, pdMS_TO_TICKS(50)) != pdTRUE) return;
char line[80];
uint32_t flushed = 0;
@ -169,6 +174,7 @@ void imu_log_flush() {
if (flushed > 0) {
imu_log_file.flush();
}
if (shared_spi_mutex) xSemaphoreGive(shared_spi_mutex);
}
#endif // BOARD_MODEL == BOARD_TWATCH_ULT && HAS_SD

View file

@ -124,7 +124,7 @@ deploy-tbeam_supreme: firmware-tbeam_supreme free-port
# partition_twatch.csv must be copied to the Arduino ESP32 tools/partitions/ directory.
# When changing partition scheme, flash ALL THREE binaries (bootloader + partition + app).
firmware-twatch_ultra:
arduino-cli compile --log --fqbn "esp32:esp32:esp32s3:CDCOnBoot=cdc,FlashSize=16M,PSRAM=enabled" -e --build-property "build.partitions=partition_twatch" --build-property "upload.maximum_size=8388608" --build-property "compiler.cpp.extra_flags=-DBOARD_MODEL=0x45"
arduino-cli compile --log --fqbn "esp32:esp32:esp32s3:USBMode=default,CDCOnBoot=cdc,FlashSize=16M,PSRAM=enabled" -e --build-property "build.partitions=partition_twatch" --build-property "upload.maximum_size=8388608" --build-property "compiler.cpp.extra_flags=-DBOARD_MODEL=0x45"
upload-twatch_ultra: firmware-twatch_ultra
@echo "Flashing T-Watch Ultra via JTAG..."

View file

@ -50,6 +50,15 @@
// IMU data logger to SD card
#include "IMULogger.h"
// Shared SPI bus mutex (LoRa + SD + NFC)
#include "SharedSPI.h"
SemaphoreHandle_t shared_spi_mutex = NULL; // definition (declared extern in SharedSPI.h)
// USB Mass Storage for SD card access (TinyUSB OTG mode only)
#if !ARDUINO_USB_MODE
#include "USBSD.h"
#endif
// CST9217 capacitive touch panel
#include <touch/TouchDrvCST92xx.h>
TouchDrvCST92xx touch;
@ -105,6 +114,12 @@ char sbuf[128];
void setup() {
#if MCU_VARIANT == MCU_ESP32
boot_seq();
// Init shared SPI bus mutex before any SPI users
#if BOARD_MODEL == BOARD_TWATCH_ULT
shared_spi_init();
#endif
EEPROM.begin(EEPROM_SIZE);
Serial.setRxBufferSize(CONFIG_UART_BUFFER_SIZE);
@ -169,6 +184,8 @@ void setup() {
Serial.begin(serial_baudrate);
// USB MSC init moved to after T-Watch hardware init (see below)
#if HAS_NP
led_init();
#endif
@ -343,6 +360,8 @@ void setup() {
speaker_init();
mic_init();
// USB MSC SD card — deferred to main loop (SPI bus needs LoRa init first)
// BHI260AP init deferred — firmware upload takes ~10s at 1MHz I2C
// and blocks serial communication during boot. Will be initialized
// lazily from the main loop after radio is up.
@ -2091,6 +2110,15 @@ void loop() {
#endif
#endif
// Deferred USB MSC SD card init — needs SPI bus configured by LoRa first
#if BOARD_MODEL == BOARD_TWATCH_ULT && HAS_SD && !ARDUINO_USB_MODE
if (!usb_sd_ready && millis() > 3000) {
if (usb_sd_init()) {
Serial.println("[usb_sd] SD card mounted");
}
}
#endif
// 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

24
SharedSPI.h Normal file
View file

@ -0,0 +1,24 @@
// SharedSPI — global mutex for the shared SPI bus (pins MISO=33, MOSI=34, CLK=35)
//
// Users of this bus MUST acquire shared_spi_mutex before any SPI transaction
// and release it after. This prevents race conditions between:
// - SX1262 LoRa radio (CS=36) — main loop, continuous polling
// - SD card (CS=21) — USB MSC callbacks (TinyUSB task), IMU logger, screenshots
// - ST25R3916 NFC (CS=4) — future, not yet implemented
//
// The CO5300 display uses a separate SPI3 bus and does NOT need this mutex.
#ifndef SHARED_SPI_H
#define SHARED_SPI_H
#include <freertos/semphr.h>
extern SemaphoreHandle_t shared_spi_mutex;
inline void shared_spi_init() {
if (!shared_spi_mutex) {
shared_spi_mutex = xSemaphoreCreateMutex();
}
}
#endif // SHARED_SPI_H

92
USBSD.h Normal file
View file

@ -0,0 +1,92 @@
// USB Mass Storage — exposes SD card via USB alongside CDC serial
// Requires USBMode=default (TinyUSB) in the FQBN build flags.
// Uses shared_spi_mutex from SharedSPI.h to coordinate with LoRa radio.
#ifndef USBSD_H
#define USBSD_H
#if BOARD_MODEL == BOARD_TWATCH_ULT && HAS_SD && !ARDUINO_USB_MODE
#include "USB.h"
#include "USBMSC.h"
#include <SD.h>
#include "SharedSPI.h"
static USBMSC usb_msc;
bool usb_sd_ready = false;
uint32_t usb_sd_read_count = 0;
uint32_t usb_sd_read_fail = 0;
static int32_t usb_sd_read(uint32_t lba, uint32_t offset, void *buffer, uint32_t bufsize) {
if (!usb_sd_ready) { usb_sd_read_fail++; return -1; }
if (xSemaphoreTake(shared_spi_mutex, pdMS_TO_TICKS(500)) != pdTRUE) {
usb_sd_read_fail++;
return -1;
}
int32_t result = bufsize;
uint32_t sec = lba + (offset / 512);
uint32_t cnt = bufsize / 512;
for (uint32_t i = 0; i < cnt; i++) {
if (!SD.readRAW((uint8_t *)buffer + i * 512, sec + i)) {
usb_sd_read_fail++;
result = -1;
break;
}
}
xSemaphoreGive(shared_spi_mutex);
usb_sd_read_count++;
return result;
}
static int32_t usb_sd_write(uint32_t lba, uint32_t offset, uint8_t *buffer, uint32_t bufsize) {
if (!usb_sd_ready) return -1;
if (xSemaphoreTake(shared_spi_mutex, pdMS_TO_TICKS(200)) != pdTRUE) return -1;
int32_t result = bufsize;
uint32_t sec = lba + (offset / 512);
uint32_t cnt = bufsize / 512;
for (uint32_t i = 0; i < cnt; i++) {
if (!SD.writeRAW(buffer + i * 512, sec + i)) { result = -1; break; }
}
xSemaphoreGive(shared_spi_mutex);
return result;
}
static bool usb_sd_start_stop(uint8_t power_condition, bool start, bool load_eject) {
return true;
}
bool usb_sd_init() {
usb_msc.vendorID("RNode");
usb_msc.productID("R-Watch SD");
usb_msc.productRevision("1.0");
usb_msc.onRead(usb_sd_read);
usb_msc.onWrite(usb_sd_write);
usb_msc.onStartStop(usb_sd_start_stop);
// Init SD card — acquire mutex since LoRa may already be using the bus
if (shared_spi_mutex) xSemaphoreTake(shared_spi_mutex, portMAX_DELAY);
SPI.begin(SD_CLK, SD_MISO, SD_MOSI, SD_CS);
bool ok = SD.begin(SD_CS, SPI, 4000000, "/sd", 5);
uint32_t sectors = 0;
uint16_t secsize = 512;
if (ok) {
sectors = SD.numSectors();
secsize = SD.sectorSize();
}
if (shared_spi_mutex) xSemaphoreGive(shared_spi_mutex);
if (!ok || sectors == 0) {
usb_msc.mediaPresent(false);
usb_msc.begin(0, 512);
return false;
}
usb_msc.mediaPresent(true);
usb_msc.begin(sectors, secsize);
usb_sd_ready = true;
return true;
}
#endif
#endif

View file

@ -100,6 +100,8 @@
extern SPIClass SPI;
#include "SharedSPI.h"
#define MAX_PKT_LENGTH 255
sx126x::sx126x() :
@ -170,6 +172,7 @@ uint8_t ISR_VECT sx126x::singleTransfer(uint8_t opcode, uint16_t address, uint8_
waitOnBusy();
uint8_t response;
if (shared_spi_mutex) xSemaphoreTake(shared_spi_mutex, portMAX_DELAY);
digitalWrite(_ss, LOW);
SPI.beginTransaction(_spiSettings);
SPI.transfer(opcode);
@ -178,8 +181,8 @@ uint8_t ISR_VECT sx126x::singleTransfer(uint8_t opcode, uint16_t address, uint8_
if (opcode == OP_READ_REGISTER_6X) { SPI.transfer(0x00); }
response = SPI.transfer(value);
SPI.endTransaction();
digitalWrite(_ss, HIGH);
if (shared_spi_mutex) xSemaphoreGive(shared_spi_mutex);
return response;
}
@ -205,16 +208,19 @@ void sx126x::waitOnBusy() {
void sx126x::executeOpcode(uint8_t opcode, uint8_t *buffer, uint8_t size) {
waitOnBusy();
if (shared_spi_mutex) xSemaphoreTake(shared_spi_mutex, portMAX_DELAY);
digitalWrite(_ss, LOW);
SPI.beginTransaction(_spiSettings);
SPI.transfer(opcode);
for (int i = 0; i < size; i++) { SPI.transfer(buffer[i]); }
SPI.endTransaction();
digitalWrite(_ss, HIGH);
if (shared_spi_mutex) xSemaphoreGive(shared_spi_mutex);
}
void sx126x::executeOpcodeRead(uint8_t opcode, uint8_t *buffer, uint8_t size) {
waitOnBusy();
if (shared_spi_mutex) xSemaphoreTake(shared_spi_mutex, portMAX_DELAY);
digitalWrite(_ss, LOW);
SPI.beginTransaction(_spiSettings);
SPI.transfer(opcode);
@ -222,10 +228,12 @@ void sx126x::executeOpcodeRead(uint8_t opcode, uint8_t *buffer, uint8_t size) {
for (int i = 0; i < size; i++) { buffer[i] = SPI.transfer(0x00); }
SPI.endTransaction();
digitalWrite(_ss, HIGH);
if (shared_spi_mutex) xSemaphoreGive(shared_spi_mutex);
}
void sx126x::writeBuffer(const uint8_t* buffer, size_t size) {
waitOnBusy();
if (shared_spi_mutex) xSemaphoreTake(shared_spi_mutex, portMAX_DELAY);
digitalWrite(_ss, LOW);
SPI.beginTransaction(_spiSettings);
SPI.transfer(OP_FIFO_WRITE_6X);
@ -233,10 +241,12 @@ void sx126x::writeBuffer(const uint8_t* buffer, size_t size) {
for (int i = 0; i < size; i++) { SPI.transfer(buffer[i]); _fifo_tx_addr_ptr++; }
SPI.endTransaction();
digitalWrite(_ss, HIGH);
if (shared_spi_mutex) xSemaphoreGive(shared_spi_mutex);
}
void sx126x::readBuffer(uint8_t* buffer, size_t size) {
waitOnBusy();
if (shared_spi_mutex) xSemaphoreTake(shared_spi_mutex, portMAX_DELAY);
digitalWrite(_ss, LOW);
SPI.beginTransaction(_spiSettings);
SPI.transfer(OP_FIFO_READ_6X);
@ -245,6 +255,7 @@ void sx126x::readBuffer(uint8_t* buffer, size_t size) {
for (int i = 0; i < size; i++) { buffer[i] = SPI.transfer(0x00); }
SPI.endTransaction();
digitalWrite(_ss, HIGH);
if (shared_spi_mutex) xSemaphoreGive(shared_spi_mutex);
}
void sx126x::setModulationParams(uint8_t sf, uint8_t bw, uint8_t cr, int ldro) {
@ -873,12 +884,14 @@ uint8_t sx126x::getModemStatus() {
// byte after the opcode, not in the data buffer.
waitOnBusy();
uint8_t status;
if (shared_spi_mutex) xSemaphoreTake(shared_spi_mutex, portMAX_DELAY);
digitalWrite(_ss, LOW);
SPI.beginTransaction(_spiSettings);
SPI.transfer(OP_STATUS_6X);
status = SPI.transfer(0x00);
SPI.endTransaction();
digitalWrite(_ss, HIGH);
if (shared_spi_mutex) xSemaphoreGive(shared_spi_mutex);
return status;
}