diff --git a/Gui.h b/Gui.h index 28c631a..c333eff 100644 --- a/Gui.h +++ b/Gui.h @@ -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 +#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; diff --git a/IMULogger.h b/IMULogger.h index c62bf32..543adc8 100644 --- a/IMULogger.h +++ b/IMULogger.h @@ -11,6 +11,7 @@ #if BOARD_MODEL == BOARD_TWATCH_ULT && HAS_SD #include +#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 diff --git a/Makefile b/Makefile index a1c3fda..f913b50 100644 --- a/Makefile +++ b/Makefile @@ -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..." diff --git a/RNode_Firmware.ino b/RNode_Firmware.ino index d44318c..74d3956 100644 --- a/RNode_Firmware.ino +++ b/RNode_Firmware.ino @@ -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 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 diff --git a/SharedSPI.h b/SharedSPI.h new file mode 100644 index 0000000..711e748 --- /dev/null +++ b/SharedSPI.h @@ -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 + +extern SemaphoreHandle_t shared_spi_mutex; + +inline void shared_spi_init() { + if (!shared_spi_mutex) { + shared_spi_mutex = xSemaphoreCreateMutex(); + } +} + +#endif // SHARED_SPI_H diff --git a/USBSD.h b/USBSD.h new file mode 100644 index 0000000..93791be --- /dev/null +++ b/USBSD.h @@ -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 +#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 diff --git a/sx126x.cpp b/sx126x.cpp index 480206f..04f3670 100644 --- a/sx126x.cpp +++ b/sx126x.cpp @@ -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; }