From 5b79da87f2deec82570a84501526e7b16dc3e048 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thomas=20G=C3=B6ttgens?= Date: Sun, 12 Jul 2026 17:13:32 +0200 Subject: [PATCH 01/21] Generic peripheral bridge for the interdevice link The RP2040 serves the ESP32 main firmware as a dumb bridge: I2C transactions and bus scans execute on the local bus, GPS NMEA is forwarded, and the SD card is exposed through chunked file transfers, directory listings and card statistics. The link runs at 2M baud with 4KB chunks; message structs are static since they exceed the core stack. SD init retries on demand, the slot has no card detect line. --- .trunk/configs/.shellcheckrc | 7 + .trunk/trunk.yaml | 20 +- .vscode/extensions.json | 4 +- .vscode/settings.json | 18 ++ platformio.ini | 76 +++--- protobufs | 2 +- src/buzzer.cpp | 10 + src/buzzer.h | 16 ++ src/main.cpp | 412 ++++-------------------------- src/meshtastic/interdevice.pb.cpp | 28 +- src/meshtastic/interdevice.pb.h | 317 ++++++++++++++++++----- src/serial_proto.cpp | 289 +++++++++++++++++++-- src/serial_proto.h | 9 +- 13 files changed, 704 insertions(+), 504 deletions(-) create mode 100644 .trunk/configs/.shellcheckrc create mode 100644 .vscode/settings.json create mode 100644 src/buzzer.cpp create mode 100644 src/buzzer.h diff --git a/.trunk/configs/.shellcheckrc b/.trunk/configs/.shellcheckrc new file mode 100644 index 0000000..8c7b1ad --- /dev/null +++ b/.trunk/configs/.shellcheckrc @@ -0,0 +1,7 @@ +enable=all +source-path=SCRIPTDIR +disable=SC2154 + +# If you're having issues with shellcheck following source, disable the errors via: +# disable=SC1090 +# disable=SC1091 diff --git a/.trunk/trunk.yaml b/.trunk/trunk.yaml index 24c2da8..6a85106 100644 --- a/.trunk/trunk.yaml +++ b/.trunk/trunk.yaml @@ -2,27 +2,31 @@ # To learn more about the format of this file, see https://docs.trunk.io/reference/trunk-yaml version: 0.1 cli: - version: 1.22.10 + version: 1.24.0 # Trunk provides extensibility via plugins. (https://docs.trunk.io/plugins) plugins: sources: - id: trunk - ref: v1.6.7 + ref: v1.7.1 uri: https://github.com/trunk-io/plugins # Many linters and tools depend on runtimes - configure them here. (https://docs.trunk.io/runtimes) runtimes: enabled: - - node@18.20.5 + - go@1.21.0 + - node@22.16.0 - python@3.10.8 # This is the section where you manage your linters. (https://docs.trunk.io/check/configuration) lint: enabled: - - markdownlint@0.44.0 - - checkov@3.2.378 + - oxipng@9.1.5 + - shellcheck@0.10.0 + - shfmt@3.6.0 + - markdownlint@0.45.0 + - checkov@3.2.447 - git-diff-check - - prettier@3.5.2 - - trufflehog@3.88.14 - - yamllint@1.35.1 + - prettier@3.6.2 + - trufflehog@3.89.2 + - yamllint@1.37.1 - clang-format@16.0.3 actions: disabled: diff --git a/.vscode/extensions.json b/.vscode/extensions.json index 080e70d..411655e 100644 --- a/.vscode/extensions.json +++ b/.vscode/extensions.json @@ -1,7 +1,7 @@ { - // See http://go.microsoft.com/fwlink/?LinkId=827846 - // for the documentation about the extensions.json format "recommendations": [ + "Jason2866.esp-decoder", + "pioarduino.pioarduino-ide", "platformio.platformio-ide" ], "unwantedRecommendations": [ diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..b9d1238 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,18 @@ +{ + "files.associations": { + "random": "cpp", + "array": "cpp", + "deque": "cpp", + "list": "cpp", + "string": "cpp", + "unordered_map": "cpp", + "vector": "cpp", + "string_view": "cpp", + "format": "cpp", + "initializer_list": "cpp", + "span": "cpp", + "text_encoding": "cpp", + "regex": "cpp", + "chrono": "cpp" + } +} diff --git a/platformio.ini b/platformio.ini index fdd10f1..b79b94b 100644 --- a/platformio.ini +++ b/platformio.ini @@ -1,40 +1,36 @@ -; PlatformIO Project Configuration File -; -; Build options: build flags, source filter -; Upload options: custom upload port, speed and extra flags -; Library options: dependencies, extra library storages -; Advanced options: extra scripting -; -; Please visit documentation for the other options and examples -; https://docs.platformio.org/page/projectconf.html - -[env:seeed_indicator_rp2040] -platform = https://github.com/maxgerhardt/platform-raspberrypi.git#c7502925e3b08af70e9f924d54ab9d00a7e64781 -platform_packages = framework-arduinopico@https://github.com/earlephilhower/arduino-pico.git#4.4.4 -framework = arduino - -board = seeed_indicator_rp2040 -board_build.core = earlephilhower -board_build.filesystem_size = 0.5m - -build_flags = - -Wno-missing-field-initializers - -Wno-format - -Isrc - -Isrc/generated - -Wl,-Map,.pio/build/output.map - -Os - -Wno-unused-variable - -Wcast-align - -D__PLAT_RP2040__ - -lib_deps = - sensirion/Sensirion Core@0.7.1 - sensirion/Sensirion I2C SCD4x@1.0.0 - sensirion/Sensirion I2C SGP40@0.1.0 - sensirion/Sensirion Gas Index Algorithm@3.2.3 - nanopb/Nanopb@0.4.91 - https://github.com/Seeed-Studio/Seeed_Arduino_AHT20.git#v1.0.1 - - -monitor_speed = 115200 +; PlatformIO Project Configuration File +; +; Build options: build flags, source filter +; Upload options: custom upload port, speed and extra flags +; Library options: dependencies, extra library storages +; Advanced options: extra scripting +; +; Please visit documentation for the other options and examples +; https://docs.platformio.org/page/projectconf.html + +[env:seeed_indicator_rp2040] +platform = https://github.com/maxgerhardt/platform-raspberrypi.git#aa70b802be8851668053d4f09734e4089fe41932 +framework = arduino + +board = seeed_indicator_rp2040 +board_build.core = earlephilhower + +build_flags = + -Wno-missing-field-initializers + -Wno-format + -Isrc + -Wl,-Map,.pio/build/output.map + -Os + -Wno-unused-variable + -Wcast-align + -D__PLAT_RP2040__ + -DDEBUG_RP2040_PORT=Serial + -DHW_SPI1_DEVICE + +lib_deps = + # renovate: datasource=custom.pio depName=Nanopb packageName=nanopb/library/Nanopb + nanopb/Nanopb@0.4.91 + +monitor_speed = 115200 + +debug_tool = cmsis-dap diff --git a/protobufs b/protobufs index 2a3a67f..641fc9f 160000 --- a/protobufs +++ b/protobufs @@ -1 +1 @@ -Subproject commit 2a3a67f0431926dc3f32a8b216d264daab09b9bf +Subproject commit 641fc9faa2640298d1a4def8fb38c91d241b0718 diff --git a/src/buzzer.cpp b/src/buzzer.cpp new file mode 100644 index 0000000..6b0814f --- /dev/null +++ b/src/buzzer.cpp @@ -0,0 +1,10 @@ +#include "buzzer.h" + + uint32_t buzz_off = 0; + +void beep_init(void) { pinMode(BUZZER_GPIO, OUTPUT); } +void beep_off(void) { digitalWrite(BUZZER_GPIO, LOW); } +void beep_on(uint32_t duration) { + analogWrite(BUZZER_GPIO, 127); + buzz_off = millis() + duration; +} \ No newline at end of file diff --git a/src/buzzer.h b/src/buzzer.h new file mode 100644 index 0000000..2bf2624 --- /dev/null +++ b/src/buzzer.h @@ -0,0 +1,16 @@ +#pragma once + +#ifndef BUZZER_H +#define BUZZER_H + +#define BUZZER_GPIO 19 + +#include + +extern uint32_t buzz_off; + +void beep_init(void); +void beep_off(void); +void beep_on(uint32_t duration); + +#endif // BUZZER_H \ No newline at end of file diff --git a/src/main.cpp b/src/main.cpp index 120eabe..c1de627 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -1,12 +1,14 @@ -#include "AHT20.h" -#include "serial_proto.h" #include -#include -#include -#include +#include +#include #include +#include "serial_proto.h" +#include "buzzer.h" + +#define VERSION "v2.0.0M" -#define VERSION "v1.0.2M" +// use pin 26 and 27 for the GPS Chip serial link +SerialPIO softSerial2(26, 27, 1024); #define SENSECAP \ "\n\ @@ -20,392 +22,86 @@ --------------------------------------------------------\n\ " -AHT20 AHT; -SensirionI2CSgp40 sgp40; -SensirionI2cScd4x scd4x; -VOCGasIndexAlgorithm voc_algorithm; - -const int BUFFER_SIZE = - 1024; // Buffer size for NMEA sentences (generous for Multi-GNSS) +// Buffer size for NMEA sentences (generous for Multi-GNSS) +const int BUFFER_SIZE = 1024; char nmeaBuffer[BUFFER_SIZE]; int bufferIndex = 0; -bool active = false; -uint32_t collectInterval = 5000; - -// sensor data send to esp32 -void sensor_data_send(meshtastic_MessageType type, float data) { - meshtastic_InterdeviceMessage myPacket = - meshtastic_InterdeviceMessage_init_default; - myPacket.data.sensor.type = type; - myPacket.data.sensor.data.float_value = data; - mt_send_uplink(myPacket); -} - -void printSerialNumber(uint64_t value) { - Serial.print("Serial: 0x"); - Serial.print(value < 0x1000000000000000 ? "0" : ""); - Serial.print(value < 0x100000000000000 ? "0" : ""); - Serial.print(value < 0x10000000000000 ? "0" : ""); - Serial.print(value < 0x1000000000000 ? "0" : ""); - Serial.print(value < 0x100000000000 ? "0" : ""); - Serial.print(value < 0x10000000000 ? "0" : ""); - Serial.print(value < 0x1000000000 ? "0" : ""); - Serial.print(value < 0x100000000 ? "0" : ""); - Serial.print(value < 0x10000000 ? "0" : ""); - Serial.print(value < 0x1000000 ? "0" : ""); - Serial.print(value < 0x100000 ? "0" : ""); - Serial.print(value < 0x10000 ? "0" : ""); - Serial.print(value < 0x1000 ? "0" : ""); - Serial.print(value < 0x100 ? "0" : ""); - Serial.print(value < 0x10 ? "0" : ""); - Serial.print(value, HEX); - Serial.println(); -} - -float temperature = 0.0; -float humidity = 0.0; - -uint16_t defaultCompenstaionRh = 0x8000; -uint16_t defaultCompenstaionT = 0x6666; - -uint16_t compensationRh = defaultCompenstaionRh; -uint16_t compensationT = defaultCompenstaionT; - -uint32_t sent = 0; - -/************************ aht temp & humidity ****************************/ - -void sensor_aht_init(void) { AHT.begin(); } - -void sensor_aht_get(void) { - - float humi, temp; - - int ret = AHT.getSensor(&humi, &temp); - - if (ret) { - // GET DATA OK - Serial.print("humidity: "); - Serial.print(humi * 100); - Serial.print("%\t temerature: "); - Serial.println(temp); - temperature = temp; - humidity = humi * 100; - compensationT = static_cast((temperature + 45) * 65535 / 175); - compensationRh = static_cast(humidity * 65535 / 100); - } else { - // GET DATA FAIL - Serial.println("GET DATA FROM AHT20 FAIL"); - compensationRh = defaultCompenstaionRh; - compensationT = defaultCompenstaionT; - } - - if (ret) { - sensor_data_send(meshtastic_MessageType_AHT20_TEMP, temperature); - sensor_data_send(meshtastic_MessageType_AHT20_HUMIDITY, humidity); - } -} - -/************************ sgp40 tvoc ****************************/ - -void sensor_sgp40_init(void) { - uint16_t error; - char errorMessage[256]; - - sgp40.begin(Wire); - - uint16_t serialNumber[3]; - uint8_t serialNumberSize = 3; - - error = sgp40.getSerialNumber(serialNumber, serialNumberSize); - - if (error) { - Serial.print("Error trying to execute getSerialNumber(): "); - errorToString(error, errorMessage, 256); - Serial.println(errorMessage); - } else { - Serial.print("SerialNumber:"); - Serial.print("0x"); - for (size_t i = 0; i < serialNumberSize; i++) { - uint16_t value = serialNumber[i]; - Serial.print(value < 4096 ? "0" : ""); - Serial.print(value < 256 ? "0" : ""); - Serial.print(value < 16 ? "0" : ""); - Serial.print(value, HEX); - } - Serial.println(); - } - - uint16_t testResult; - error = sgp40.executeSelfTest(testResult); - if (error) { - Serial.print("Error trying to execute executeSelfTest(): "); - errorToString(error, errorMessage, 256); - Serial.println(errorMessage); - } else if (testResult != 0xD400) { - Serial.print("executeSelfTest failed with error: "); - Serial.println(testResult); - } -} - -void sensor_sgp40_get(void) { - uint16_t error; - char errorMessage[256]; - uint16_t defaultRh = 0x8000; - uint16_t defaultT = 0x6666; - uint16_t srawVoc = 0; - - Serial.print("sensor sgp40: "); - - error = sgp40.measureRawSignal(compensationRh, compensationT, srawVoc); - if (error) { - Serial.print("Error trying to execute measureRawSignal(): "); - errorToString(error, errorMessage, 256); - Serial.println(errorMessage); - } else { - Serial.print("SRAW_VOC:"); - Serial.println(srawVoc); - } - - if (!error) { - int32_t voc_index = voc_algorithm.process(srawVoc); - Serial.print("VOC Index: "); - Serial.println(voc_index); - - sensor_data_send(meshtastic_MessageType_TVOC_INDEX, (float)voc_index); - } -} - -/************************ scd4x co2 ****************************/ - -void sensor_scd4x_init(void) { - uint16_t error; - char errorMessage[256]; - - scd4x.begin(Wire, 0x62); - - // stop potentially previously started measurement - error = scd4x.stopPeriodicMeasurement(); - if (error) { - Serial.print("Error trying to execute stopPeriodicMeasurement(): "); - errorToString(error, errorMessage, 256); - Serial.println(errorMessage); - } - - uint64_t serial; - - error = scd4x.getSerialNumber(serial); - if (error) { - Serial.print("Error trying to execute getSerialNumber(): "); - errorToString(error, errorMessage, 256); - Serial.println(errorMessage); - } else { - printSerialNumber(serial); - } - - // Start Measurement - error = scd4x.startPeriodicMeasurement(); - if (error) { - Serial.print("Error trying to execute startPeriodicMeasurement(): "); - errorToString(error, errorMessage, 256); - Serial.println(errorMessage); - } - // scd4x.powerDown(); -} - -void sensor_scd4x_get(void) { - uint16_t error; - char errorMessage[256]; - - Serial.print("sensor scd4x: "); - // Read Measurement - uint16_t co2; - float temperature; - float humidity; - error = scd4x.readMeasurement(co2, temperature, humidity); - if (error) { - Serial.print("Error trying to execute readMeasurement(): "); - errorToString(error, errorMessage, 256); - Serial.println(errorMessage); - } else if (co2 == 0) { - Serial.println("Invalid sample detected, skipping."); - } else { - Serial.print("Co2:"); - Serial.print(co2); - Serial.print("\t"); - Serial.print("Temperature:"); - Serial.print(temperature); - Serial.print("\t"); - Serial.print("Humidity:"); - Serial.println(humidity); - } - - if (!error) { - sensor_data_send(meshtastic_MessageType_SCD41_CO2, (float)co2); - sensor_data_send(meshtastic_MessageType_SCD41_TEMP, temperature); - sensor_data_send(meshtastic_MessageType_SCD41_HUMIDITY, humidity); - } +bool sd_init_flag = 0; +const int SD_CS_PIN = 13; + +// (Re-)initialize the SD card on demand. The Indicator's slot has no +// card-detect line, so retry rate-limited whenever a file operation +// arrives; this makes inserting a card after boot just work. +bool sd_ensure(void) { + static uint32_t last_attempt = 0; + if (sd_init_flag) + return true; + if (last_attempt != 0 && millis() - last_attempt < 2000) + return false; + last_attempt = millis(); + if (!SD.begin(SD_CS_PIN, 16000000, SPI1)) { + Serial.println("Card failed, or not present"); + return false; + } + Serial.println("card initialized."); + sd_init_flag = 1; + return true; } +// The built-in sensor needs to be powered on void sensor_power_on(void) { pinMode(18, OUTPUT); digitalWrite(18, HIGH); - active = true; - delay(100); - sensor_aht_init(); - sensor_sgp40_init(); - sensor_scd4x_init(); - sent = 0; -} - -void sensor_power_off(void) { - pinMode(18, OUTPUT); - digitalWrite(18, LOW); - active = false; } /************************ beep ****************************/ -#define Buzzer 19 // Buzzer GPIO - -uint32_t buzz_off = 0; - -void beep_init(void) { pinMode(Buzzer, OUTPUT); } -void beep_off(void) { digitalWrite(Buzzer, LOW); } -void beep_on(uint32_t duration) { - analogWrite(Buzzer, 127); - buzz_off = millis() + duration; -} - -/************************ grove ****************************/ - -// Don't send this for now, we need the 2nd port for the GPS - -// void grove_adc_get(void) { -// String dataString = ""; -// int adc0 = analogRead(26); -// dataString += String(adc0); -// dataString += ','; -// int adc1 = analogRead(27); -// dataString += String(adc1); -// Serial.print("grove adc: "); -// Serial.println(dataString); -// } - -/************************ recv cmd from esp32 ****************************/ - -static bool shutdown_flag = false; - -void onPacketReceived(meshtastic_SensorData sensor) { - switch (sensor.type) { - case meshtastic_MessageType_POWER_ON: { - Serial.println("cmd power on"); - sensor_power_on(); - break; - } - case meshtastic_MessageType_SHUTDOWN: { - Serial.println("cmd shutdown"); - shutdown_flag = true; - sensor_power_off(); - break; - } - case meshtastic_MessageType_BEEP_ON: { - Serial.println("cmd beep on"); - beep_on(sensor.data.uint32_value); - break; - } - case meshtastic_MessageType_BEEP_OFF: { - Serial.println("cmd beep off"); - beep_off(); - break; - } - case meshtastic_MessageType_COLLECT_INTERVAL: { - collectInterval = sensor.data.uint32_value; - Serial.println("cmd collect interval " + String(collectInterval)); - break; - } - - default: - break; - } -} - void onNmeaReceived(char *nmea) { // pass this on to the GPS chip - Serial2.print(nmea); + softSerial2.print(nmea); } /************************ setup & loop ****************************/ -int cnt = 0; - void setup() { - // The Virtual USB Serial + // The Virtual USB Serial for debug logging Serial.begin(115200); + sensor_power_on(); + // this is the device link to the ESP32-S3 CPU + // FIFO must hold a full request frame (~4.7KB for chunked writes) plus + // whatever arrives while a response transmit blocks (~23ms at 2M baud) + Serial1.setFIFOSize(8192); Serial1.setRX(17); Serial1.setTX(16); - Serial1.begin(115200); + Serial1.begin(2000000); - // use pin 26 and 27 for the GPS Chip serial link - Serial2.setRX(27); - Serial2.setTX(26); - Serial2.begin(9600); + softSerial2.begin(9600); // I2C is on 20/21 Wire.setSDA(20); Wire.setSCL(21); Wire.begin(); - mt_set_sensor_callback(onPacketReceived); - mt_set_nmea_callback(onNmeaReceived); - - int32_t index_offset; - int32_t learning_time_offset_hours; - int32_t learning_time_gain_hours; - int32_t gating_max_duration_minutes; - int32_t std_initial; - int32_t gain_factor; - voc_algorithm.get_tuning_parameters( - index_offset, learning_time_offset_hours, learning_time_gain_hours, - gating_max_duration_minutes, std_initial, gain_factor); + // SD Card slot is on SPI1 + SPI1.setSCK(10); + SPI1.setTX(11); + SPI1.setRX(12); + sd_ensure(); - Serial.println("\nVOC Gas Index Algorithm parameters"); - Serial.print("Index offset:\t"); - Serial.println(index_offset); - Serial.print("Learing time offset hours:\t"); - Serial.println(learning_time_offset_hours); - Serial.print("Learing time gain hours:\t"); - Serial.println(learning_time_gain_hours); - Serial.print("Gating max duration minutes:\t"); - Serial.println(gating_max_duration_minutes); - Serial.print("Std inital:\t"); - Serial.println(std_initial); - Serial.print("Gain factor:\t"); - Serial.println(gain_factor); + mt_set_nmea_callback(onNmeaReceived); beep_init(); delay(500); - beep_on(50); Serial.printf(SENSECAP, VERSION); + beep_on(50); } void loop() { - if ((sent == 0 || (millis() - sent > collectInterval)) && (active)) { - Serial.printf("\r\n\r\n--------- start measure %d-------\r\n", cnt); - cnt++; - sensor_aht_get(); - sensor_sgp40_get(); - sensor_scd4x_get(); - // Don't send this for now, we need the 2nd port for the GPS - // grove_adc_get(); - sent = millis(); - } - if (buzz_off > 0 && millis() > buzz_off) { beep_off(); buzz_off = 0; @@ -414,15 +110,19 @@ void loop() { mt_loop(); // read GPS data lines into buffer and send it to the ESP32 - while (Serial2.available()) { - char c = Serial2.read(); + while (softSerial2.available()) { + char c = softSerial2.read(); if (c == '\n') { nmeaBuffer[bufferIndex] = '\0'; - Serial.print(nmeaBuffer); - meshtastic_InterdeviceMessage myPacket = - meshtastic_InterdeviceMessage_init_default; + Serial.println(nmeaBuffer); + // static: ~4.6KB struct, too large for the core stack + static meshtastic_InterdeviceMessage myPacket; + memset(&myPacket, 0, sizeof(myPacket)); + myPacket.which_data = meshtastic_InterdeviceMessage_nmea_tag; + myPacket.data.nmea[0] = '\0'; // Ensure the string is null strncpy(myPacket.data.nmea, nmeaBuffer, bufferIndex); + myPacket.data.nmea[bufferIndex] = '\0'; // Explicitly null-terminate the string mt_send_uplink(myPacket); bufferIndex = 0; break; diff --git a/src/meshtastic/interdevice.pb.cpp b/src/meshtastic/interdevice.pb.cpp index 4b99563..5c16746 100644 --- a/src/meshtastic/interdevice.pb.cpp +++ b/src/meshtastic/interdevice.pb.cpp @@ -6,6 +6,30 @@ #error Regenerate this file with the current version of nanopb generator. #endif -PB_BIND(meshtastic_SensorData, meshtastic_SensorData, AUTO) +PB_BIND(meshtastic_FileTransfer, meshtastic_FileTransfer, 4) + + +PB_BIND(meshtastic_DirectoryListing, meshtastic_DirectoryListing, 2) + + +PB_BIND(meshtastic_I2CTransaction, meshtastic_I2CTransaction, AUTO) + + +PB_BIND(meshtastic_SdCardInfo, meshtastic_SdCardInfo, AUTO) + + +PB_BIND(meshtastic_I2CResult, meshtastic_I2CResult, AUTO) + + +PB_BIND(meshtastic_InterdeviceMessage, meshtastic_InterdeviceMessage, 4) + + + + + + + + + + -PB_BIND(meshtastic_InterdeviceMessage, meshtastic_InterdeviceMessage, 2) diff --git a/src/meshtastic/interdevice.pb.h b/src/meshtastic/interdevice.pb.h index e275d13..91973ed 100644 --- a/src/meshtastic/interdevice.pb.h +++ b/src/meshtastic/interdevice.pb.h @@ -10,104 +10,279 @@ #endif /* Enum definitions */ -typedef enum _meshtastic_MessageType { - meshtastic_MessageType_ACK = 0, - meshtastic_MessageType_COLLECT_INTERVAL = 160, /* in ms */ - meshtastic_MessageType_BEEP_ON = 161, /* duration ms */ - meshtastic_MessageType_BEEP_OFF = 162, /* cancel prematurely */ - meshtastic_MessageType_SHUTDOWN = 163, - meshtastic_MessageType_POWER_ON = 164, - meshtastic_MessageType_SCD41_TEMP = 176, - meshtastic_MessageType_SCD41_HUMIDITY = 177, - meshtastic_MessageType_SCD41_CO2 = 178, - meshtastic_MessageType_AHT20_TEMP = 179, - meshtastic_MessageType_AHT20_HUMIDITY = 180, - meshtastic_MessageType_TVOC_INDEX = 181 -} meshtastic_MessageType; +/* Defines the supported file operations */ +typedef enum _meshtastic_FileOperation { + meshtastic_FileOperation_GET = 0, + meshtastic_FileOperation_POST = 1, + meshtastic_FileOperation_PUT = 2, + meshtastic_FileOperation_DELETE = 3 +} meshtastic_FileOperation; + +typedef enum _meshtastic_SdCardInfo_CardType { + meshtastic_SdCardInfo_CardType_NONE = 0, + meshtastic_SdCardInfo_CardType_MMC = 1, + meshtastic_SdCardInfo_CardType_SD = 2, + meshtastic_SdCardInfo_CardType_SDHC = 3, + meshtastic_SdCardInfo_CardType_SDXC = 4, + meshtastic_SdCardInfo_CardType_UNKNOWN_CARD = 5 +} meshtastic_SdCardInfo_CardType; + +typedef enum _meshtastic_SdCardInfo_FatType { + meshtastic_SdCardInfo_FatType_UNKNOWN_FAT = 0, + meshtastic_SdCardInfo_FatType_FAT16 = 1, + meshtastic_SdCardInfo_FatType_FAT32 = 2, + meshtastic_SdCardInfo_FatType_EXFAT = 3 +} meshtastic_SdCardInfo_FatType; + +typedef enum _meshtastic_I2CResult_Status { + meshtastic_I2CResult_Status_OK = 0, + meshtastic_I2CResult_Status_NACK_ADDRESS = 1, + meshtastic_I2CResult_Status_NACK_DATA = 2, + meshtastic_I2CResult_Status_ERROR = 3 +} meshtastic_I2CResult_Status; /* Struct definitions */ -typedef struct _meshtastic_SensorData { - /* The message type */ - meshtastic_MessageType type; - pb_size_t which_data; - union { - float float_value; - uint32_t uint32_value; - } data; -} meshtastic_SensorData; +typedef PB_BYTES_ARRAY_T(4096) meshtastic_FileTransfer_filedata_t; +/* Message for file operations */ +typedef struct _meshtastic_FileTransfer { + meshtastic_FileOperation operation; /* File operation (GET, POST, PUT, DELETE) */ + char filepath[255]; /* Path of the file on the SD card */ + meshtastic_FileTransfer_filedata_t filedata; /* Chunk content (POST/PUT request, GET response) */ + bool success; /* Response: Was the operation successful? */ + char message[255]; /* Response: Status message */ + uint32_t offset; /* Byte offset of this chunk within the file (ranged GET/PUT) */ + uint32_t length; /* GET request: number of bytes to read (0 = max chunk size) */ + uint32_t file_size; /* GET response: total size of the file */ +} meshtastic_FileTransfer; + +/* Message for structured directory listing */ +typedef struct _meshtastic_DirectoryListing { + char directory[255]; /* Path of the directory */ + pb_size_t filenames_count; + char filenames[32][41]; /* List of file names */ + bool success; /* Response: Was the operation successful? */ + char message[255]; /* Response: Status message */ + uint32_t offset; /* Request: skip this many entries (paging) */ + uint32_t total_count; /* Response: total number of entries in the directory */ +} meshtastic_DirectoryListing; + +typedef PB_BYTES_ARRAY_T(64) meshtastic_I2CTransaction_write_data_t; +/* A single I2C transaction: an optional write followed by an optional + read with repeated start, matching the TwoWire usage of sensor drivers + (beginTransmission/write.../endTransmission(false)/requestFrom) */ +typedef struct _meshtastic_I2CTransaction { + uint32_t address; /* 7-bit device address */ + meshtastic_I2CTransaction_write_data_t write_data; /* Bytes to write, may be empty */ + uint32_t read_len; /* Number of bytes to read after the write, 0 = write-only */ +} meshtastic_I2CTransaction; + +/* SD card statistics */ +typedef struct _meshtastic_SdCardInfo { + bool present; /* Card initialized and usable */ + meshtastic_SdCardInfo_CardType card_type; + meshtastic_SdCardInfo_FatType fat_type; + uint64_t card_size; /* Filesystem size in bytes */ + uint64_t used_bytes; /* Used bytes (may be expensive to compute on FAT32) */ + uint64_t free_bytes; /* Free bytes */ +} meshtastic_SdCardInfo; +typedef PB_BYTES_ARRAY_T(64) meshtastic_I2CResult_read_data_t; +/* Result of an I2CTransaction */ +typedef struct _meshtastic_I2CResult { + meshtastic_I2CResult_Status status; + meshtastic_I2CResult_read_data_t read_data; /* Data read from the device, empty for write-only transactions */ +} meshtastic_I2CResult; + +typedef PB_BYTES_ARRAY_T(128) meshtastic_InterdeviceMessage_i2c_scan_result_t; +/* Main message for interdevice communication */ typedef struct _meshtastic_InterdeviceMessage { - pb_size_t which_data; - union { - char nmea[1024]; - meshtastic_SensorData sensor; - } data; + pb_size_t which_data; + union { + char nmea[1024]; + uint16_t beep; + meshtastic_I2CTransaction i2c_transaction; + meshtastic_I2CResult i2c_result; + bool i2c_scan; /* Request: scan the secondary I2C bus */ + meshtastic_InterdeviceMessage_i2c_scan_result_t i2c_scan_result; /* Response: 7-bit addresses of discovered devices */ + meshtastic_FileTransfer file_transfer; + meshtastic_DirectoryListing directory_listing; + bool get_sd_info; /* Request: SD card statistics */ + meshtastic_SdCardInfo sd_info; /* Response */ + } data; } meshtastic_InterdeviceMessage; + #ifdef __cplusplus extern "C" { #endif /* Helper constants for enums */ -#define _meshtastic_MessageType_MIN meshtastic_MessageType_ACK -#define _meshtastic_MessageType_MAX meshtastic_MessageType_TVOC_INDEX -#define _meshtastic_MessageType_ARRAYSIZE \ - ((meshtastic_MessageType)(meshtastic_MessageType_TVOC_INDEX + 1)) +#define _meshtastic_FileOperation_MIN meshtastic_FileOperation_GET +#define _meshtastic_FileOperation_MAX meshtastic_FileOperation_DELETE +#define _meshtastic_FileOperation_ARRAYSIZE ((meshtastic_FileOperation)(meshtastic_FileOperation_DELETE+1)) + +#define _meshtastic_SdCardInfo_CardType_MIN meshtastic_SdCardInfo_CardType_NONE +#define _meshtastic_SdCardInfo_CardType_MAX meshtastic_SdCardInfo_CardType_UNKNOWN_CARD +#define _meshtastic_SdCardInfo_CardType_ARRAYSIZE ((meshtastic_SdCardInfo_CardType)(meshtastic_SdCardInfo_CardType_UNKNOWN_CARD+1)) + +#define _meshtastic_SdCardInfo_FatType_MIN meshtastic_SdCardInfo_FatType_UNKNOWN_FAT +#define _meshtastic_SdCardInfo_FatType_MAX meshtastic_SdCardInfo_FatType_EXFAT +#define _meshtastic_SdCardInfo_FatType_ARRAYSIZE ((meshtastic_SdCardInfo_FatType)(meshtastic_SdCardInfo_FatType_EXFAT+1)) + +#define _meshtastic_I2CResult_Status_MIN meshtastic_I2CResult_Status_OK +#define _meshtastic_I2CResult_Status_MAX meshtastic_I2CResult_Status_ERROR +#define _meshtastic_I2CResult_Status_ARRAYSIZE ((meshtastic_I2CResult_Status)(meshtastic_I2CResult_Status_ERROR+1)) + +#define meshtastic_FileTransfer_operation_ENUMTYPE meshtastic_FileOperation + + + +#define meshtastic_SdCardInfo_card_type_ENUMTYPE meshtastic_SdCardInfo_CardType +#define meshtastic_SdCardInfo_fat_type_ENUMTYPE meshtastic_SdCardInfo_FatType + +#define meshtastic_I2CResult_status_ENUMTYPE meshtastic_I2CResult_Status + -#define meshtastic_SensorData_type_ENUMTYPE meshtastic_MessageType /* Initializer values for message structs */ -#define meshtastic_SensorData_init_default \ - { \ - _meshtastic_MessageType_MIN, 0, { 0 } \ - } -#define meshtastic_InterdeviceMessage_init_default \ - { \ - 0, { "" } \ - } -#define meshtastic_SensorData_init_zero \ - { \ - _meshtastic_MessageType_MIN, 0, { 0 } \ - } -#define meshtastic_InterdeviceMessage_init_zero \ - { \ - 0, { "" } \ - } +#define meshtastic_FileTransfer_init_default {_meshtastic_FileOperation_MIN, "", {0, {0}}, 0, "", 0, 0, 0} +#define meshtastic_DirectoryListing_init_default {"", 0, {"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", ""}, 0, "", 0, 0} +#define meshtastic_I2CTransaction_init_default {0, {0, {0}}, 0} +#define meshtastic_SdCardInfo_init_default {0, _meshtastic_SdCardInfo_CardType_MIN, _meshtastic_SdCardInfo_FatType_MIN, 0, 0, 0} +#define meshtastic_I2CResult_init_default {_meshtastic_I2CResult_Status_MIN, {0, {0}}} +#define meshtastic_InterdeviceMessage_init_default {0, {""}} +#define meshtastic_FileTransfer_init_zero {_meshtastic_FileOperation_MIN, "", {0, {0}}, 0, "", 0, 0, 0} +#define meshtastic_DirectoryListing_init_zero {"", 0, {"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", ""}, 0, "", 0, 0} +#define meshtastic_I2CTransaction_init_zero {0, {0, {0}}, 0} +#define meshtastic_SdCardInfo_init_zero {0, _meshtastic_SdCardInfo_CardType_MIN, _meshtastic_SdCardInfo_FatType_MIN, 0, 0, 0} +#define meshtastic_I2CResult_init_zero {_meshtastic_I2CResult_Status_MIN, {0, {0}}} +#define meshtastic_InterdeviceMessage_init_zero {0, {""}} /* Field tags (for use in manual encoding/decoding) */ -#define meshtastic_SensorData_type_tag 1 -#define meshtastic_SensorData_float_value_tag 2 -#define meshtastic_SensorData_uint32_value_tag 3 -#define meshtastic_InterdeviceMessage_nmea_tag 1 -#define meshtastic_InterdeviceMessage_sensor_tag 2 +#define meshtastic_FileTransfer_operation_tag 1 +#define meshtastic_FileTransfer_filepath_tag 2 +#define meshtastic_FileTransfer_filedata_tag 3 +#define meshtastic_FileTransfer_success_tag 4 +#define meshtastic_FileTransfer_message_tag 5 +#define meshtastic_FileTransfer_offset_tag 6 +#define meshtastic_FileTransfer_length_tag 7 +#define meshtastic_FileTransfer_file_size_tag 8 +#define meshtastic_DirectoryListing_directory_tag 1 +#define meshtastic_DirectoryListing_filenames_tag 2 +#define meshtastic_DirectoryListing_success_tag 3 +#define meshtastic_DirectoryListing_message_tag 4 +#define meshtastic_DirectoryListing_offset_tag 5 +#define meshtastic_DirectoryListing_total_count_tag 6 +#define meshtastic_I2CTransaction_address_tag 1 +#define meshtastic_I2CTransaction_write_data_tag 2 +#define meshtastic_I2CTransaction_read_len_tag 3 +#define meshtastic_SdCardInfo_present_tag 1 +#define meshtastic_SdCardInfo_card_type_tag 2 +#define meshtastic_SdCardInfo_fat_type_tag 3 +#define meshtastic_SdCardInfo_card_size_tag 4 +#define meshtastic_SdCardInfo_used_bytes_tag 5 +#define meshtastic_SdCardInfo_free_bytes_tag 6 +#define meshtastic_I2CResult_status_tag 1 +#define meshtastic_I2CResult_read_data_tag 2 +#define meshtastic_InterdeviceMessage_nmea_tag 1 +#define meshtastic_InterdeviceMessage_beep_tag 2 +#define meshtastic_InterdeviceMessage_i2c_transaction_tag 3 +#define meshtastic_InterdeviceMessage_i2c_result_tag 4 +#define meshtastic_InterdeviceMessage_i2c_scan_tag 5 +#define meshtastic_InterdeviceMessage_i2c_scan_result_tag 6 +#define meshtastic_InterdeviceMessage_file_transfer_tag 7 +#define meshtastic_InterdeviceMessage_directory_listing_tag 8 +#define meshtastic_InterdeviceMessage_get_sd_info_tag 9 +#define meshtastic_InterdeviceMessage_sd_info_tag 10 /* Struct field encoding specification for nanopb */ -#define meshtastic_SensorData_FIELDLIST(X, a) \ - X(a, STATIC, SINGULAR, UENUM, type, 1) \ - X(a, STATIC, ONEOF, FLOAT, (data, float_value, data.float_value), 2) \ - X(a, STATIC, ONEOF, UINT32, (data, uint32_value, data.uint32_value), 3) -#define meshtastic_SensorData_CALLBACK NULL -#define meshtastic_SensorData_DEFAULT NULL - -#define meshtastic_InterdeviceMessage_FIELDLIST(X, a) \ - X(a, STATIC, ONEOF, STRING, (data, nmea, data.nmea), 1) \ - X(a, STATIC, ONEOF, MESSAGE, (data, sensor, data.sensor), 2) +#define meshtastic_FileTransfer_FIELDLIST(X, a) \ +X(a, STATIC, SINGULAR, UENUM, operation, 1) \ +X(a, STATIC, SINGULAR, STRING, filepath, 2) \ +X(a, STATIC, SINGULAR, BYTES, filedata, 3) \ +X(a, STATIC, SINGULAR, BOOL, success, 4) \ +X(a, STATIC, SINGULAR, STRING, message, 5) \ +X(a, STATIC, SINGULAR, UINT32, offset, 6) \ +X(a, STATIC, SINGULAR, UINT32, length, 7) \ +X(a, STATIC, SINGULAR, UINT32, file_size, 8) +#define meshtastic_FileTransfer_CALLBACK NULL +#define meshtastic_FileTransfer_DEFAULT NULL + +#define meshtastic_DirectoryListing_FIELDLIST(X, a) \ +X(a, STATIC, SINGULAR, STRING, directory, 1) \ +X(a, STATIC, REPEATED, STRING, filenames, 2) \ +X(a, STATIC, SINGULAR, BOOL, success, 3) \ +X(a, STATIC, SINGULAR, STRING, message, 4) \ +X(a, STATIC, SINGULAR, UINT32, offset, 5) \ +X(a, STATIC, SINGULAR, UINT32, total_count, 6) +#define meshtastic_DirectoryListing_CALLBACK NULL +#define meshtastic_DirectoryListing_DEFAULT NULL + +#define meshtastic_I2CTransaction_FIELDLIST(X, a) \ +X(a, STATIC, SINGULAR, UINT32, address, 1) \ +X(a, STATIC, SINGULAR, BYTES, write_data, 2) \ +X(a, STATIC, SINGULAR, UINT32, read_len, 3) +#define meshtastic_I2CTransaction_CALLBACK NULL +#define meshtastic_I2CTransaction_DEFAULT NULL + +#define meshtastic_SdCardInfo_FIELDLIST(X, a) \ +X(a, STATIC, SINGULAR, BOOL, present, 1) \ +X(a, STATIC, SINGULAR, UENUM, card_type, 2) \ +X(a, STATIC, SINGULAR, UENUM, fat_type, 3) \ +X(a, STATIC, SINGULAR, UINT64, card_size, 4) \ +X(a, STATIC, SINGULAR, UINT64, used_bytes, 5) \ +X(a, STATIC, SINGULAR, UINT64, free_bytes, 6) +#define meshtastic_SdCardInfo_CALLBACK NULL +#define meshtastic_SdCardInfo_DEFAULT NULL + +#define meshtastic_I2CResult_FIELDLIST(X, a) \ +X(a, STATIC, SINGULAR, UENUM, status, 1) \ +X(a, STATIC, SINGULAR, BYTES, read_data, 2) +#define meshtastic_I2CResult_CALLBACK NULL +#define meshtastic_I2CResult_DEFAULT NULL + +#define meshtastic_InterdeviceMessage_FIELDLIST(X, a) \ +X(a, STATIC, ONEOF, STRING, (data,nmea,data.nmea), 1) \ +X(a, STATIC, ONEOF, UINT32, (data,beep,data.beep), 2) \ +X(a, STATIC, ONEOF, MESSAGE, (data,i2c_transaction,data.i2c_transaction), 3) \ +X(a, STATIC, ONEOF, MESSAGE, (data,i2c_result,data.i2c_result), 4) \ +X(a, STATIC, ONEOF, BOOL, (data,i2c_scan,data.i2c_scan), 5) \ +X(a, STATIC, ONEOF, BYTES, (data,i2c_scan_result,data.i2c_scan_result), 6) \ +X(a, STATIC, ONEOF, MESSAGE, (data,file_transfer,data.file_transfer), 7) \ +X(a, STATIC, ONEOF, MESSAGE, (data,directory_listing,data.directory_listing), 8) \ +X(a, STATIC, ONEOF, BOOL, (data,get_sd_info,data.get_sd_info), 9) \ +X(a, STATIC, ONEOF, MESSAGE, (data,sd_info,data.sd_info), 10) #define meshtastic_InterdeviceMessage_CALLBACK NULL #define meshtastic_InterdeviceMessage_DEFAULT NULL -#define meshtastic_InterdeviceMessage_data_sensor_MSGTYPE meshtastic_SensorData +#define meshtastic_InterdeviceMessage_data_i2c_transaction_MSGTYPE meshtastic_I2CTransaction +#define meshtastic_InterdeviceMessage_data_i2c_result_MSGTYPE meshtastic_I2CResult +#define meshtastic_InterdeviceMessage_data_file_transfer_MSGTYPE meshtastic_FileTransfer +#define meshtastic_InterdeviceMessage_data_directory_listing_MSGTYPE meshtastic_DirectoryListing +#define meshtastic_InterdeviceMessage_data_sd_info_MSGTYPE meshtastic_SdCardInfo -extern const pb_msgdesc_t meshtastic_SensorData_msg; +extern const pb_msgdesc_t meshtastic_FileTransfer_msg; +extern const pb_msgdesc_t meshtastic_DirectoryListing_msg; +extern const pb_msgdesc_t meshtastic_I2CTransaction_msg; +extern const pb_msgdesc_t meshtastic_SdCardInfo_msg; +extern const pb_msgdesc_t meshtastic_I2CResult_msg; extern const pb_msgdesc_t meshtastic_InterdeviceMessage_msg; /* Defines for backwards compatibility with code written before nanopb-0.4.0 */ -#define meshtastic_SensorData_fields &meshtastic_SensorData_msg +#define meshtastic_FileTransfer_fields &meshtastic_FileTransfer_msg +#define meshtastic_DirectoryListing_fields &meshtastic_DirectoryListing_msg +#define meshtastic_I2CTransaction_fields &meshtastic_I2CTransaction_msg +#define meshtastic_SdCardInfo_fields &meshtastic_SdCardInfo_msg +#define meshtastic_I2CResult_fields &meshtastic_I2CResult_msg #define meshtastic_InterdeviceMessage_fields &meshtastic_InterdeviceMessage_msg /* Maximum encoded size of messages (where known) */ -#define MESHTASTIC_MESHTASTIC_INTERDEVICE_PB_H_MAX_SIZE \ - meshtastic_InterdeviceMessage_size -#define meshtastic_InterdeviceMessage_size 1026 -#define meshtastic_SensorData_size 9 +#define MESHTASTIC_MESHTASTIC_INTERDEVICE_PB_H_MAX_SIZE meshtastic_InterdeviceMessage_size +#define meshtastic_DirectoryListing_size 1872 +#define meshtastic_FileTransfer_size 4635 +#define meshtastic_I2CResult_size 68 +#define meshtastic_I2CTransaction_size 78 +#define meshtastic_InterdeviceMessage_size 4638 +#define meshtastic_SdCardInfo_size 39 #ifdef __cplusplus } /* extern "C" */ diff --git a/src/serial_proto.cpp b/src/serial_proto.cpp index 55d6377..7bccbc0 100644 --- a/src/serial_proto.cpp +++ b/src/serial_proto.cpp @@ -1,4 +1,20 @@ #include "serial_proto.h" +#include "buzzer.h" +#include +#include + +extern bool sd_ensure(void); + +// One-entry read cache: map tiles are fetched in sequential 4K chunks, so +// keeping the file open between chunks avoids a FAT open + seek per chunk +static File read_cache; +static char read_cache_path[256] = ""; + +static void invalidate_read_cache() { + if (read_cache) + read_cache.close(); + read_cache_path[0] = '\0'; +} // The buffer used for protobuf encoding/decoding. Since there's only one, and // it's global, we have to make sure we're only ever doing one encoding or @@ -11,7 +27,12 @@ size_t pb_rx_size = 0; // Number of bytes currently in the buffer pb_byte_t pb_tx_buf[PB_BUFSIZE]; -void (*sensor_callback)(meshtastic_SensorData sensor) = NULL; +// Statically allocated message structs: with 4KB file chunks an +// InterdeviceMessage is ~4.6KB, far too large for the 8KB core stack. +// The loop is single threaded, so one of each suffices. +static meshtastic_InterdeviceMessage rx_message; +static meshtastic_InterdeviceMessage tx_response; + void (*nmea_callback)(char *nmea) = NULL; bool mt_send(const char *buf, size_t len) { @@ -24,8 +45,8 @@ bool mt_send(const char *buf, size_t len) { // Parse a packet that came in, and handle it. Return true if we were able to // parse it. bool mt_handle_packet(size_t payload_len) { - meshtastic_InterdeviceMessage message = - meshtastic_InterdeviceMessage_init_zero; + meshtastic_InterdeviceMessage &message = rx_message; + memset(&message, 0, sizeof(message)); // Decode the protobuf and shift forward any remaining bytes in the buffer // (which, if present, belong to the packet that we're going to process on the @@ -44,16 +65,257 @@ bool mt_handle_packet(size_t payload_len) { } switch (message.which_data) { - case meshtastic_InterdeviceMessage_sensor_tag: - if (sensor_callback != NULL) - sensor_callback(message.data.sensor); - return true; - break; case meshtastic_InterdeviceMessage_nmea_tag: if (nmea_callback != NULL) nmea_callback(message.data.nmea); return true; - break; + + case meshtastic_InterdeviceMessage_beep_tag: + // Handle the beep command + if (message.data.beep > 0) { + beep_on(message.data.beep); + } else { + beep_off(); + } + return true; + case meshtastic_InterdeviceMessage_i2c_transaction_tag: { + // Execute a tunneled I2C transaction: an optional write followed by an + // optional read with repeated start + meshtastic_I2CTransaction transaction = message.data.i2c_transaction; + meshtastic_InterdeviceMessage &response = tx_response; + memset(&response, 0, sizeof(response)); + response.which_data = meshtastic_InterdeviceMessage_i2c_result_tag; + meshtastic_I2CResult *result = &response.data.i2c_result; + result->status = meshtastic_I2CResult_Status_OK; + + if (transaction.write_data.size > 0 || transaction.read_len == 0) { + Wire.beginTransmission((uint8_t)transaction.address); + Wire.write(transaction.write_data.bytes, transaction.write_data.size); + // keep the bus claimed (repeated start) when a read follows + switch (Wire.endTransmission(transaction.read_len == 0)) { + case 0: + break; + case 2: + result->status = meshtastic_I2CResult_Status_NACK_ADDRESS; + break; + case 3: + result->status = meshtastic_I2CResult_Status_NACK_DATA; + break; + default: + result->status = meshtastic_I2CResult_Status_ERROR; + } + } + + if (result->status == meshtastic_I2CResult_Status_OK && + transaction.read_len > 0) { + size_t want = transaction.read_len; + if (want > sizeof(result->read_data.bytes)) + want = sizeof(result->read_data.bytes); + size_t got = Wire.requestFrom((uint8_t)transaction.address, want); + if (got == 0) + result->status = meshtastic_I2CResult_Status_NACK_ADDRESS; + while (Wire.available() && result->read_data.size < got) + result->read_data.bytes[result->read_data.size++] = Wire.read(); + } + return mt_send_uplink(response); + } + + case meshtastic_InterdeviceMessage_file_transfer_tag: { + // SD card file operations. GET is a ranged chunk read. Writes are + // sequential: POST starts a new file (offset 0), PUT appends a chunk + // at the current end of the file. + meshtastic_FileTransfer &request = message.data.file_transfer; + meshtastic_InterdeviceMessage &response = tx_response; + memset(&response, 0, sizeof(response)); + response.which_data = meshtastic_InterdeviceMessage_file_transfer_tag; + meshtastic_FileTransfer *out = &response.data.file_transfer; + out->operation = request.operation; + strncpy(out->filepath, request.filepath, sizeof(out->filepath) - 1); + out->offset = request.offset; + out->success = false; + + if (!sd_ensure()) { + strncpy(out->message, "no SD card", sizeof(out->message) - 1); + return mt_send_uplink(response); + } + + switch (request.operation) { + case meshtastic_FileOperation_GET: { + if (!read_cache || strcmp(read_cache_path, request.filepath) != 0) { + invalidate_read_cache(); + read_cache = SD.open(request.filepath, FILE_READ); + if (!read_cache) { + strncpy(out->message, "open failed", sizeof(out->message) - 1); + break; + } + strncpy(read_cache_path, request.filepath, + sizeof(read_cache_path) - 1); + } + out->file_size = read_cache.size(); + size_t want = request.length; + if (want == 0 || want > sizeof(out->filedata.bytes)) + want = sizeof(out->filedata.bytes); + if (!read_cache.seek(request.offset)) { + strncpy(out->message, "seek failed", sizeof(out->message) - 1); + break; + } + int got = read_cache.read(out->filedata.bytes, want); + if (got < 0) { + strncpy(out->message, "read failed", sizeof(out->message) - 1); + break; + } + out->filedata.size = got; + out->success = true; + break; + } + + case meshtastic_FileOperation_POST: + case meshtastic_FileOperation_PUT: { + invalidate_read_cache(); + if (request.operation == meshtastic_FileOperation_POST && + SD.exists(request.filepath)) + SD.remove(request.filepath); + File f = SD.open(request.filepath, FILE_WRITE); // append mode, creates + if (!f) { + strncpy(out->message, "open failed", sizeof(out->message) - 1); + break; + } + if (f.size() != request.offset) { + snprintf(out->message, sizeof(out->message), + "bad offset %u, file size %u", (unsigned)request.offset, + (unsigned)f.size()); + f.close(); + break; + } + size_t wrote = f.write(request.filedata.bytes, request.filedata.size); + out->file_size = f.size(); + f.close(); + out->success = (wrote == request.filedata.size); + if (!out->success) + strncpy(out->message, "write failed", sizeof(out->message) - 1); + break; + } + + case meshtastic_FileOperation_DELETE: + invalidate_read_cache(); + out->success = SD.remove(request.filepath); + if (!out->success) + strncpy(out->message, "remove failed", sizeof(out->message) - 1); + break; + + default: + strncpy(out->message, "unknown operation", sizeof(out->message) - 1); + } + return mt_send_uplink(response); + } + + case meshtastic_InterdeviceMessage_directory_listing_tag: { + // Paged directory listing, subdirectories get a trailing slash + meshtastic_DirectoryListing &request = message.data.directory_listing; + meshtastic_InterdeviceMessage &response = tx_response; + memset(&response, 0, sizeof(response)); + response.which_data = meshtastic_InterdeviceMessage_directory_listing_tag; + meshtastic_DirectoryListing *out = &response.data.directory_listing; + strncpy(out->directory, request.directory, sizeof(out->directory) - 1); + out->offset = request.offset; + out->success = false; + + if (!sd_ensure()) { + strncpy(out->message, "no SD card", sizeof(out->message) - 1); + return mt_send_uplink(response); + } + + File dir = SD.open(request.directory); + if (!dir || !dir.isDirectory()) { + strncpy(out->message, "not a directory", sizeof(out->message) - 1); + } else { + const size_t max_names = + sizeof(out->filenames) / sizeof(out->filenames[0]); + uint32_t index = 0; + while (true) { + File entry = dir.openNextFile(); + if (!entry) + break; + if (index >= request.offset && out->filenames_count < max_names) { + snprintf(out->filenames[out->filenames_count], + sizeof(out->filenames[0]), "%s%s", entry.name(), + entry.isDirectory() ? "/" : ""); + out->filenames_count++; + } + index++; + entry.close(); + } + out->total_count = index; + out->success = true; + } + if (dir) + dir.close(); + return mt_send_uplink(response); + } + + case meshtastic_InterdeviceMessage_get_sd_info_tag: { + // SD card statistics. The used-bytes calculation scans the FAT on + // FAT16/32 cards, which can take a while on the first call. + meshtastic_InterdeviceMessage &response = tx_response; + memset(&response, 0, sizeof(response)); + response.which_data = meshtastic_InterdeviceMessage_sd_info_tag; + meshtastic_SdCardInfo *info = &response.data.sd_info; + + if (sd_ensure()) { + info->present = true; + info->card_size = SD.size64(); + switch (SD.fatType()) { + case 16: + info->fat_type = meshtastic_SdCardInfo_FatType_FAT16; + break; + case 32: + info->fat_type = meshtastic_SdCardInfo_FatType_FAT32; + break; + case 64: + info->fat_type = meshtastic_SdCardInfo_FatType_EXFAT; + break; + default: + info->fat_type = meshtastic_SdCardInfo_FatType_UNKNOWN_FAT; + } + switch (SD.type()) { + case 0: // SD1 + case 1: // SD2 + info->card_type = meshtastic_SdCardInfo_CardType_SD; + break; + case 3: // SDHC or SDXC, differ by capacity + info->card_type = info->card_size > 32ull * 1000 * 1000 * 1000 + ? meshtastic_SdCardInfo_CardType_SDXC + : meshtastic_SdCardInfo_CardType_SDHC; + break; + default: + info->card_type = meshtastic_SdCardInfo_CardType_UNKNOWN_CARD; + } + FSInfo fs_info; + if (SDFS.info(fs_info)) { + info->used_bytes = fs_info.usedBytes; + info->free_bytes = fs_info.totalBytes - fs_info.usedBytes; + } + } + return mt_send_uplink(response); + } + + case meshtastic_InterdeviceMessage_i2c_scan_tag: { + // Scan the local bus and report all responding addresses + meshtastic_InterdeviceMessage &response = tx_response; + memset(&response, 0, sizeof(response)); + response.which_data = meshtastic_InterdeviceMessage_i2c_scan_result_tag; + for (uint8_t addr = 8; addr < 120; addr++) { + if (response.data.i2c_scan_result.size >= + sizeof(response.data.i2c_scan_result.bytes)) + break; + Wire.beginTransmission(addr); + if (Wire.endTransmission() == 0) + response.data.i2c_scan_result + .bytes[response.data.i2c_scan_result.size++] = addr; + } + return mt_send_uplink(response); + } + default: // the other messages really only flow downstream Serial.println("Got a message of unexpected type"); @@ -64,7 +326,6 @@ bool mt_handle_packet(size_t payload_len) { void mt_check_packet() { if (pb_rx_size < MT_HEADER_SIZE) { // We don't even have a header yet - delay(NO_NEWS_PAUSE); return; } @@ -82,7 +343,7 @@ void mt_check_packet() { } if ((size_t)(payload_len + MT_HEADER_SIZE) > pb_rx_size) { - delay(NO_NEWS_PAUSE); + // Packet not complete yet return; } @@ -103,7 +364,7 @@ size_t mt_serial_check(char *buf, size_t space_left) { return bytes_read; } -bool mt_send_uplink(meshtastic_InterdeviceMessage message) { +bool mt_send_uplink(const meshtastic_InterdeviceMessage &message) { pb_tx_buf[0] = MT_MAGIC_0; pb_tx_buf[1] = MT_MAGIC_1; @@ -136,10 +397,6 @@ void mt_loop() { mt_check_packet(); } -void mt_set_sensor_callback(void (*callback)(meshtastic_SensorData sensor)) { - sensor_callback = callback; -} - void mt_set_nmea_callback(void (*callback)(char *nmea)) { nmea_callback = callback; } \ No newline at end of file diff --git a/src/serial_proto.h b/src/serial_proto.h index aa942b2..075f34e 100644 --- a/src/serial_proto.h +++ b/src/serial_proto.h @@ -18,15 +18,8 @@ // Wait this many msec if there's nothing new on the channel #define NO_NEWS_PAUSE 25 -bool mt_send_uplink(meshtastic_InterdeviceMessage message); - -// Set the callback function that gets called when the node receives a sensor -// control message. -void mt_set_sensor_callback(void (*callback)(meshtastic_SensorData sensor)); - -// Set callback function that gets called when nmea message is received +bool mt_send_uplink(const meshtastic_InterdeviceMessage &message); void mt_set_nmea_callback(void (*callback)(char *nmea)); - void mt_loop(); #endif \ No newline at end of file From 0bf7988f52704fc20b7093a60173d498ff038237 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thomas=20G=C3=B6ttgens?= Date: Sun, 12 Jul 2026 17:24:32 +0200 Subject: [PATCH 02/21] Interdevice payload bounds: 256B I2C, full-length filenames, 64 bit file offsets --- protobufs | 2 +- src/meshtastic/interdevice.pb.cpp | 6 ++--- src/meshtastic/interdevice.pb.h | 41 +++++++++++++++++++------------ src/serial_proto.cpp | 7 +++++- 4 files changed, 35 insertions(+), 21 deletions(-) diff --git a/protobufs b/protobufs index 641fc9f..486a98e 160000 --- a/protobufs +++ b/protobufs @@ -1 +1 @@ -Subproject commit 641fc9faa2640298d1a4def8fb38c91d241b0718 +Subproject commit 486a98e1d711a2b56ed855abb45c972fd44a5c79 diff --git a/src/meshtastic/interdevice.pb.cpp b/src/meshtastic/interdevice.pb.cpp index 5c16746..eb8f528 100644 --- a/src/meshtastic/interdevice.pb.cpp +++ b/src/meshtastic/interdevice.pb.cpp @@ -9,16 +9,16 @@ PB_BIND(meshtastic_FileTransfer, meshtastic_FileTransfer, 4) -PB_BIND(meshtastic_DirectoryListing, meshtastic_DirectoryListing, 2) +PB_BIND(meshtastic_DirectoryListing, meshtastic_DirectoryListing, 4) -PB_BIND(meshtastic_I2CTransaction, meshtastic_I2CTransaction, AUTO) +PB_BIND(meshtastic_I2CTransaction, meshtastic_I2CTransaction, 2) PB_BIND(meshtastic_SdCardInfo, meshtastic_SdCardInfo, AUTO) -PB_BIND(meshtastic_I2CResult, meshtastic_I2CResult, AUTO) +PB_BIND(meshtastic_I2CResult, meshtastic_I2CResult, 2) PB_BIND(meshtastic_InterdeviceMessage, meshtastic_InterdeviceMessage, 4) diff --git a/src/meshtastic/interdevice.pb.h b/src/meshtastic/interdevice.pb.h index 91973ed..1ee11e8 100644 --- a/src/meshtastic/interdevice.pb.h +++ b/src/meshtastic/interdevice.pb.h @@ -50,30 +50,39 @@ typedef struct _meshtastic_FileTransfer { meshtastic_FileTransfer_filedata_t filedata; /* Chunk content (POST/PUT request, GET response) */ bool success; /* Response: Was the operation successful? */ char message[255]; /* Response: Status message */ - uint32_t offset; /* Byte offset of this chunk within the file (ranged GET/PUT) */ - uint32_t length; /* GET request: number of bytes to read (0 = max chunk size) */ - uint32_t file_size; /* GET response: total size of the file */ + uint64_t offset; /* Byte offset of this chunk within the file (ranged GET/PUT) */ + /* GET request: number of bytes to read, 0 = max chunk size. A response + carries at most the filedata max_size (see interdevice.options) per + chunk; larger requests are truncated, visible in the filedata length. */ + uint32_t length; + uint64_t file_size; /* GET response: total size of the file */ } meshtastic_FileTransfer; /* Message for structured directory listing */ typedef struct _meshtastic_DirectoryListing { char directory[255]; /* Path of the directory */ + /* One page of entry names, full FAT LFN length so they round-trip into + FileTransfer.filepath. Page size is the max_count in + interdevice.options; page through with offset and total_count. */ pb_size_t filenames_count; - char filenames[32][41]; /* List of file names */ + char filenames[16][256]; bool success; /* Response: Was the operation successful? */ char message[255]; /* Response: Status message */ uint32_t offset; /* Request: skip this many entries (paging) */ uint32_t total_count; /* Response: total number of entries in the directory */ } meshtastic_DirectoryListing; -typedef PB_BYTES_ARRAY_T(64) meshtastic_I2CTransaction_write_data_t; +typedef PB_BYTES_ARRAY_T(256) meshtastic_I2CTransaction_write_data_t; /* A single I2C transaction: an optional write followed by an optional read with repeated start, matching the TwoWire usage of sensor drivers (beginTransmission/write.../endTransmission(false)/requestFrom) */ typedef struct _meshtastic_I2CTransaction { uint32_t address; /* 7-bit device address */ meshtastic_I2CTransaction_write_data_t write_data; /* Bytes to write, may be empty */ - uint32_t read_len; /* Number of bytes to read after the write, 0 = write-only */ + /* Number of bytes to read after the write, 0 = write-only. Bounded by + the read_data max_size of I2CResult (see interdevice.options); larger + requests are truncated, visible in the returned byte count. */ + uint32_t read_len; } meshtastic_I2CTransaction; /* SD card statistics */ @@ -86,7 +95,7 @@ typedef struct _meshtastic_SdCardInfo { uint64_t free_bytes; /* Free bytes */ } meshtastic_SdCardInfo; -typedef PB_BYTES_ARRAY_T(64) meshtastic_I2CResult_read_data_t; +typedef PB_BYTES_ARRAY_T(256) meshtastic_I2CResult_read_data_t; /* Result of an I2CTransaction */ typedef struct _meshtastic_I2CResult { meshtastic_I2CResult_Status status; @@ -146,13 +155,13 @@ extern "C" { /* Initializer values for message structs */ #define meshtastic_FileTransfer_init_default {_meshtastic_FileOperation_MIN, "", {0, {0}}, 0, "", 0, 0, 0} -#define meshtastic_DirectoryListing_init_default {"", 0, {"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", ""}, 0, "", 0, 0} +#define meshtastic_DirectoryListing_init_default {"", 0, {"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", ""}, 0, "", 0, 0} #define meshtastic_I2CTransaction_init_default {0, {0, {0}}, 0} #define meshtastic_SdCardInfo_init_default {0, _meshtastic_SdCardInfo_CardType_MIN, _meshtastic_SdCardInfo_FatType_MIN, 0, 0, 0} #define meshtastic_I2CResult_init_default {_meshtastic_I2CResult_Status_MIN, {0, {0}}} #define meshtastic_InterdeviceMessage_init_default {0, {""}} #define meshtastic_FileTransfer_init_zero {_meshtastic_FileOperation_MIN, "", {0, {0}}, 0, "", 0, 0, 0} -#define meshtastic_DirectoryListing_init_zero {"", 0, {"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", ""}, 0, "", 0, 0} +#define meshtastic_DirectoryListing_init_zero {"", 0, {"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", ""}, 0, "", 0, 0} #define meshtastic_I2CTransaction_init_zero {0, {0, {0}}, 0} #define meshtastic_SdCardInfo_init_zero {0, _meshtastic_SdCardInfo_CardType_MIN, _meshtastic_SdCardInfo_FatType_MIN, 0, 0, 0} #define meshtastic_I2CResult_init_zero {_meshtastic_I2CResult_Status_MIN, {0, {0}}} @@ -202,9 +211,9 @@ X(a, STATIC, SINGULAR, STRING, filepath, 2) \ X(a, STATIC, SINGULAR, BYTES, filedata, 3) \ X(a, STATIC, SINGULAR, BOOL, success, 4) \ X(a, STATIC, SINGULAR, STRING, message, 5) \ -X(a, STATIC, SINGULAR, UINT32, offset, 6) \ +X(a, STATIC, SINGULAR, UINT64, offset, 6) \ X(a, STATIC, SINGULAR, UINT32, length, 7) \ -X(a, STATIC, SINGULAR, UINT32, file_size, 8) +X(a, STATIC, SINGULAR, UINT64, file_size, 8) #define meshtastic_FileTransfer_CALLBACK NULL #define meshtastic_FileTransfer_DEFAULT NULL @@ -277,11 +286,11 @@ extern const pb_msgdesc_t meshtastic_InterdeviceMessage_msg; /* Maximum encoded size of messages (where known) */ #define MESHTASTIC_MESHTASTIC_INTERDEVICE_PB_H_MAX_SIZE meshtastic_InterdeviceMessage_size -#define meshtastic_DirectoryListing_size 1872 -#define meshtastic_FileTransfer_size 4635 -#define meshtastic_I2CResult_size 68 -#define meshtastic_I2CTransaction_size 78 -#define meshtastic_InterdeviceMessage_size 4638 +#define meshtastic_DirectoryListing_size 4656 +#define meshtastic_FileTransfer_size 4645 +#define meshtastic_I2CResult_size 261 +#define meshtastic_I2CTransaction_size 271 +#define meshtastic_InterdeviceMessage_size 4659 #define meshtastic_SdCardInfo_size 39 #ifdef __cplusplus diff --git a/src/serial_proto.cpp b/src/serial_proto.cpp index 7bccbc0..873c1a8 100644 --- a/src/serial_proto.cpp +++ b/src/serial_proto.cpp @@ -155,7 +155,12 @@ bool mt_handle_packet(size_t payload_len) { size_t want = request.length; if (want == 0 || want > sizeof(out->filedata.bytes)) want = sizeof(out->filedata.bytes); - if (!read_cache.seek(request.offset)) { + if (request.offset > 0xFFFFFFFFull) { + // the FS layer seeks with 32 bit positions + strncpy(out->message, "offset too large", sizeof(out->message) - 1); + break; + } + if (!read_cache.seek((uint32_t)request.offset)) { strncpy(out->message, "seek failed", sizeof(out->message) - 1); break; } From b072049616ec8d93437f4e43183e1aad69c5c638 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thomas=20G=C3=B6ttgens?= Date: Sun, 12 Jul 2026 19:46:32 +0200 Subject: [PATCH 03/21] Compute SD statistics in the background The FAT scan behind usedBytes takes seconds on FAT16/32 cards and stalled the UI on the other end of the link. sd_info now answers from a cache filled from loop(). --- src/main.cpp | 33 +++++++++++++++++++++++++++++++++ src/serial_proto.cpp | 10 ++++++---- 2 files changed, 39 insertions(+), 4 deletions(-) diff --git a/src/main.cpp b/src/main.cpp index c1de627..1f0e699 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -30,6 +30,14 @@ int bufferIndex = 0; bool sd_init_flag = 0; const int SD_CS_PIN = 13; +// used/free statistics, computed in the background: the FAT scan behind +// usedBytes takes seconds on FAT16/32 cards and must not stall the +// request path (the UI on the other end waits synchronously) +static bool sd_stats_pending = false; +static bool sd_stats_valid = false; +static uint64_t sd_used_bytes = 0; +static uint64_t sd_free_bytes = 0; + // (Re-)initialize the SD card on demand. The Indicator's slot has no // card-detect line, so retry rate-limited whenever a file operation // arrives; this makes inserting a card after boot just work. @@ -46,6 +54,30 @@ bool sd_ensure(void) { } Serial.println("card initialized."); sd_init_flag = 1; + sd_stats_valid = false; + sd_stats_pending = true; + return true; +} + +// runs from loop(), outside the request path +void sd_update_stats(void) { + if (!sd_stats_pending || !sd_init_flag) + return; + sd_stats_pending = false; + FSInfo info; + if (SDFS.info(info)) { + sd_used_bytes = info.usedBytes; + sd_free_bytes = info.totalBytes - info.usedBytes; + sd_stats_valid = true; + } +} + +// false while the background scan has not completed yet +bool sd_used_free(uint64_t *used, uint64_t *free_bytes) { + if (!sd_stats_valid) + return false; + *used = sd_used_bytes; + *free_bytes = sd_free_bytes; return true; } @@ -107,6 +139,7 @@ void loop() { buzz_off = 0; } + sd_update_stats(); mt_loop(); // read GPS data lines into buffer and send it to the ESP32 diff --git a/src/serial_proto.cpp b/src/serial_proto.cpp index 873c1a8..daa5c69 100644 --- a/src/serial_proto.cpp +++ b/src/serial_proto.cpp @@ -4,6 +4,7 @@ #include extern bool sd_ensure(void); +extern bool sd_used_free(uint64_t *used, uint64_t *free_bytes); // One-entry read cache: map tiles are fetched in sequential 4K chunks, so // keeping the file open between chunks avoids a FAT open + seek per chunk @@ -295,10 +296,11 @@ bool mt_handle_packet(size_t payload_len) { default: info->card_type = meshtastic_SdCardInfo_CardType_UNKNOWN_CARD; } - FSInfo fs_info; - if (SDFS.info(fs_info)) { - info->used_bytes = fs_info.usedBytes; - info->free_bytes = fs_info.totalBytes - fs_info.usedBytes; + // from the background scan; zero while it is still running + uint64_t used, free_bytes; + if (sd_used_free(&used, &free_bytes)) { + info->used_bytes = used; + info->free_bytes = free_bytes; } } return mt_send_uplink(response); From 7d0e10e37f8db8853f23a8e4b928d0ff2eb44476 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thomas=20G=C3=B6ttgens?= Date: Sun, 12 Jul 2026 20:13:30 +0200 Subject: [PATCH 04/21] Harden the link RX path and correlate responses Echo the request id in responses, reject oversized frames with a buffer reset, fix a one byte RX buffer overflow. --- protobufs | 2 +- src/meshtastic/interdevice.pb.h | 13 +++++++++---- src/serial_proto.cpp | 19 +++++++++++-------- 3 files changed, 21 insertions(+), 13 deletions(-) diff --git a/protobufs b/protobufs index 486a98e..ba8b238 160000 --- a/protobufs +++ b/protobufs @@ -1 +1 @@ -Subproject commit 486a98e1d711a2b56ed855abb45c972fd44a5c79 +Subproject commit ba8b2383925c5eb447f8112054277278669861b9 diff --git a/src/meshtastic/interdevice.pb.h b/src/meshtastic/interdevice.pb.h index 1ee11e8..c316c92 100644 --- a/src/meshtastic/interdevice.pb.h +++ b/src/meshtastic/interdevice.pb.h @@ -118,6 +118,9 @@ typedef struct _meshtastic_InterdeviceMessage { bool get_sd_info; /* Request: SD card statistics */ meshtastic_SdCardInfo sd_info; /* Response */ } data; + /* Correlates a response with its request: responses echo the id of the + request they answer. 0 for unsolicited messages (e.g. the nmea stream). */ + uint32_t id; } meshtastic_InterdeviceMessage; @@ -159,13 +162,13 @@ extern "C" { #define meshtastic_I2CTransaction_init_default {0, {0, {0}}, 0} #define meshtastic_SdCardInfo_init_default {0, _meshtastic_SdCardInfo_CardType_MIN, _meshtastic_SdCardInfo_FatType_MIN, 0, 0, 0} #define meshtastic_I2CResult_init_default {_meshtastic_I2CResult_Status_MIN, {0, {0}}} -#define meshtastic_InterdeviceMessage_init_default {0, {""}} +#define meshtastic_InterdeviceMessage_init_default {0, {""}, 0} #define meshtastic_FileTransfer_init_zero {_meshtastic_FileOperation_MIN, "", {0, {0}}, 0, "", 0, 0, 0} #define meshtastic_DirectoryListing_init_zero {"", 0, {"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", ""}, 0, "", 0, 0} #define meshtastic_I2CTransaction_init_zero {0, {0, {0}}, 0} #define meshtastic_SdCardInfo_init_zero {0, _meshtastic_SdCardInfo_CardType_MIN, _meshtastic_SdCardInfo_FatType_MIN, 0, 0, 0} #define meshtastic_I2CResult_init_zero {_meshtastic_I2CResult_Status_MIN, {0, {0}}} -#define meshtastic_InterdeviceMessage_init_zero {0, {""}} +#define meshtastic_InterdeviceMessage_init_zero {0, {""}, 0} /* Field tags (for use in manual encoding/decoding) */ #define meshtastic_FileTransfer_operation_tag 1 @@ -203,6 +206,7 @@ extern "C" { #define meshtastic_InterdeviceMessage_directory_listing_tag 8 #define meshtastic_InterdeviceMessage_get_sd_info_tag 9 #define meshtastic_InterdeviceMessage_sd_info_tag 10 +#define meshtastic_InterdeviceMessage_id_tag 15 /* Struct field encoding specification for nanopb */ #define meshtastic_FileTransfer_FIELDLIST(X, a) \ @@ -260,7 +264,8 @@ X(a, STATIC, ONEOF, BYTES, (data,i2c_scan_result,data.i2c_scan_result), X(a, STATIC, ONEOF, MESSAGE, (data,file_transfer,data.file_transfer), 7) \ X(a, STATIC, ONEOF, MESSAGE, (data,directory_listing,data.directory_listing), 8) \ X(a, STATIC, ONEOF, BOOL, (data,get_sd_info,data.get_sd_info), 9) \ -X(a, STATIC, ONEOF, MESSAGE, (data,sd_info,data.sd_info), 10) +X(a, STATIC, ONEOF, MESSAGE, (data,sd_info,data.sd_info), 10) \ +X(a, STATIC, SINGULAR, UINT32, id, 15) #define meshtastic_InterdeviceMessage_CALLBACK NULL #define meshtastic_InterdeviceMessage_DEFAULT NULL #define meshtastic_InterdeviceMessage_data_i2c_transaction_MSGTYPE meshtastic_I2CTransaction @@ -290,7 +295,7 @@ extern const pb_msgdesc_t meshtastic_InterdeviceMessage_msg; #define meshtastic_FileTransfer_size 4645 #define meshtastic_I2CResult_size 261 #define meshtastic_I2CTransaction_size 271 -#define meshtastic_InterdeviceMessage_size 4659 +#define meshtastic_InterdeviceMessage_size 4665 #define meshtastic_SdCardInfo_size 39 #ifdef __cplusplus diff --git a/src/serial_proto.cpp b/src/serial_proto.cpp index daa5c69..452ed48 100644 --- a/src/serial_proto.cpp +++ b/src/serial_proto.cpp @@ -85,6 +85,7 @@ bool mt_handle_packet(size_t payload_len) { meshtastic_I2CTransaction transaction = message.data.i2c_transaction; meshtastic_InterdeviceMessage &response = tx_response; memset(&response, 0, sizeof(response)); + response.id = message.id; // correlate with the request response.which_data = meshtastic_InterdeviceMessage_i2c_result_tag; meshtastic_I2CResult *result = &response.data.i2c_result; result->status = meshtastic_I2CResult_Status_OK; @@ -128,6 +129,7 @@ bool mt_handle_packet(size_t payload_len) { meshtastic_FileTransfer &request = message.data.file_transfer; meshtastic_InterdeviceMessage &response = tx_response; memset(&response, 0, sizeof(response)); + response.id = message.id; // correlate with the request response.which_data = meshtastic_InterdeviceMessage_file_transfer_tag; meshtastic_FileTransfer *out = &response.data.file_transfer; out->operation = request.operation; @@ -220,6 +222,7 @@ bool mt_handle_packet(size_t payload_len) { meshtastic_DirectoryListing &request = message.data.directory_listing; meshtastic_InterdeviceMessage &response = tx_response; memset(&response, 0, sizeof(response)); + response.id = message.id; // correlate with the request response.which_data = meshtastic_InterdeviceMessage_directory_listing_tag; meshtastic_DirectoryListing *out = &response.data.directory_listing; strncpy(out->directory, request.directory, sizeof(out->directory) - 1); @@ -264,6 +267,7 @@ bool mt_handle_packet(size_t payload_len) { // FAT16/32 cards, which can take a while on the first call. meshtastic_InterdeviceMessage &response = tx_response; memset(&response, 0, sizeof(response)); + response.id = message.id; // correlate with the request response.which_data = meshtastic_InterdeviceMessage_sd_info_tag; meshtastic_SdCardInfo *info = &response.data.sd_info; @@ -310,6 +314,7 @@ bool mt_handle_packet(size_t payload_len) { // Scan the local bus and report all responding addresses meshtastic_InterdeviceMessage &response = tx_response; memset(&response, 0, sizeof(response)); + response.id = message.id; // correlate with the request response.which_data = meshtastic_InterdeviceMessage_i2c_scan_result_tag; for (uint8_t addr = 8; addr < 120; addr++) { if (response.data.i2c_scan_result.size >= @@ -344,8 +349,11 @@ void mt_check_packet() { } uint16_t payload_len = pb_rx_buf[2] << 8 | pb_rx_buf[3]; - if (payload_len > PB_BUFSIZE) { + if ((size_t)payload_len + MT_HEADER_SIZE > PB_BUFSIZE) { + // oversized frame can never complete, resync on the next magic Serial.println("Got packet claiming to be ridiculous length"); + memset(pb_rx_buf, 0, PB_BUFSIZE); + pb_rx_size = 0; return; } @@ -360,13 +368,8 @@ void mt_check_packet() { size_t mt_serial_check(char *buf, size_t space_left) { size_t bytes_read = 0; - while (Serial1.available()) { - char c = Serial1.read(); - *buf++ = c; - if (++bytes_read >= space_left) { - Serial.println("Serial overflow"); - break; - } + while (bytes_read < space_left && Serial1.available()) { + buf[bytes_read++] = Serial1.read(); } return bytes_read; } From 0c2bfd44e21ddb2a4b63e606250b9e874ef36c45 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thomas=20G=C3=B6ttgens?= Date: Sun, 12 Jul 2026 20:16:36 +0200 Subject: [PATCH 05/21] Address review: stats retry on transient failure, Wire timeout, full-length paths --- protobufs | 2 +- src/main.cpp | 11 +++++++++-- src/meshtastic/interdevice.pb.h | 10 +++++----- 3 files changed, 15 insertions(+), 8 deletions(-) diff --git a/protobufs b/protobufs index ba8b238..10287b6 160000 --- a/protobufs +++ b/protobufs @@ -1 +1 @@ -Subproject commit ba8b2383925c5eb447f8112054277278669861b9 +Subproject commit 10287b670691ec45b32b8dc426f4c790c183bcc4 diff --git a/src/main.cpp b/src/main.cpp index 1f0e699..6187c56 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -59,16 +59,21 @@ bool sd_ensure(void) { return true; } -// runs from loop(), outside the request path +// runs from loop(), outside the request path; retries rate-limited on +// transient SDFS.info failures void sd_update_stats(void) { + static uint32_t last_attempt = 0; if (!sd_stats_pending || !sd_init_flag) return; - sd_stats_pending = false; + if (last_attempt != 0 && millis() - last_attempt < 5000) + return; + last_attempt = millis(); FSInfo info; if (SDFS.info(info)) { sd_used_bytes = info.usedBytes; sd_free_bytes = info.totalBytes - info.usedBytes; sd_stats_valid = true; + sd_stats_pending = false; } } @@ -116,6 +121,8 @@ void setup() { Wire.setSDA(20); Wire.setSCL(21); Wire.begin(); + // a hung bus must not stall the executor, reset it on timeout + Wire.setTimeout(50, true); // SD Card slot is on SPI1 SPI1.setSCK(10); diff --git a/src/meshtastic/interdevice.pb.h b/src/meshtastic/interdevice.pb.h index c316c92..e8f13d7 100644 --- a/src/meshtastic/interdevice.pb.h +++ b/src/meshtastic/interdevice.pb.h @@ -46,7 +46,7 @@ typedef PB_BYTES_ARRAY_T(4096) meshtastic_FileTransfer_filedata_t; /* Message for file operations */ typedef struct _meshtastic_FileTransfer { meshtastic_FileOperation operation; /* File operation (GET, POST, PUT, DELETE) */ - char filepath[255]; /* Path of the file on the SD card */ + char filepath[256]; /* Path of the file on the SD card */ meshtastic_FileTransfer_filedata_t filedata; /* Chunk content (POST/PUT request, GET response) */ bool success; /* Response: Was the operation successful? */ char message[255]; /* Response: Status message */ @@ -60,7 +60,7 @@ typedef struct _meshtastic_FileTransfer { /* Message for structured directory listing */ typedef struct _meshtastic_DirectoryListing { - char directory[255]; /* Path of the directory */ + char directory[256]; /* Path of the directory */ /* One page of entry names, full FAT LFN length so they round-trip into FileTransfer.filepath. Page size is the max_count in interdevice.options; page through with offset and total_count. */ @@ -291,11 +291,11 @@ extern const pb_msgdesc_t meshtastic_InterdeviceMessage_msg; /* Maximum encoded size of messages (where known) */ #define MESHTASTIC_MESHTASTIC_INTERDEVICE_PB_H_MAX_SIZE meshtastic_InterdeviceMessage_size -#define meshtastic_DirectoryListing_size 4656 -#define meshtastic_FileTransfer_size 4645 +#define meshtastic_DirectoryListing_size 4657 +#define meshtastic_FileTransfer_size 4646 #define meshtastic_I2CResult_size 261 #define meshtastic_I2CTransaction_size 271 -#define meshtastic_InterdeviceMessage_size 4665 +#define meshtastic_InterdeviceMessage_size 4666 #define meshtastic_SdCardInfo_size 39 #ifdef __cplusplus From 598a30e2fed669f4a7be5506d3d26d4ad611d07a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thomas=20G=C3=B6ttgens?= Date: Sun, 12 Jul 2026 22:00:10 +0200 Subject: [PATCH 06/21] Move SD ownership to core1, answer ping, buffer NMEA downlink Core1 now owns SD mounting, the stats scan and card-removal recovery; core0 claims the card per request with a try-lock and reports SD busy instead of stalling the link while a mount attempt or FAT scan runs. Failed mounts no longer block the loop with no card inserted, a pulled card is detected and remounted, and get_sd_info answers from state cached at mount time. Directory listings compute total_count once per directory instead of per page. Downlink NMEA drains through a ring buffer instead of blocking on the 9600 baud GPS UART. Adds the pong response for the new ping liveness probe. --- protobufs | 2 +- src/main.cpp | 237 ++++++++++++++++++++++++-------- src/meshtastic/interdevice.pb.h | 8 ++ src/serial_proto.cpp | 123 ++++++++++------- src/serial_proto.h | 12 ++ 5 files changed, 275 insertions(+), 107 deletions(-) diff --git a/protobufs b/protobufs index 10287b6..eec1158 160000 --- a/protobufs +++ b/protobufs @@ -1 +1 @@ -Subproject commit 10287b670691ec45b32b8dc426f4c790c183bcc4 +Subproject commit eec11585c98c9eb56829b038ace2b17ddd7a766b diff --git a/src/main.cpp b/src/main.cpp index 6187c56..f858101 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -1,9 +1,10 @@ +#include "buzzer.h" +#include "serial_proto.h" #include #include #include #include -#include "serial_proto.h" -#include "buzzer.h" +#include #define VERSION "v2.0.0M" @@ -27,63 +28,173 @@ const int BUFFER_SIZE = 1024; char nmeaBuffer[BUFFER_SIZE]; int bufferIndex = 0; -bool sd_init_flag = 0; const int SD_CS_PIN = 13; -// used/free statistics, computed in the background: the FAT scan behind -// usedBytes takes seconds on FAT16/32 cards and must not stall the -// request path (the UI on the other end waits synchronously) -static bool sd_stats_pending = false; +// All SD card access is arbitrated between the two cores with this mutex. +// Core1 owns mounting, card-removal recovery and the stats scan, all of +// which can block for seconds (SD.begin spins its full timeout on an empty +// slot, the FAT scan behind usedBytes walks the whole FAT). Core0 claims +// the mutex per request with a try-lock and reports "SD busy" instead of +// stalling the link, so GPS forwarding and other requests keep flowing. +auto_init_mutex(sd_mutex); +static volatile bool sd_mounted = false; +static volatile bool sd_recheck = false; +// bumped on every unmount so serial_proto drops file handles that cannot +// survive a remount +volatile uint32_t sd_generation = 0; + +// used/free statistics from the core1 scan +static volatile bool sd_stats_pending = false; static bool sd_stats_valid = false; static uint64_t sd_used_bytes = 0; static uint64_t sd_free_bytes = 0; +// card identity, cached at mount time so get_sd_info never touches the card +static meshtastic_SdCardInfo sd_info_cache; -// (Re-)initialize the SD card on demand. The Indicator's slot has no -// card-detect line, so retry rate-limited whenever a file operation -// arrives; this makes inserting a card after boot just work. -bool sd_ensure(void) { - static uint32_t last_attempt = 0; - if (sd_init_flag) - return true; - if (last_attempt != 0 && millis() - last_attempt < 2000) - return false; - last_attempt = millis(); - if (!SD.begin(SD_CS_PIN, 16000000, SPI1)) { - Serial.println("Card failed, or not present"); - return false; +// ---- core0 interface (used by serial_proto.cpp) ---- + +// Claim exclusive card access for one request. SD_CLAIM_BUSY means core1 +// is mounting or scanning right now; the caller reports that instead of +// waiting for it. +int sd_claim(void) { + if (!mutex_try_enter(&sd_mutex, NULL)) + return SD_CLAIM_BUSY; + if (!sd_mounted) { + mutex_exit(&sd_mutex); + return SD_CLAIM_NOCARD; } - Serial.println("card initialized."); - sd_init_flag = 1; - sd_stats_valid = false; - sd_stats_pending = true; - return true; + return SD_CLAIM_OK; } -// runs from loop(), outside the request path; retries rate-limited on -// transient SDFS.info failures -void sd_update_stats(void) { - static uint32_t last_attempt = 0; - if (!sd_stats_pending || !sd_init_flag) - return; - if (last_attempt != 0 && millis() - last_attempt < 5000) +void sd_release(void) { mutex_exit(&sd_mutex); } + +// A card-level failure was observed on a mounted card: assume it was +// pulled and have core1 recover with a clean remount +void sd_mark_dead(void) { sd_recheck = true; } + +// A write changed the filesystem; refresh the stats (rate-limited on core1) +void sd_note_write(void) { sd_stats_pending = true; } + +// Snapshot for get_sd_info, answered entirely from cached state so the +// response never waits on the card +void sd_get_info(meshtastic_SdCardInfo *out) { + memset(out, 0, sizeof(*out)); + if (!sd_mounted) return; - last_attempt = millis(); - FSInfo info; - if (SDFS.info(info)) { - sd_used_bytes = info.usedBytes; - sd_free_bytes = info.totalBytes - info.usedBytes; - sd_stats_valid = true; - sd_stats_pending = false; + *out = sd_info_cache; // stable while mounted + out->present = true; + // used/free stay zero while the scan is still running (mutex held by + // core1) or has not completed yet + if (mutex_try_enter(&sd_mutex, NULL)) { + if (sd_stats_valid) { + out->used_bytes = sd_used_bytes; + out->free_bytes = sd_free_bytes; + } + mutex_exit(&sd_mutex); + } +} + +// ---- core1: mount, recovery and stats scan ---- + +// caller holds sd_mutex, card is freshly mounted +static void sd_cache_card_info(void) { + memset(&sd_info_cache, 0, sizeof(sd_info_cache)); + sd_info_cache.card_size = SD.size64(); + switch (SD.fatType()) { + case 16: + sd_info_cache.fat_type = meshtastic_SdCardInfo_FatType_FAT16; + break; + case 32: + sd_info_cache.fat_type = meshtastic_SdCardInfo_FatType_FAT32; + break; + case 64: + sd_info_cache.fat_type = meshtastic_SdCardInfo_FatType_EXFAT; + break; + default: + sd_info_cache.fat_type = meshtastic_SdCardInfo_FatType_UNKNOWN_FAT; } + // SdFat card types: 1 = SD1, 2 = SD2, 3 = SDHC/SDXC (differ by capacity) + switch (SD.type()) { + case 1: + case 2: + sd_info_cache.card_type = meshtastic_SdCardInfo_CardType_SD; + break; + case 3: + sd_info_cache.card_type = + sd_info_cache.card_size > 32ull * 1000 * 1000 * 1000 + ? meshtastic_SdCardInfo_CardType_SDXC + : meshtastic_SdCardInfo_CardType_SDHC; + break; + default: + sd_info_cache.card_type = meshtastic_SdCardInfo_CardType_UNKNOWN_CARD; + } +} + +void setup1() { + // SD card slot is on SPI1, used exclusively under sd_mutex + SPI1.setSCK(10); + SPI1.setTX(11); + SPI1.setRX(12); } -// false while the background scan has not completed yet -bool sd_used_free(uint64_t *used, uint64_t *free_bytes) { - if (!sd_stats_valid) - return false; - *used = sd_used_bytes; - *free_bytes = sd_free_bytes; - return true; +void loop1() { + static uint32_t last_mount = 0; + static uint32_t last_scan = 0; + + if (sd_recheck) { + mutex_enter_blocking(&sd_mutex); + sd_mounted = false; + sd_stats_valid = false; + sd_generation++; + SD.end(false); + mutex_exit(&sd_mutex); + sd_recheck = false; + last_mount = 0; // retry immediately + Serial.println("SD card lost, remounting"); + } + + if (!sd_mounted) { + // The Indicator's slot has no card-detect line, so retry rate-limited; + // this makes inserting (or re-inserting) a card after boot just work + if (last_mount == 0 || millis() - last_mount >= 2000) { + bool ok; + mutex_enter_blocking(&sd_mutex); + if ((ok = SD.begin(SD_CS_PIN, 16000000, SPI1))) { + sd_cache_card_info(); + sd_stats_valid = false; + sd_stats_pending = true; + } + mutex_exit(&sd_mutex); + if (ok) { + // published after the info cache is fully written + sd_mounted = true; + Serial.println("card initialized."); + } + // stamped after the attempt: begin() spins for its full timeout + // when the slot is empty + last_mount = millis(); + } + } else if (sd_stats_pending) { + // initial scan right after mount, write-triggered refreshes at most + // once a minute (each scan blocks file requests for its duration) + uint32_t interval = sd_stats_valid ? 60000 : 0; + if (last_scan == 0 || millis() - last_scan >= interval) { + FSInfo info; + mutex_enter_blocking(&sd_mutex); + bool ok = SDFS.info(info); + if (ok) { + sd_used_bytes = info.usedBytes; + sd_free_bytes = info.totalBytes - info.usedBytes; + sd_stats_valid = true; + sd_stats_pending = false; + } + mutex_exit(&sd_mutex); + if (!ok) + sd_recheck = true; // info failing on a mounted card = card gone + last_scan = millis(); + } + } + delay(20); } // The built-in sensor needs to be powered on @@ -94,9 +205,28 @@ void sensor_power_on(void) { /************************ beep ****************************/ +// Downlink NMEA (ESP32 -> GPS chip) is buffered and drained a few bytes +// per loop() pass: SerialPIO::write blocks at 9600 baud once its small +// hardware FIFO is full, so printing a whole sentence inline would stall +// the loop for tens of ms to a second per message +static uint8_t gps_tx_ring[2048]; +static size_t gps_tx_head = 0, gps_tx_tail = 0; + void onNmeaReceived(char *nmea) { - // pass this on to the GPS chip - softSerial2.print(nmea); + for (const char *p = nmea; *p; p++) { + size_t next = (gps_tx_head + 1) % sizeof(gps_tx_ring); + if (next == gps_tx_tail) + break; // ring full: drop the rest, the NMEA stream self-heals + gps_tx_ring[gps_tx_head] = *p; + gps_tx_head = next; + } +} + +static void gps_tx_drain(void) { + while (gps_tx_head != gps_tx_tail && softSerial2.availableForWrite() > 0) { + softSerial2.write(gps_tx_ring[gps_tx_tail]); + gps_tx_tail = (gps_tx_tail + 1) % sizeof(gps_tx_ring); + } } /************************ setup & loop ****************************/ @@ -124,11 +254,7 @@ void setup() { // a hung bus must not stall the executor, reset it on timeout Wire.setTimeout(50, true); - // SD Card slot is on SPI1 - SPI1.setSCK(10); - SPI1.setTX(11); - SPI1.setRX(12); - sd_ensure(); + // the SD card lives on core1, see setup1()/loop1() mt_set_nmea_callback(onNmeaReceived); @@ -146,7 +272,7 @@ void loop() { buzz_off = 0; } - sd_update_stats(); + gps_tx_drain(); mt_loop(); // read GPS data lines into buffer and send it to the ESP32 @@ -162,7 +288,8 @@ void loop() { myPacket.which_data = meshtastic_InterdeviceMessage_nmea_tag; myPacket.data.nmea[0] = '\0'; // Ensure the string is null strncpy(myPacket.data.nmea, nmeaBuffer, bufferIndex); - myPacket.data.nmea[bufferIndex] = '\0'; // Explicitly null-terminate the string + myPacket.data.nmea[bufferIndex] = + '\0'; // Explicitly null-terminate the string mt_send_uplink(myPacket); bufferIndex = 0; break; diff --git a/src/meshtastic/interdevice.pb.h b/src/meshtastic/interdevice.pb.h index e8f13d7..c383ea0 100644 --- a/src/meshtastic/interdevice.pb.h +++ b/src/meshtastic/interdevice.pb.h @@ -117,6 +117,10 @@ typedef struct _meshtastic_InterdeviceMessage { meshtastic_DirectoryListing directory_listing; bool get_sd_info; /* Request: SD card statistics */ meshtastic_SdCardInfo sd_info; /* Response */ + /* Link liveness probe. The receiver answers ping with pong, echoing the + id. Touches no peripherals, so it works with nothing attached. */ + bool ping; + bool pong; } data; /* Correlates a response with its request: responses echo the id of the request they answer. 0 for unsolicited messages (e.g. the nmea stream). */ @@ -206,6 +210,8 @@ extern "C" { #define meshtastic_InterdeviceMessage_directory_listing_tag 8 #define meshtastic_InterdeviceMessage_get_sd_info_tag 9 #define meshtastic_InterdeviceMessage_sd_info_tag 10 +#define meshtastic_InterdeviceMessage_ping_tag 11 +#define meshtastic_InterdeviceMessage_pong_tag 12 #define meshtastic_InterdeviceMessage_id_tag 15 /* Struct field encoding specification for nanopb */ @@ -265,6 +271,8 @@ X(a, STATIC, ONEOF, MESSAGE, (data,file_transfer,data.file_transfer), 7) X(a, STATIC, ONEOF, MESSAGE, (data,directory_listing,data.directory_listing), 8) \ X(a, STATIC, ONEOF, BOOL, (data,get_sd_info,data.get_sd_info), 9) \ X(a, STATIC, ONEOF, MESSAGE, (data,sd_info,data.sd_info), 10) \ +X(a, STATIC, ONEOF, BOOL, (data,ping,data.ping), 11) \ +X(a, STATIC, ONEOF, BOOL, (data,pong,data.pong), 12) \ X(a, STATIC, SINGULAR, UINT32, id, 15) #define meshtastic_InterdeviceMessage_CALLBACK NULL #define meshtastic_InterdeviceMessage_DEFAULT NULL diff --git a/src/serial_proto.cpp b/src/serial_proto.cpp index 452ed48..6e62e5f 100644 --- a/src/serial_proto.cpp +++ b/src/serial_proto.cpp @@ -3,20 +3,32 @@ #include #include -extern bool sd_ensure(void); -extern bool sd_used_free(uint64_t *used, uint64_t *free_bytes); - // One-entry read cache: map tiles are fetched in sequential 4K chunks, so // keeping the file open between chunks avoids a FAT open + seek per chunk static File read_cache; static char read_cache_path[256] = ""; +// Directory entry count from the last full walk, so follow-up pages of the +// same listing don't have to walk to the end of the directory again +static char count_cache_path[256] = ""; +static uint32_t count_cache_total = 0; + static void invalidate_read_cache() { if (read_cache) read_cache.close(); read_cache_path[0] = '\0'; } +// call before any card access: file handles do not survive a remount +static void check_sd_generation() { + static uint32_t seen_generation = 0; + if (seen_generation != sd_generation) { + invalidate_read_cache(); + count_cache_path[0] = '\0'; + seen_generation = sd_generation; + } +} + // The buffer used for protobuf encoding/decoding. Since there's only one, and // it's global, we have to make sure we're only ever doing one encoding or // decoding at a time. @@ -137,10 +149,13 @@ bool mt_handle_packet(size_t payload_len) { out->offset = request.offset; out->success = false; - if (!sd_ensure()) { - strncpy(out->message, "no SD card", sizeof(out->message) - 1); + int claim = sd_claim(); + if (claim != SD_CLAIM_OK) { + strncpy(out->message, claim == SD_CLAIM_BUSY ? "SD busy" : "no SD card", + sizeof(out->message) - 1); return mt_send_uplink(response); } + check_sd_generation(); switch (request.operation) { case meshtastic_FileOperation_GET: { @@ -151,8 +166,7 @@ bool mt_handle_packet(size_t payload_len) { strncpy(out->message, "open failed", sizeof(out->message) - 1); break; } - strncpy(read_cache_path, request.filepath, - sizeof(read_cache_path) - 1); + strncpy(read_cache_path, request.filepath, sizeof(read_cache_path) - 1); } out->file_size = read_cache.size(); size_t want = request.length; @@ -165,11 +179,13 @@ bool mt_handle_packet(size_t payload_len) { } if (!read_cache.seek((uint32_t)request.offset)) { strncpy(out->message, "seek failed", sizeof(out->message) - 1); + sd_mark_dead(); // seek on an open handle only fails at card level break; } int got = read_cache.read(out->filedata.bytes, want); if (got < 0) { strncpy(out->message, "read failed", sizeof(out->message) - 1); + sd_mark_dead(); break; } out->filedata.size = got; @@ -186,12 +202,16 @@ bool mt_handle_packet(size_t payload_len) { File f = SD.open(request.filepath, FILE_WRITE); // append mode, creates if (!f) { strncpy(out->message, "open failed", sizeof(out->message) - 1); + // creating a file on a healthy card does not fail; assume the + // card was pulled and let core1 verify with a remount + sd_mark_dead(); break; } if (f.size() != request.offset) { snprintf(out->message, sizeof(out->message), "bad offset %u, file size %u", (unsigned)request.offset, (unsigned)f.size()); + out->file_size = f.size(); // lets the client resync its offset f.close(); break; } @@ -199,8 +219,13 @@ bool mt_handle_packet(size_t payload_len) { out->file_size = f.size(); f.close(); out->success = (wrote == request.filedata.size); - if (!out->success) + if (!out->success) { strncpy(out->message, "write failed", sizeof(out->message) - 1); + sd_mark_dead(); + } else { + sd_note_write(); + } + count_cache_path[0] = '\0'; break; } @@ -209,11 +234,15 @@ bool mt_handle_packet(size_t payload_len) { out->success = SD.remove(request.filepath); if (!out->success) strncpy(out->message, "remove failed", sizeof(out->message) - 1); + else + sd_note_write(); + count_cache_path[0] = '\0'; break; default: strncpy(out->message, "unknown operation", sizeof(out->message) - 1); } + sd_release(); return mt_send_uplink(response); } @@ -229,10 +258,13 @@ bool mt_handle_packet(size_t payload_len) { out->offset = request.offset; out->success = false; - if (!sd_ensure()) { - strncpy(out->message, "no SD card", sizeof(out->message) - 1); + int claim = sd_claim(); + if (claim != SD_CLAIM_OK) { + strncpy(out->message, claim == SD_CLAIM_BUSY ? "SD busy" : "no SD card", + sizeof(out->message) - 1); return mt_send_uplink(response); } + check_sd_generation(); File dir = SD.open(request.directory); if (!dir || !dir.isDirectory()) { @@ -240,6 +272,11 @@ bool mt_handle_packet(size_t payload_len) { } else { const size_t max_names = sizeof(out->filenames) / sizeof(out->filenames[0]); + // Walking to the end of the directory just to count the entries is + // expensive (a full LFN-parsing walk per page); do it once on the + // first page and reuse the count for the follow-up pages + bool have_total = request.offset > 0 && + strcmp(count_cache_path, request.directory) == 0; uint32_t index = 0; while (true) { File entry = dir.openNextFile(); @@ -253,60 +290,44 @@ bool mt_handle_packet(size_t payload_len) { } index++; entry.close(); + if (have_total && out->filenames_count >= max_names) + break; // page full, the total is already known + } + if (have_total) { + out->total_count = count_cache_total; + } else { + out->total_count = index; + strncpy(count_cache_path, request.directory, + sizeof(count_cache_path) - 1); + count_cache_total = index; } - out->total_count = index; out->success = true; } if (dir) dir.close(); + sd_release(); return mt_send_uplink(response); } case meshtastic_InterdeviceMessage_get_sd_info_tag: { - // SD card statistics. The used-bytes calculation scans the FAT on - // FAT16/32 cards, which can take a while on the first call. + // SD card statistics, answered entirely from state cached at mount + // time so the response never waits on the card (this also serves as + // the readiness probe target for older ESP32 firmware) meshtastic_InterdeviceMessage &response = tx_response; memset(&response, 0, sizeof(response)); response.id = message.id; // correlate with the request response.which_data = meshtastic_InterdeviceMessage_sd_info_tag; - meshtastic_SdCardInfo *info = &response.data.sd_info; - - if (sd_ensure()) { - info->present = true; - info->card_size = SD.size64(); - switch (SD.fatType()) { - case 16: - info->fat_type = meshtastic_SdCardInfo_FatType_FAT16; - break; - case 32: - info->fat_type = meshtastic_SdCardInfo_FatType_FAT32; - break; - case 64: - info->fat_type = meshtastic_SdCardInfo_FatType_EXFAT; - break; - default: - info->fat_type = meshtastic_SdCardInfo_FatType_UNKNOWN_FAT; - } - switch (SD.type()) { - case 0: // SD1 - case 1: // SD2 - info->card_type = meshtastic_SdCardInfo_CardType_SD; - break; - case 3: // SDHC or SDXC, differ by capacity - info->card_type = info->card_size > 32ull * 1000 * 1000 * 1000 - ? meshtastic_SdCardInfo_CardType_SDXC - : meshtastic_SdCardInfo_CardType_SDHC; - break; - default: - info->card_type = meshtastic_SdCardInfo_CardType_UNKNOWN_CARD; - } - // from the background scan; zero while it is still running - uint64_t used, free_bytes; - if (sd_used_free(&used, &free_bytes)) { - info->used_bytes = used; - info->free_bytes = free_bytes; - } - } + sd_get_info(&response.data.sd_info); + return mt_send_uplink(response); + } + + case meshtastic_InterdeviceMessage_ping_tag: { + // liveness probe, must work with nothing attached to this MCU + meshtastic_InterdeviceMessage &response = tx_response; + memset(&response, 0, sizeof(response)); + response.id = message.id; // correlate with the request + response.which_data = meshtastic_InterdeviceMessage_pong_tag; + response.data.pong = true; return mt_send_uplink(response); } diff --git a/src/serial_proto.h b/src/serial_proto.h index 075f34e..ae1ac19 100644 --- a/src/serial_proto.h +++ b/src/serial_proto.h @@ -22,4 +22,16 @@ bool mt_send_uplink(const meshtastic_InterdeviceMessage &message); void mt_set_nmea_callback(void (*callback)(char *nmea)); void mt_loop(); +// SD card access arbitration between the cores, implemented in main.cpp: +// core1 owns mounting and the background stats scan, core0 claims the card +// per request and fails fast instead of stalling the link +enum { SD_CLAIM_OK = 0, SD_CLAIM_NOCARD, SD_CLAIM_BUSY }; +int sd_claim(void); +void sd_release(void); +void sd_mark_dead(void); +void sd_note_write(void); +void sd_get_info(meshtastic_SdCardInfo *out); +// bumped on every unmount, open file handles do not survive a remount +extern volatile uint32_t sd_generation; + #endif \ No newline at end of file From 148e0230e930fdd33561b4324cc2a1ca51c0e9ad Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thomas=20G=C3=B6ttgens?= Date: Sun, 12 Jul 2026 22:20:42 +0200 Subject: [PATCH 07/21] Link resync, ping/stats protocol update, scan cap, watchdog Frame resync scans to the next magic instead of flushing the RX buffer, handles all buffered frames per pass and logs Serial1 RX overflows. The protobuf encoder gets the correct buffer bound. GET on a directory path is rejected. sd_info reports stats_valid so a scan in progress is distinguishable from a full card. An I2C scan where every address responds is reported as empty (stuck bus). Hardware watchdog on the core0 request loop. Buzzer timing is millis-wrap safe. --- src/buzzer.cpp | 4 +- src/main.cpp | 11 ++++- src/meshtastic/interdevice.pb.h | 22 ++++++---- src/serial_proto.cpp | 72 ++++++++++++++++++++------------- 4 files changed, 73 insertions(+), 36 deletions(-) diff --git a/src/buzzer.cpp b/src/buzzer.cpp index 6b0814f..79a2b5c 100644 --- a/src/buzzer.cpp +++ b/src/buzzer.cpp @@ -1,10 +1,12 @@ #include "buzzer.h" - uint32_t buzz_off = 0; +uint32_t buzz_off = 0; void beep_init(void) { pinMode(BUZZER_GPIO, OUTPUT); } void beep_off(void) { digitalWrite(BUZZER_GPIO, LOW); } void beep_on(uint32_t duration) { analogWrite(BUZZER_GPIO, 127); buzz_off = millis() + duration; + if (buzz_off == 0) // 0 means inactive, don't let the beep stick on + buzz_off = 1; } \ No newline at end of file diff --git a/src/main.cpp b/src/main.cpp index f858101..0394b39 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -89,6 +89,7 @@ void sd_get_info(meshtastic_SdCardInfo *out) { if (sd_stats_valid) { out->used_bytes = sd_used_bytes; out->free_bytes = sd_free_bytes; + out->stats_valid = true; } mutex_exit(&sd_mutex); } @@ -263,11 +264,19 @@ void setup() { Serial.printf(SENSECAP, VERSION); beep_on(50); + + // This MCU is the sole path to GPS, sensors and SD card; a wedged + // peripheral driver must reboot it rather than take them down for good. + // The request loop on core0 feeds the watchdog, core1 may block freely. + rp2040.wdt_begin(4000); } void loop() { - if (buzz_off > 0 && millis() > buzz_off) { + // watchdog: this loop must never stall, all blocking work is on core1 + rp2040.wdt_reset(); + + if (buzz_off != 0 && (int32_t)(millis() - buzz_off) >= 0) { beep_off(); buzz_off = 0; } diff --git a/src/meshtastic/interdevice.pb.h b/src/meshtastic/interdevice.pb.h index c383ea0..00a94e8 100644 --- a/src/meshtastic/interdevice.pb.h +++ b/src/meshtastic/interdevice.pb.h @@ -61,9 +61,11 @@ typedef struct _meshtastic_FileTransfer { /* Message for structured directory listing */ typedef struct _meshtastic_DirectoryListing { char directory[256]; /* Path of the directory */ - /* One page of entry names, full FAT LFN length so they round-trip into - FileTransfer.filepath. Page size is the max_count in - interdevice.options; page through with offset and total_count. */ + /* One page of entry names, full FAT LFN length. Subdirectories carry a + trailing slash. Note that a name whose directory prefix pushes the + combined path past the FileTransfer.filepath limit cannot round-trip. + Page size is the max_count in interdevice.options; page through with + offset and total_count. */ pb_size_t filenames_count; char filenames[16][256]; bool success; /* Response: Was the operation successful? */ @@ -93,6 +95,10 @@ typedef struct _meshtastic_SdCardInfo { uint64_t card_size; /* Filesystem size in bytes */ uint64_t used_bytes; /* Used bytes (may be expensive to compute on FAT32) */ uint64_t free_bytes; /* Free bytes */ + /* used_bytes/free_bytes are only meaningful when true: the scan behind + them runs in the background after mount and can take a while, and a + full card is otherwise indistinguishable from a scan in progress */ + bool stats_valid; } meshtastic_SdCardInfo; typedef PB_BYTES_ARRAY_T(256) meshtastic_I2CResult_read_data_t; @@ -164,13 +170,13 @@ extern "C" { #define meshtastic_FileTransfer_init_default {_meshtastic_FileOperation_MIN, "", {0, {0}}, 0, "", 0, 0, 0} #define meshtastic_DirectoryListing_init_default {"", 0, {"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", ""}, 0, "", 0, 0} #define meshtastic_I2CTransaction_init_default {0, {0, {0}}, 0} -#define meshtastic_SdCardInfo_init_default {0, _meshtastic_SdCardInfo_CardType_MIN, _meshtastic_SdCardInfo_FatType_MIN, 0, 0, 0} +#define meshtastic_SdCardInfo_init_default {0, _meshtastic_SdCardInfo_CardType_MIN, _meshtastic_SdCardInfo_FatType_MIN, 0, 0, 0, 0} #define meshtastic_I2CResult_init_default {_meshtastic_I2CResult_Status_MIN, {0, {0}}} #define meshtastic_InterdeviceMessage_init_default {0, {""}, 0} #define meshtastic_FileTransfer_init_zero {_meshtastic_FileOperation_MIN, "", {0, {0}}, 0, "", 0, 0, 0} #define meshtastic_DirectoryListing_init_zero {"", 0, {"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", ""}, 0, "", 0, 0} #define meshtastic_I2CTransaction_init_zero {0, {0, {0}}, 0} -#define meshtastic_SdCardInfo_init_zero {0, _meshtastic_SdCardInfo_CardType_MIN, _meshtastic_SdCardInfo_FatType_MIN, 0, 0, 0} +#define meshtastic_SdCardInfo_init_zero {0, _meshtastic_SdCardInfo_CardType_MIN, _meshtastic_SdCardInfo_FatType_MIN, 0, 0, 0, 0} #define meshtastic_I2CResult_init_zero {_meshtastic_I2CResult_Status_MIN, {0, {0}}} #define meshtastic_InterdeviceMessage_init_zero {0, {""}, 0} @@ -198,6 +204,7 @@ extern "C" { #define meshtastic_SdCardInfo_card_size_tag 4 #define meshtastic_SdCardInfo_used_bytes_tag 5 #define meshtastic_SdCardInfo_free_bytes_tag 6 +#define meshtastic_SdCardInfo_stats_valid_tag 7 #define meshtastic_I2CResult_status_tag 1 #define meshtastic_I2CResult_read_data_tag 2 #define meshtastic_InterdeviceMessage_nmea_tag 1 @@ -250,7 +257,8 @@ X(a, STATIC, SINGULAR, UENUM, card_type, 2) \ X(a, STATIC, SINGULAR, UENUM, fat_type, 3) \ X(a, STATIC, SINGULAR, UINT64, card_size, 4) \ X(a, STATIC, SINGULAR, UINT64, used_bytes, 5) \ -X(a, STATIC, SINGULAR, UINT64, free_bytes, 6) +X(a, STATIC, SINGULAR, UINT64, free_bytes, 6) \ +X(a, STATIC, SINGULAR, BOOL, stats_valid, 7) #define meshtastic_SdCardInfo_CALLBACK NULL #define meshtastic_SdCardInfo_DEFAULT NULL @@ -304,7 +312,7 @@ extern const pb_msgdesc_t meshtastic_InterdeviceMessage_msg; #define meshtastic_I2CResult_size 261 #define meshtastic_I2CTransaction_size 271 #define meshtastic_InterdeviceMessage_size 4666 -#define meshtastic_SdCardInfo_size 39 +#define meshtastic_SdCardInfo_size 41 #ifdef __cplusplus } /* extern "C" */ diff --git a/src/serial_proto.cpp b/src/serial_proto.cpp index 6e62e5f..e92b1cb 100644 --- a/src/serial_proto.cpp +++ b/src/serial_proto.cpp @@ -33,7 +33,7 @@ static void check_sd_generation() { // it's global, we have to make sure we're only ever doing one encoding or // decoding at a time. -#define PB_BUFSIZE meshtastic_InterdeviceMessage_size + MT_HEADER_SIZE +#define PB_BUFSIZE (meshtastic_InterdeviceMessage_size + MT_HEADER_SIZE) pb_byte_t pb_rx_buf[PB_BUFSIZE]; size_t pb_rx_size = 0; // Number of bytes currently in the buffer @@ -166,6 +166,12 @@ bool mt_handle_packet(size_t payload_len) { strncpy(out->message, "open failed", sizeof(out->message) - 1); break; } + if (read_cache.isDirectory()) { + // reading one returns raw directory entries, not file content + invalidate_read_cache(); + strncpy(out->message, "not a file", sizeof(out->message) - 1); + break; + } strncpy(read_cache_path, request.filepath, sizeof(read_cache_path) - 1); } out->file_size = read_cache.size(); @@ -346,6 +352,12 @@ bool mt_handle_packet(size_t payload_len) { response.data.i2c_scan_result .bytes[response.data.i2c_scan_result.size++] = addr; } + // a stuck-low SDA acks every probe; report a fault as an empty scan + // rather than 112 phantom devices + if (response.data.i2c_scan_result.size >= 100) { + Serial.println("I2C scan: bus fault, every address responded"); + response.data.i2c_scan_result.size = 0; + } return mt_send_uplink(response); } @@ -356,35 +368,37 @@ bool mt_handle_packet(size_t payload_len) { } } -void mt_check_packet() { - if (pb_rx_size < MT_HEADER_SIZE) { - // We don't even have a header yet - return; +// Distance to the next byte that could start a frame. Skips the corrupt +// prefix while keeping anything that may be a frame queued behind it (a +// trailing lone MT_MAGIC_0 counts, its successor has not arrived yet). +static size_t scan_magic(const pb_byte_t *buf, size_t len) { + for (size_t i = 1; i < len; i++) { + if (buf[i] == MT_MAGIC_0 && (i + 1 == len || buf[i + 1] == MT_MAGIC_1)) + return i; } + return len; +} - if (pb_rx_buf[0] != MT_MAGIC_0 || pb_rx_buf[1] != MT_MAGIC_1) { - Serial.println("Got bad magic"); - memset(pb_rx_buf, 0, PB_BUFSIZE); - pb_rx_size = 0; - return; - } +void mt_check_packet() { + // process everything buffered; one pass can deliver several frames + while (pb_rx_size >= MT_HEADER_SIZE) { + size_t payload_len = (size_t)(pb_rx_buf[2] << 8 | pb_rx_buf[3]); + if (pb_rx_buf[0] != MT_MAGIC_0 || pb_rx_buf[1] != MT_MAGIC_1 || + payload_len + MT_HEADER_SIZE > PB_BUFSIZE) { + // Corrupt or false header: resync on the next magic instead of + // flushing, one bad byte must not cost the frames behind it + size_t skip = scan_magic(pb_rx_buf, pb_rx_size); + Serial.printf("Bad frame header, dropping %u bytes\r\n", (unsigned)skip); + memmove(pb_rx_buf, pb_rx_buf + skip, pb_rx_size - skip); + pb_rx_size -= skip; + continue; + } - uint16_t payload_len = pb_rx_buf[2] << 8 | pb_rx_buf[3]; - if ((size_t)payload_len + MT_HEADER_SIZE > PB_BUFSIZE) { - // oversized frame can never complete, resync on the next magic - Serial.println("Got packet claiming to be ridiculous length"); - memset(pb_rx_buf, 0, PB_BUFSIZE); - pb_rx_size = 0; - return; - } + if (payload_len + MT_HEADER_SIZE > pb_rx_size) + return; // frame not complete yet - if ((size_t)(payload_len + MT_HEADER_SIZE) > pb_rx_size) { - // Packet not complete yet - return; + mt_handle_packet(payload_len); } - - // We have a complete packet, handle it - mt_handle_packet(payload_len); } size_t mt_serial_check(char *buf, size_t space_left) { @@ -399,8 +413,8 @@ bool mt_send_uplink(const meshtastic_InterdeviceMessage &message) { pb_tx_buf[0] = MT_MAGIC_0; pb_tx_buf[1] = MT_MAGIC_1; - pb_ostream_t stream = - pb_ostream_from_buffer(pb_tx_buf + MT_HEADER_SIZE, PB_BUFSIZE); + pb_ostream_t stream = pb_ostream_from_buffer(pb_tx_buf + MT_HEADER_SIZE, + PB_BUFSIZE - MT_HEADER_SIZE); if (!pb_encode(&stream, meshtastic_InterdeviceMessage_fields, &message)) { Serial.println("pb_encode failed"); return false; @@ -419,6 +433,10 @@ bool mt_send_uplink(const meshtastic_InterdeviceMessage &message) { void mt_loop() { size_t bytes_read = 0; + // byte loss is otherwise invisible: it surfaces as a resync much later + if (Serial1.overflow()) + Serial.println("Serial1 RX overflow, frames lost"); + // See if there are any more bytes to add to our buffer. size_t space_left = PB_BUFSIZE - pb_rx_size; From 0797cf5912d1a2776b92d40541b5e3ab4d639bb3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thomas=20G=C3=B6ttgens?= Date: Sun, 12 Jul 2026 22:32:58 +0200 Subject: [PATCH 08/21] Regen protos for I2CResult UNSPECIFIED, drop core debug port define --- platformio.ini | 1 - protobufs | 2 +- src/meshtastic/interdevice.pb.h | 13 ++++++++----- 3 files changed, 9 insertions(+), 7 deletions(-) diff --git a/platformio.ini b/platformio.ini index b79b94b..9e10056 100644 --- a/platformio.ini +++ b/platformio.ini @@ -24,7 +24,6 @@ build_flags = -Wno-unused-variable -Wcast-align -D__PLAT_RP2040__ - -DDEBUG_RP2040_PORT=Serial -DHW_SPI1_DEVICE lib_deps = diff --git a/protobufs b/protobufs index eec1158..674dd2c 160000 --- a/protobufs +++ b/protobufs @@ -1 +1 @@ -Subproject commit eec11585c98c9eb56829b038ace2b17ddd7a766b +Subproject commit 674dd2c0b9b3043a5380899f0fd5e34827df95bc diff --git a/src/meshtastic/interdevice.pb.h b/src/meshtastic/interdevice.pb.h index 00a94e8..02c9b2e 100644 --- a/src/meshtastic/interdevice.pb.h +++ b/src/meshtastic/interdevice.pb.h @@ -35,10 +35,13 @@ typedef enum _meshtastic_SdCardInfo_FatType { } meshtastic_SdCardInfo_FatType; typedef enum _meshtastic_I2CResult_Status { - meshtastic_I2CResult_Status_OK = 0, - meshtastic_I2CResult_Status_NACK_ADDRESS = 1, - meshtastic_I2CResult_Status_NACK_DATA = 2, - meshtastic_I2CResult_Status_ERROR = 3 + /* Never sent: an all-defaults (e.g. accidentally empty) message must + not decode as a successful transaction */ + meshtastic_I2CResult_Status_UNSPECIFIED = 0, + meshtastic_I2CResult_Status_OK = 1, + meshtastic_I2CResult_Status_NACK_ADDRESS = 2, + meshtastic_I2CResult_Status_NACK_DATA = 3, + meshtastic_I2CResult_Status_ERROR = 4 } meshtastic_I2CResult_Status; /* Struct definitions */ @@ -151,7 +154,7 @@ extern "C" { #define _meshtastic_SdCardInfo_FatType_MAX meshtastic_SdCardInfo_FatType_EXFAT #define _meshtastic_SdCardInfo_FatType_ARRAYSIZE ((meshtastic_SdCardInfo_FatType)(meshtastic_SdCardInfo_FatType_EXFAT+1)) -#define _meshtastic_I2CResult_Status_MIN meshtastic_I2CResult_Status_OK +#define _meshtastic_I2CResult_Status_MIN meshtastic_I2CResult_Status_UNSPECIFIED #define _meshtastic_I2CResult_Status_MAX meshtastic_I2CResult_Status_ERROR #define _meshtastic_I2CResult_Status_ARRAYSIZE ((meshtastic_I2CResult_Status)(meshtastic_I2CResult_Status_ERROR+1)) From 954da2889b572e382ecb270385d9492e1b13f113 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thomas=20G=C3=B6ttgens?= Date: Mon, 13 Jul 2026 09:20:07 +0200 Subject: [PATCH 09/21] Nack unhandled requests, classify I2C errors with a probe An undecodable or unhandled request gets a nack so the requester fails fast instead of burning its timeout. The core reports every failed non-empty write as a generic error, so a zero-length probe now tells an absent device apart from a data NACK or a bus fault, both for writes and for failed reads. Drops the unused NO_NEWS_PAUSE and a doubled include guard. --- protobufs | 2 +- src/meshtastic/interdevice.pb.h | 7 +++++++ src/serial_proto.cpp | 35 +++++++++++++++++++++++++++++++-- src/serial_proto.h | 10 +--------- 4 files changed, 42 insertions(+), 12 deletions(-) diff --git a/protobufs b/protobufs index 674dd2c..1cd8f0a 160000 --- a/protobufs +++ b/protobufs @@ -1 +1 @@ -Subproject commit 674dd2c0b9b3043a5380899f0fd5e34827df95bc +Subproject commit 1cd8f0a1dcef9edf466cae03f7077ce06118570d diff --git a/src/meshtastic/interdevice.pb.h b/src/meshtastic/interdevice.pb.h index 02c9b2e..3f2bb3e 100644 --- a/src/meshtastic/interdevice.pb.h +++ b/src/meshtastic/interdevice.pb.h @@ -130,6 +130,11 @@ typedef struct _meshtastic_InterdeviceMessage { id. Touches no peripherals, so it works with nothing attached. */ bool ping; bool pong; + /* Response: the request could not be decoded or is of an unhandled + type, so the requester fails fast instead of burning its timeout. + Echoes the id when known, 0 when the frame was undecodable. Never + sent in reaction to a nack. */ + bool nack; } data; /* Correlates a response with its request: responses echo the id of the request they answer. 0 for unsolicited messages (e.g. the nmea stream). */ @@ -222,6 +227,7 @@ extern "C" { #define meshtastic_InterdeviceMessage_sd_info_tag 10 #define meshtastic_InterdeviceMessage_ping_tag 11 #define meshtastic_InterdeviceMessage_pong_tag 12 +#define meshtastic_InterdeviceMessage_nack_tag 13 #define meshtastic_InterdeviceMessage_id_tag 15 /* Struct field encoding specification for nanopb */ @@ -284,6 +290,7 @@ X(a, STATIC, ONEOF, BOOL, (data,get_sd_info,data.get_sd_info), 9) \ X(a, STATIC, ONEOF, MESSAGE, (data,sd_info,data.sd_info), 10) \ X(a, STATIC, ONEOF, BOOL, (data,ping,data.ping), 11) \ X(a, STATIC, ONEOF, BOOL, (data,pong,data.pong), 12) \ +X(a, STATIC, ONEOF, BOOL, (data,nack,data.nack), 13) \ X(a, STATIC, SINGULAR, UINT32, id, 15) #define meshtastic_InterdeviceMessage_CALLBACK NULL #define meshtastic_InterdeviceMessage_DEFAULT NULL diff --git a/src/serial_proto.cpp b/src/serial_proto.cpp index e92b1cb..e1d0c83 100644 --- a/src/serial_proto.cpp +++ b/src/serial_proto.cpp @@ -55,6 +55,25 @@ bool mt_send(const char *buf, size_t len) { return false; } +// The requester fails fast on a nack instead of burning its timeout. +// id 0 when the request could not even be decoded. +static bool mt_send_nack(uint32_t id) { + meshtastic_InterdeviceMessage &response = tx_response; + memset(&response, 0, sizeof(response)); + response.id = id; + response.which_data = meshtastic_InterdeviceMessage_nack_tag; + response.data.nack = true; + return mt_send_uplink(response); +} + +// The core reports any failed non-empty write as a generic error; a +// zero-length probe (bit-banged, always classifies) tells an absent +// device apart from a data NACK or bus fault +static bool i2c_device_present(uint8_t addr) { + Wire.beginTransmission(addr); + return Wire.endTransmission() == 0; +} + // Parse a packet that came in, and handle it. Return true if we were able to // parse it. bool mt_handle_packet(size_t payload_len) { @@ -74,6 +93,7 @@ bool mt_handle_packet(size_t payload_len) { if (!status) { Serial.println("Decoding failed"); + mt_send_nack(0); return false; } @@ -116,7 +136,9 @@ bool mt_handle_packet(size_t payload_len) { result->status = meshtastic_I2CResult_Status_NACK_DATA; break; default: - result->status = meshtastic_I2CResult_Status_ERROR; + result->status = i2c_device_present((uint8_t)transaction.address) + ? meshtastic_I2CResult_Status_NACK_DATA + : meshtastic_I2CResult_Status_NACK_ADDRESS; } } @@ -127,7 +149,10 @@ bool mt_handle_packet(size_t payload_len) { want = sizeof(result->read_data.bytes); size_t got = Wire.requestFrom((uint8_t)transaction.address, want); if (got == 0) - result->status = meshtastic_I2CResult_Status_NACK_ADDRESS; + // a failed read can also be a bus timeout with the device present + result->status = i2c_device_present((uint8_t)transaction.address) + ? meshtastic_I2CResult_Status_ERROR + : meshtastic_I2CResult_Status_NACK_ADDRESS; while (Wire.available() && result->read_data.size < got) result->read_data.bytes[result->read_data.size++] = Wire.read(); } @@ -361,9 +386,15 @@ bool mt_handle_packet(size_t payload_len) { return mt_send_uplink(response); } + case meshtastic_InterdeviceMessage_pong_tag: + case meshtastic_InterdeviceMessage_nack_tag: + // never react to these, a nacked nack would ping-pong forever + return true; + default: // the other messages really only flow downstream Serial.println("Got a message of unexpected type"); + mt_send_nack(message.id); return false; } } diff --git a/src/serial_proto.h b/src/serial_proto.h index ae1ac19..0cb6bca 100644 --- a/src/serial_proto.h +++ b/src/serial_proto.h @@ -1,8 +1,5 @@ #pragma once -#ifndef SERIAL_PROTO_H -#define SERIAL_PROTO_H - #include "meshtastic/interdevice.pb.h" #include #include @@ -15,9 +12,6 @@ // The header is the magic number plus a 16-bit payload-length field #define MT_HEADER_SIZE 4 -// Wait this many msec if there's nothing new on the channel -#define NO_NEWS_PAUSE 25 - bool mt_send_uplink(const meshtastic_InterdeviceMessage &message); void mt_set_nmea_callback(void (*callback)(char *nmea)); void mt_loop(); @@ -32,6 +26,4 @@ void sd_mark_dead(void); void sd_note_write(void); void sd_get_info(meshtastic_SdCardInfo *out); // bumped on every unmount, open file handles do not survive a remount -extern volatile uint32_t sd_generation; - -#endif \ No newline at end of file +extern volatile uint32_t sd_generation; \ No newline at end of file From cb0b9ddaeb5c1a0c159e23ca54f3d451638c4038 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thomas=20G=C3=B6ttgens?= Date: Mon, 13 Jul 2026 09:28:37 +0200 Subject: [PATCH 10/21] Version handshake: answer ping with the protocol version we speak --- protobufs | 2 +- src/meshtastic/interdevice.pb.cpp | 2 ++ src/meshtastic/interdevice.pb.h | 31 +++++++++++++++++++++++++------ src/serial_proto.cpp | 13 +++++++++++-- 4 files changed, 39 insertions(+), 9 deletions(-) diff --git a/protobufs b/protobufs index 1cd8f0a..0f0ce00 160000 --- a/protobufs +++ b/protobufs @@ -1 +1 @@ -Subproject commit 1cd8f0a1dcef9edf466cae03f7077ce06118570d +Subproject commit 0f0ce002f1f5c4e23ad6f2686f54aad65fa4c75a diff --git a/src/meshtastic/interdevice.pb.cpp b/src/meshtastic/interdevice.pb.cpp index eb8f528..6c49cf3 100644 --- a/src/meshtastic/interdevice.pb.cpp +++ b/src/meshtastic/interdevice.pb.cpp @@ -33,3 +33,5 @@ PB_BIND(meshtastic_InterdeviceMessage, meshtastic_InterdeviceMessage, 4) + + diff --git a/src/meshtastic/interdevice.pb.h b/src/meshtastic/interdevice.pb.h index 3f2bb3e..4348e1f 100644 --- a/src/meshtastic/interdevice.pb.h +++ b/src/meshtastic/interdevice.pb.h @@ -10,6 +10,18 @@ #endif /* Enum definitions */ +/* Version of the interdevice protocol spoken on the link. Both sides send + theirs in the ping/pong handshake; a peer reporting a different one runs + firmware that does not match and is not talked to. + + On a change that breaks the other side (renumbered fields, changed + semantics, removed messages), raise the value of CURRENT. Do not add + another entry: this enum carries a single constant, not a history. */ +typedef enum _meshtastic_InterdeviceVersion { + meshtastic_InterdeviceVersion_INTERDEVICE_VERSION_UNSPECIFIED = 0, + meshtastic_InterdeviceVersion_INTERDEVICE_VERSION_CURRENT = 1 +} meshtastic_InterdeviceVersion; + /* Defines the supported file operations */ typedef enum _meshtastic_FileOperation { meshtastic_FileOperation_GET = 0, @@ -126,10 +138,13 @@ typedef struct _meshtastic_InterdeviceMessage { meshtastic_DirectoryListing directory_listing; bool get_sd_info; /* Request: SD card statistics */ meshtastic_SdCardInfo sd_info; /* Response */ - /* Link liveness probe. The receiver answers ping with pong, echoing the - id. Touches no peripherals, so it works with nothing attached. */ - bool ping; - bool pong; + /* Link liveness probe and version handshake. The receiver answers ping + with pong, echoing the id. Touches no peripherals, so it works with + nothing attached. Both carry the sender's InterdeviceVersion; a peer + that answers with a different one speaks another protocol and must + not be used. */ + uint32_t ping; + uint32_t pong; /* Response: the request could not be decoded or is of an unhandled type, so the requester fails fast instead of burning its timeout. Echoes the id when known, 0 when the frame was undecodable. Never @@ -147,6 +162,10 @@ extern "C" { #endif /* Helper constants for enums */ +#define _meshtastic_InterdeviceVersion_MIN meshtastic_InterdeviceVersion_INTERDEVICE_VERSION_UNSPECIFIED +#define _meshtastic_InterdeviceVersion_MAX meshtastic_InterdeviceVersion_INTERDEVICE_VERSION_CURRENT +#define _meshtastic_InterdeviceVersion_ARRAYSIZE ((meshtastic_InterdeviceVersion)(meshtastic_InterdeviceVersion_INTERDEVICE_VERSION_CURRENT+1)) + #define _meshtastic_FileOperation_MIN meshtastic_FileOperation_GET #define _meshtastic_FileOperation_MAX meshtastic_FileOperation_DELETE #define _meshtastic_FileOperation_ARRAYSIZE ((meshtastic_FileOperation)(meshtastic_FileOperation_DELETE+1)) @@ -288,8 +307,8 @@ X(a, STATIC, ONEOF, MESSAGE, (data,file_transfer,data.file_transfer), 7) X(a, STATIC, ONEOF, MESSAGE, (data,directory_listing,data.directory_listing), 8) \ X(a, STATIC, ONEOF, BOOL, (data,get_sd_info,data.get_sd_info), 9) \ X(a, STATIC, ONEOF, MESSAGE, (data,sd_info,data.sd_info), 10) \ -X(a, STATIC, ONEOF, BOOL, (data,ping,data.ping), 11) \ -X(a, STATIC, ONEOF, BOOL, (data,pong,data.pong), 12) \ +X(a, STATIC, ONEOF, UINT32, (data,ping,data.ping), 11) \ +X(a, STATIC, ONEOF, UINT32, (data,pong,data.pong), 12) \ X(a, STATIC, ONEOF, BOOL, (data,nack,data.nack), 13) \ X(a, STATIC, SINGULAR, UINT32, id, 15) #define meshtastic_InterdeviceMessage_CALLBACK NULL diff --git a/src/serial_proto.cpp b/src/serial_proto.cpp index e1d0c83..b602c15 100644 --- a/src/serial_proto.cpp +++ b/src/serial_proto.cpp @@ -353,12 +353,21 @@ bool mt_handle_packet(size_t payload_len) { } case meshtastic_InterdeviceMessage_ping_tag: { - // liveness probe, must work with nothing attached to this MCU + // Liveness probe and version handshake, must work with nothing + // attached to this MCU. Always answer with the version we speak: the + // main firmware compares it and shuts the bridge down on a mismatch. + if (message.data.ping != + meshtastic_InterdeviceVersion_INTERDEVICE_VERSION_CURRENT) + Serial.printf( + "Peer speaks interdevice protocol v%u, we speak v%u\r\n", + (unsigned)message.data.ping, + (unsigned)meshtastic_InterdeviceVersion_INTERDEVICE_VERSION_CURRENT); meshtastic_InterdeviceMessage &response = tx_response; memset(&response, 0, sizeof(response)); response.id = message.id; // correlate with the request response.which_data = meshtastic_InterdeviceMessage_pong_tag; - response.data.pong = true; + response.data.pong = + meshtastic_InterdeviceVersion_INTERDEVICE_VERSION_CURRENT; return mt_send_uplink(response); } From 1ca576c104121eca71364ce5c94a70d92e0895b0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thomas=20G=C3=B6ttgens?= Date: Mon, 13 Jul 2026 09:44:25 +0200 Subject: [PATCH 11/21] Regen protos: interdevice protocol version 2 --- protobufs | 2 +- src/meshtastic/interdevice.pb.h | 5 ++++- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/protobufs b/protobufs index 0f0ce00..111eb5a 160000 --- a/protobufs +++ b/protobufs @@ -1 +1 @@ -Subproject commit 0f0ce002f1f5c4e23ad6f2686f54aad65fa4c75a +Subproject commit 111eb5a3cbbce6e89cd4c7960431428008e913a1 diff --git a/src/meshtastic/interdevice.pb.h b/src/meshtastic/interdevice.pb.h index 4348e1f..f5a67d0 100644 --- a/src/meshtastic/interdevice.pb.h +++ b/src/meshtastic/interdevice.pb.h @@ -19,7 +19,10 @@ another entry: this enum carries a single constant, not a history. */ typedef enum _meshtastic_InterdeviceVersion { meshtastic_InterdeviceVersion_INTERDEVICE_VERSION_UNSPECIFIED = 0, - meshtastic_InterdeviceVersion_INTERDEVICE_VERSION_CURRENT = 1 + /* Never use 1: ping/pong were bools before the handshake existed, and a + bool true is the same varint on the wire as the number 1, so firmware + predating the handshake would pass it. */ + meshtastic_InterdeviceVersion_INTERDEVICE_VERSION_CURRENT = 2 } meshtastic_InterdeviceVersion; /* Defines the supported file operations */ From 109ff26681d16ad83c5262e1985b14e218190633 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thomas=20G=C3=B6ttgens?= Date: Mon, 13 Jul 2026 09:58:36 +0200 Subject: [PATCH 12/21] Report a status per file operation, keep maintenance off the request path File and directory responses now carry a FileStatus, so the requester can tell a transient BUSY (card maintenance) from a definitive failure and a PUT offset conflict from a write error. DELETE is idempotent. sd_mark_dead only fires on genuine card failures: a seek past the end, a full card or a bad path no longer unmount the card (a full card used to thrash between remounts), and cached handles are closed while the mutex is still held so they cannot be flushed onto a freshly mounted volume. The free space scan runs only after core0 has left the card alone for a few seconds, so map tiles are not starved by it, and its result is sanity checked (SDFS.info reports success even when the FAT walk failed). sd_claim reports an empty slot as NOCARD instead of BUSY. The directory walk feeds the watchdog, and the version mismatch is logged once per boot. --- protobufs | 2 +- src/main.cpp | 53 ++++++++++++++--- src/meshtastic/interdevice.pb.cpp | 2 + src/meshtastic/interdevice.pb.h | 49 ++++++++++++---- src/serial_proto.cpp | 98 +++++++++++++++++++------------ src/serial_proto.h | 3 + 6 files changed, 149 insertions(+), 58 deletions(-) diff --git a/protobufs b/protobufs index 111eb5a..ef1f99d 160000 --- a/protobufs +++ b/protobufs @@ -1 +1 @@ -Subproject commit 111eb5a3cbbce6e89cd4c7960431428008e913a1 +Subproject commit ef1f99d4da67c56af29035db6792c6a8f67e38b1 diff --git a/src/main.cpp b/src/main.cpp index 0394b39..3f7f442 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -43,6 +43,10 @@ static volatile bool sd_recheck = false; // survive a remount volatile uint32_t sd_generation = 0; +// last time core0 touched the card; core1 keeps its multi-second +// maintenance off the card while requests are still flowing +static volatile uint32_t sd_last_access = 0; + // used/free statistics from the core1 scan static volatile bool sd_stats_pending = false; static bool sd_stats_valid = false; @@ -57,20 +61,35 @@ static meshtastic_SdCardInfo sd_info_cache; // is mounting or scanning right now; the caller reports that instead of // waiting for it. int sd_claim(void) { + // checked before the try-lock: core1 holds the mutex for the whole + // (2 second) mount attempt on an empty slot, and answering BUSY there + // would make an empty slot look like a card that is merely busy + if (!sd_mounted) + return SD_CLAIM_NOCARD; if (!mutex_try_enter(&sd_mutex, NULL)) return SD_CLAIM_BUSY; if (!sd_mounted) { mutex_exit(&sd_mutex); return SD_CLAIM_NOCARD; } + sd_last_access = millis(); // keeps core1 off the card while requests flow return SD_CLAIM_OK; } -void sd_release(void) { mutex_exit(&sd_mutex); } +void sd_release(void) { + sd_last_access = millis(); + mutex_exit(&sd_mutex); +} // A card-level failure was observed on a mounted card: assume it was -// pulled and have core1 recover with a clean remount -void sd_mark_dead(void) { sd_recheck = true; } +// pulled and have core1 recover with a clean remount. Only for genuine +// card failures: a full card or a bad path must not unmount anything. +// The caller still holds the mutex, so drop the cached handle here rather +// than after the remount, when it would belong to the old volume. +void sd_mark_dead(void) { + sd_close_cached_files(); + sd_recheck = true; +} // A write changed the filesystem; refresh the stats (rate-limited on core1) void sd_note_write(void) { sd_stats_pending = true; } @@ -176,22 +195,31 @@ void loop1() { last_mount = millis(); } } else if (sd_stats_pending) { - // initial scan right after mount, write-triggered refreshes at most - // once a minute (each scan blocks file requests for its duration) + // The free space scan walks the whole FAT (SdFat keeps no cached + // cluster count) and holds the card for seconds, so only start it when + // core0 has not touched the card for a while: serving map tiles beats + // knowing the free space. Write-triggered refreshes wait a minute on + // top of that. uint32_t interval = sd_stats_valid ? 60000 : 0; - if (last_scan == 0 || millis() - last_scan >= interval) { + if ((last_scan == 0 || millis() - last_scan >= interval) && + millis() - sd_last_access >= 3000) { FSInfo info; mutex_enter_blocking(&sd_mutex); - bool ok = SDFS.info(info); + SDFS.info(info); + // info() reports success even when the FAT walk failed, and then + // returns a used count that wraps past the card size + bool ok = info.totalBytes > 0 && info.usedBytes <= info.totalBytes; if (ok) { sd_used_bytes = info.usedBytes; sd_free_bytes = info.totalBytes - info.usedBytes; sd_stats_valid = true; sd_stats_pending = false; + } else { + sd_close_cached_files(); } mutex_exit(&sd_mutex); if (!ok) - sd_recheck = true; // info failing on a mounted card = card gone + sd_recheck = true; // a FAT walk only fails when the card is gone last_scan = millis(); } } @@ -224,7 +252,14 @@ void onNmeaReceived(char *nmea) { } static void gps_tx_drain(void) { - while (gps_tx_head != gps_tx_tail && softSerial2.availableForWrite() > 0) { + // SerialPIO::availableForWrite() returns 8 minus the fill level of a + // 4 deep TX FIFO, so it never reaches 0 and cannot be used to detect a + // full FIFO. write() blocks once the FIFO is full, so hand it a few + // bytes per pass and let the next pass continue: at 9600 baud a full + // ring would otherwise block the loop for two seconds. + const int fifo_depth = 4; + int free_slots = softSerial2.availableForWrite() - (8 - fifo_depth); + while (gps_tx_head != gps_tx_tail && free_slots-- > 0) { softSerial2.write(gps_tx_ring[gps_tx_tail]); gps_tx_tail = (gps_tx_tail + 1) % sizeof(gps_tx_ring); } diff --git a/src/meshtastic/interdevice.pb.cpp b/src/meshtastic/interdevice.pb.cpp index 6c49cf3..2ab1340 100644 --- a/src/meshtastic/interdevice.pb.cpp +++ b/src/meshtastic/interdevice.pb.cpp @@ -35,3 +35,5 @@ PB_BIND(meshtastic_InterdeviceMessage, meshtastic_InterdeviceMessage, 4) + + diff --git a/src/meshtastic/interdevice.pb.h b/src/meshtastic/interdevice.pb.h index f5a67d0..e927dc5 100644 --- a/src/meshtastic/interdevice.pb.h +++ b/src/meshtastic/interdevice.pb.h @@ -33,6 +33,25 @@ typedef enum _meshtastic_FileOperation { meshtastic_FileOperation_DELETE = 3 } meshtastic_FileOperation; +/* Outcome of a file or directory operation. The requester must be able to + tell a transient condition from a definitive one: BUSY is worth another + try, NOT_FOUND is not. */ +typedef enum _meshtastic_FileStatus { + meshtastic_FileStatus_FILE_UNSPECIFIED = 0, + meshtastic_FileStatus_FILE_OK = 1, + /* Retry later: the co-processor is doing card maintenance (mount, + free space scan) and cannot serve the request right now */ + meshtastic_FileStatus_FILE_BUSY = 2, + meshtastic_FileStatus_FILE_NO_CARD = 3, + meshtastic_FileStatus_FILE_NOT_FOUND = 4, + /* PUT only: offset did not match the current end of the file. file_size + carries the size the file actually has, so the writer can resync (or + recognize its own chunk as already written after a lost response). */ + meshtastic_FileStatus_FILE_OFFSET_CONFLICT = 5, + meshtastic_FileStatus_FILE_IO_ERROR = 6, + meshtastic_FileStatus_FILE_NOT_A_FILE = 7 /* path is a directory (GET) or not one (listing) */ +} meshtastic_FileStatus; + typedef enum _meshtastic_SdCardInfo_CardType { meshtastic_SdCardInfo_CardType_NONE = 0, meshtastic_SdCardInfo_CardType_MMC = 1, @@ -66,8 +85,8 @@ typedef struct _meshtastic_FileTransfer { meshtastic_FileOperation operation; /* File operation (GET, POST, PUT, DELETE) */ char filepath[256]; /* Path of the file on the SD card */ meshtastic_FileTransfer_filedata_t filedata; /* Chunk content (POST/PUT request, GET response) */ - bool success; /* Response: Was the operation successful? */ - char message[255]; /* Response: Status message */ + meshtastic_FileStatus status; /* Response: outcome of the operation */ + char message[255]; /* Response: human readable detail, may be empty */ uint64_t offset; /* Byte offset of this chunk within the file (ranged GET/PUT) */ /* GET request: number of bytes to read, 0 = max chunk size. A response carries at most the filedata max_size (see interdevice.options) per @@ -86,8 +105,8 @@ typedef struct _meshtastic_DirectoryListing { offset and total_count. */ pb_size_t filenames_count; char filenames[16][256]; - bool success; /* Response: Was the operation successful? */ - char message[255]; /* Response: Status message */ + meshtastic_FileStatus status; /* Response: outcome of the operation */ + char message[255]; /* Response: human readable detail, may be empty */ uint32_t offset; /* Request: skip this many entries (paging) */ uint32_t total_count; /* Response: total number of entries in the directory */ } meshtastic_DirectoryListing; @@ -173,6 +192,10 @@ extern "C" { #define _meshtastic_FileOperation_MAX meshtastic_FileOperation_DELETE #define _meshtastic_FileOperation_ARRAYSIZE ((meshtastic_FileOperation)(meshtastic_FileOperation_DELETE+1)) +#define _meshtastic_FileStatus_MIN meshtastic_FileStatus_FILE_UNSPECIFIED +#define _meshtastic_FileStatus_MAX meshtastic_FileStatus_FILE_NOT_A_FILE +#define _meshtastic_FileStatus_ARRAYSIZE ((meshtastic_FileStatus)(meshtastic_FileStatus_FILE_NOT_A_FILE+1)) + #define _meshtastic_SdCardInfo_CardType_MIN meshtastic_SdCardInfo_CardType_NONE #define _meshtastic_SdCardInfo_CardType_MAX meshtastic_SdCardInfo_CardType_UNKNOWN_CARD #define _meshtastic_SdCardInfo_CardType_ARRAYSIZE ((meshtastic_SdCardInfo_CardType)(meshtastic_SdCardInfo_CardType_UNKNOWN_CARD+1)) @@ -186,7 +209,9 @@ extern "C" { #define _meshtastic_I2CResult_Status_ARRAYSIZE ((meshtastic_I2CResult_Status)(meshtastic_I2CResult_Status_ERROR+1)) #define meshtastic_FileTransfer_operation_ENUMTYPE meshtastic_FileOperation +#define meshtastic_FileTransfer_status_ENUMTYPE meshtastic_FileStatus +#define meshtastic_DirectoryListing_status_ENUMTYPE meshtastic_FileStatus #define meshtastic_SdCardInfo_card_type_ENUMTYPE meshtastic_SdCardInfo_CardType @@ -197,14 +222,14 @@ extern "C" { /* Initializer values for message structs */ -#define meshtastic_FileTransfer_init_default {_meshtastic_FileOperation_MIN, "", {0, {0}}, 0, "", 0, 0, 0} -#define meshtastic_DirectoryListing_init_default {"", 0, {"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", ""}, 0, "", 0, 0} +#define meshtastic_FileTransfer_init_default {_meshtastic_FileOperation_MIN, "", {0, {0}}, _meshtastic_FileStatus_MIN, "", 0, 0, 0} +#define meshtastic_DirectoryListing_init_default {"", 0, {"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", ""}, _meshtastic_FileStatus_MIN, "", 0, 0} #define meshtastic_I2CTransaction_init_default {0, {0, {0}}, 0} #define meshtastic_SdCardInfo_init_default {0, _meshtastic_SdCardInfo_CardType_MIN, _meshtastic_SdCardInfo_FatType_MIN, 0, 0, 0, 0} #define meshtastic_I2CResult_init_default {_meshtastic_I2CResult_Status_MIN, {0, {0}}} #define meshtastic_InterdeviceMessage_init_default {0, {""}, 0} -#define meshtastic_FileTransfer_init_zero {_meshtastic_FileOperation_MIN, "", {0, {0}}, 0, "", 0, 0, 0} -#define meshtastic_DirectoryListing_init_zero {"", 0, {"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", ""}, 0, "", 0, 0} +#define meshtastic_FileTransfer_init_zero {_meshtastic_FileOperation_MIN, "", {0, {0}}, _meshtastic_FileStatus_MIN, "", 0, 0, 0} +#define meshtastic_DirectoryListing_init_zero {"", 0, {"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", ""}, _meshtastic_FileStatus_MIN, "", 0, 0} #define meshtastic_I2CTransaction_init_zero {0, {0, {0}}, 0} #define meshtastic_SdCardInfo_init_zero {0, _meshtastic_SdCardInfo_CardType_MIN, _meshtastic_SdCardInfo_FatType_MIN, 0, 0, 0, 0} #define meshtastic_I2CResult_init_zero {_meshtastic_I2CResult_Status_MIN, {0, {0}}} @@ -214,14 +239,14 @@ extern "C" { #define meshtastic_FileTransfer_operation_tag 1 #define meshtastic_FileTransfer_filepath_tag 2 #define meshtastic_FileTransfer_filedata_tag 3 -#define meshtastic_FileTransfer_success_tag 4 +#define meshtastic_FileTransfer_status_tag 4 #define meshtastic_FileTransfer_message_tag 5 #define meshtastic_FileTransfer_offset_tag 6 #define meshtastic_FileTransfer_length_tag 7 #define meshtastic_FileTransfer_file_size_tag 8 #define meshtastic_DirectoryListing_directory_tag 1 #define meshtastic_DirectoryListing_filenames_tag 2 -#define meshtastic_DirectoryListing_success_tag 3 +#define meshtastic_DirectoryListing_status_tag 3 #define meshtastic_DirectoryListing_message_tag 4 #define meshtastic_DirectoryListing_offset_tag 5 #define meshtastic_DirectoryListing_total_count_tag 6 @@ -257,7 +282,7 @@ extern "C" { X(a, STATIC, SINGULAR, UENUM, operation, 1) \ X(a, STATIC, SINGULAR, STRING, filepath, 2) \ X(a, STATIC, SINGULAR, BYTES, filedata, 3) \ -X(a, STATIC, SINGULAR, BOOL, success, 4) \ +X(a, STATIC, SINGULAR, UENUM, status, 4) \ X(a, STATIC, SINGULAR, STRING, message, 5) \ X(a, STATIC, SINGULAR, UINT64, offset, 6) \ X(a, STATIC, SINGULAR, UINT32, length, 7) \ @@ -268,7 +293,7 @@ X(a, STATIC, SINGULAR, UINT64, file_size, 8) #define meshtastic_DirectoryListing_FIELDLIST(X, a) \ X(a, STATIC, SINGULAR, STRING, directory, 1) \ X(a, STATIC, REPEATED, STRING, filenames, 2) \ -X(a, STATIC, SINGULAR, BOOL, success, 3) \ +X(a, STATIC, SINGULAR, UENUM, status, 3) \ X(a, STATIC, SINGULAR, STRING, message, 4) \ X(a, STATIC, SINGULAR, UINT32, offset, 5) \ X(a, STATIC, SINGULAR, UINT32, total_count, 6) diff --git a/src/serial_proto.cpp b/src/serial_proto.cpp index b602c15..553533a 100644 --- a/src/serial_proto.cpp +++ b/src/serial_proto.cpp @@ -19,6 +19,14 @@ static void invalidate_read_cache() { read_cache_path[0] = '\0'; } +// Called by core1 (holding the mutex, before it unmounts) so the handle is +// closed against the volume it belongs to. Closing it after the remount +// would flush the old volume's sector cache onto the new card. +void sd_close_cached_files(void) { + invalidate_read_cache(); + count_cache_path[0] = '\0'; +} + // call before any card access: file handles do not survive a remount static void check_sd_generation() { static uint32_t seen_generation = 0; @@ -114,7 +122,7 @@ bool mt_handle_packet(size_t payload_len) { case meshtastic_InterdeviceMessage_i2c_transaction_tag: { // Execute a tunneled I2C transaction: an optional write followed by an // optional read with repeated start - meshtastic_I2CTransaction transaction = message.data.i2c_transaction; + meshtastic_I2CTransaction &transaction = message.data.i2c_transaction; meshtastic_InterdeviceMessage &response = tx_response; memset(&response, 0, sizeof(response)); response.id = message.id; // correlate with the request @@ -172,12 +180,11 @@ bool mt_handle_packet(size_t payload_len) { out->operation = request.operation; strncpy(out->filepath, request.filepath, sizeof(out->filepath) - 1); out->offset = request.offset; - out->success = false; int claim = sd_claim(); if (claim != SD_CLAIM_OK) { - strncpy(out->message, claim == SD_CLAIM_BUSY ? "SD busy" : "no SD card", - sizeof(out->message) - 1); + out->status = claim == SD_CLAIM_BUSY ? meshtastic_FileStatus_FILE_BUSY + : meshtastic_FileStatus_FILE_NO_CARD; return mt_send_uplink(response); } check_sd_generation(); @@ -188,13 +195,13 @@ bool mt_handle_packet(size_t payload_len) { invalidate_read_cache(); read_cache = SD.open(request.filepath, FILE_READ); if (!read_cache) { - strncpy(out->message, "open failed", sizeof(out->message) - 1); + out->status = meshtastic_FileStatus_FILE_NOT_FOUND; break; } if (read_cache.isDirectory()) { // reading one returns raw directory entries, not file content invalidate_read_cache(); - strncpy(out->message, "not a file", sizeof(out->message) - 1); + out->status = meshtastic_FileStatus_FILE_NOT_A_FILE; break; } strncpy(read_cache_path, request.filepath, sizeof(read_cache_path) - 1); @@ -203,24 +210,26 @@ bool mt_handle_packet(size_t payload_len) { size_t want = request.length; if (want == 0 || want > sizeof(out->filedata.bytes)) want = sizeof(out->filedata.bytes); - if (request.offset > 0xFFFFFFFFull) { - // the FS layer seeks with 32 bit positions - strncpy(out->message, "offset too large", sizeof(out->message) - 1); + // the FS layer seeks with 32 bit positions, and seeking past the end + // is a request error, not a card failure + if (request.offset > 0xFFFFFFFFull || + request.offset > (uint64_t)read_cache.size()) { + out->status = meshtastic_FileStatus_FILE_OFFSET_CONFLICT; break; } if (!read_cache.seek((uint32_t)request.offset)) { - strncpy(out->message, "seek failed", sizeof(out->message) - 1); - sd_mark_dead(); // seek on an open handle only fails at card level + out->status = meshtastic_FileStatus_FILE_IO_ERROR; + sd_mark_dead(); // in range, so the card itself failed break; } int got = read_cache.read(out->filedata.bytes, want); if (got < 0) { - strncpy(out->message, "read failed", sizeof(out->message) - 1); + out->status = meshtastic_FileStatus_FILE_IO_ERROR; sd_mark_dead(); break; } out->filedata.size = got; - out->success = true; + out->status = meshtastic_FileStatus_FILE_OK; break; } @@ -232,29 +241,29 @@ bool mt_handle_packet(size_t payload_len) { SD.remove(request.filepath); File f = SD.open(request.filepath, FILE_WRITE); // append mode, creates if (!f) { + // a full card or a bad path fails here just like a pulled card + // does, so do not unmount on it + out->status = meshtastic_FileStatus_FILE_IO_ERROR; strncpy(out->message, "open failed", sizeof(out->message) - 1); - // creating a file on a healthy card does not fail; assume the - // card was pulled and let core1 verify with a remount - sd_mark_dead(); break; } if (f.size() != request.offset) { - snprintf(out->message, sizeof(out->message), - "bad offset %u, file size %u", (unsigned)request.offset, - (unsigned)f.size()); - out->file_size = f.size(); // lets the client resync its offset + out->status = meshtastic_FileStatus_FILE_OFFSET_CONFLICT; + out->file_size = f.size(); // lets the writer resync its offset f.close(); break; } size_t wrote = f.write(request.filedata.bytes, request.filedata.size); out->file_size = f.size(); f.close(); - out->success = (wrote == request.filedata.size); - if (!out->success) { - strncpy(out->message, "write failed", sizeof(out->message) - 1); - sd_mark_dead(); - } else { + if (wrote == request.filedata.size) { + out->status = meshtastic_FileStatus_FILE_OK; sd_note_write(); + } else { + // short write: card full, or the card went away. Do not unmount, + // a full card would thrash between remounts + out->status = meshtastic_FileStatus_FILE_IO_ERROR; + strncpy(out->message, "write failed", sizeof(out->message) - 1); } count_cache_path[0] = '\0'; break; @@ -262,15 +271,20 @@ bool mt_handle_packet(size_t payload_len) { case meshtastic_FileOperation_DELETE: invalidate_read_cache(); - out->success = SD.remove(request.filepath); - if (!out->success) - strncpy(out->message, "remove failed", sizeof(out->message) - 1); - else + // idempotent: a file that is already gone is the requested outcome, + // so a retried delete after a lost response reports OK + if (SD.remove(request.filepath) || !SD.exists(request.filepath)) { + out->status = meshtastic_FileStatus_FILE_OK; sd_note_write(); + } else { + out->status = meshtastic_FileStatus_FILE_IO_ERROR; + strncpy(out->message, "remove failed", sizeof(out->message) - 1); + } count_cache_path[0] = '\0'; break; default: + out->status = meshtastic_FileStatus_FILE_IO_ERROR; strncpy(out->message, "unknown operation", sizeof(out->message) - 1); } sd_release(); @@ -287,19 +301,20 @@ bool mt_handle_packet(size_t payload_len) { meshtastic_DirectoryListing *out = &response.data.directory_listing; strncpy(out->directory, request.directory, sizeof(out->directory) - 1); out->offset = request.offset; - out->success = false; int claim = sd_claim(); if (claim != SD_CLAIM_OK) { - strncpy(out->message, claim == SD_CLAIM_BUSY ? "SD busy" : "no SD card", - sizeof(out->message) - 1); + out->status = claim == SD_CLAIM_BUSY ? meshtastic_FileStatus_FILE_BUSY + : meshtastic_FileStatus_FILE_NO_CARD; return mt_send_uplink(response); } check_sd_generation(); File dir = SD.open(request.directory); - if (!dir || !dir.isDirectory()) { - strncpy(out->message, "not a directory", sizeof(out->message) - 1); + if (!dir) { + out->status = meshtastic_FileStatus_FILE_NOT_FOUND; + } else if (!dir.isDirectory()) { + out->status = meshtastic_FileStatus_FILE_NOT_A_FILE; } else { const size_t max_names = sizeof(out->filenames) / sizeof(out->filenames[0]); @@ -323,6 +338,10 @@ bool mt_handle_packet(size_t payload_len) { entry.close(); if (have_total && out->filenames_count >= max_names) break; // page full, the total is already known + // the core re-opens every entry by path, so a directory with + // thousands of tiles takes a while: keep the watchdog fed + if ((index & 0x3f) == 0) + rp2040.wdt_reset(); } if (have_total) { out->total_count = count_cache_total; @@ -332,7 +351,7 @@ bool mt_handle_packet(size_t payload_len) { sizeof(count_cache_path) - 1); count_cache_total = index; } - out->success = true; + out->status = meshtastic_FileStatus_FILE_OK; } if (dir) dir.close(); @@ -356,12 +375,19 @@ bool mt_handle_packet(size_t payload_len) { // Liveness probe and version handshake, must work with nothing // attached to this MCU. Always answer with the version we speak: the // main firmware compares it and shuts the bridge down on a mismatch. + static bool mismatch_logged = false; if (message.data.ping != - meshtastic_InterdeviceVersion_INTERDEVICE_VERSION_CURRENT) + meshtastic_InterdeviceVersion_INTERDEVICE_VERSION_CURRENT && + !mismatch_logged) { + // Once per boot: the peer probes repeatedly while the link is down, + // and a USB CDC write blocks for up to a second when a host is + // attached but not reading. + mismatch_logged = true; Serial.printf( "Peer speaks interdevice protocol v%u, we speak v%u\r\n", (unsigned)message.data.ping, (unsigned)meshtastic_InterdeviceVersion_INTERDEVICE_VERSION_CURRENT); + } meshtastic_InterdeviceMessage &response = tx_response; memset(&response, 0, sizeof(response)); response.id = message.id; // correlate with the request diff --git a/src/serial_proto.h b/src/serial_proto.h index 0cb6bca..b86ecbb 100644 --- a/src/serial_proto.h +++ b/src/serial_proto.h @@ -23,6 +23,9 @@ enum { SD_CLAIM_OK = 0, SD_CLAIM_NOCARD, SD_CLAIM_BUSY }; int sd_claim(void); void sd_release(void); void sd_mark_dead(void); +// drops cached file handles; called with the card mutex held, right before +// core1 unmounts (implemented next to the caches, in serial_proto.cpp) +void sd_close_cached_files(void); void sd_note_write(void); void sd_get_info(meshtastic_SdCardInfo *out); // bumped on every unmount, open file handles do not survive a remount From cf1c3b769c867ea6e412befa76610e3f44c53212 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thomas=20G=C3=B6ttgens?= Date: Mon, 13 Jul 2026 10:19:15 +0200 Subject: [PATCH 13/21] Do not report a card as absent while it is being mounted, bound the scan wait sd_claim answered NO_CARD for the whole two second mount attempt, and the requester treats that as definitive: the first tile requests after boot made the UI conclude there is no card. A mount in progress now answers BUSY, which the requester retries. The free space scan waited for core0 to leave the card alone, which a steady stream of tile reads never does; it now takes the card anyway after 30 seconds rather than never producing statistics. --- src/main.cpp | 32 +++++++++++++++++++++++++------- 1 file changed, 25 insertions(+), 7 deletions(-) diff --git a/src/main.cpp b/src/main.cpp index 3f7f442..db17c28 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -38,6 +38,9 @@ const int SD_CS_PIN = 13; // stalling the link, so GPS forwarding and other requests keep flowing. auto_init_mutex(sd_mutex); static volatile bool sd_mounted = false; +// a mount attempt is running (or has not been tried yet): the card is not +// usable right now, but whether one is present is not decided either +static volatile bool sd_mounting = true; static volatile bool sd_recheck = false; // bumped on every unmount so serial_proto drops file handles that cannot // survive a remount @@ -49,6 +52,7 @@ static volatile uint32_t sd_last_access = 0; // used/free statistics from the core1 scan static volatile bool sd_stats_pending = false; +static volatile uint32_t stats_pending_since = 0; static bool sd_stats_valid = false; static uint64_t sd_used_bytes = 0; static uint64_t sd_free_bytes = 0; @@ -61,16 +65,18 @@ static meshtastic_SdCardInfo sd_info_cache; // is mounting or scanning right now; the caller reports that instead of // waiting for it. int sd_claim(void) { - // checked before the try-lock: core1 holds the mutex for the whole - // (2 second) mount attempt on an empty slot, and answering BUSY there - // would make an empty slot look like a card that is merely busy + // Checked before the try-lock: core1 holds the mutex for the whole + // (2 second) mount attempt, and answering BUSY for an empty slot would + // make it look like a card that is merely busy. While a mount attempt is + // actually running, though, the outcome is not known yet: BUSY tells the + // requester to come back rather than to give up on the card. if (!sd_mounted) - return SD_CLAIM_NOCARD; + return sd_mounting ? SD_CLAIM_BUSY : SD_CLAIM_NOCARD; if (!mutex_try_enter(&sd_mutex, NULL)) return SD_CLAIM_BUSY; if (!sd_mounted) { mutex_exit(&sd_mutex); - return SD_CLAIM_NOCARD; + return sd_mounting ? SD_CLAIM_BUSY : SD_CLAIM_NOCARD; } sd_last_access = millis(); // keeps core1 off the card while requests flow return SD_CLAIM_OK; @@ -92,7 +98,12 @@ void sd_mark_dead(void) { } // A write changed the filesystem; refresh the stats (rate-limited on core1) -void sd_note_write(void) { sd_stats_pending = true; } +void sd_note_write(void) { + if (!sd_stats_pending) { + stats_pending_since = millis(); + sd_stats_pending = true; + } +} // Snapshot for get_sd_info, answered entirely from cached state so the // response never waits on the card @@ -178,11 +189,13 @@ void loop1() { // this makes inserting (or re-inserting) a card after boot just work if (last_mount == 0 || millis() - last_mount >= 2000) { bool ok; + sd_mounting = true; // requests get BUSY, not "no card", until we know mutex_enter_blocking(&sd_mutex); if ((ok = SD.begin(SD_CS_PIN, 16000000, SPI1))) { sd_cache_card_info(); sd_stats_valid = false; sd_stats_pending = true; + stats_pending_since = millis(); } mutex_exit(&sd_mutex); if (ok) { @@ -190,6 +203,7 @@ void loop1() { sd_mounted = true; Serial.println("card initialized."); } + sd_mounting = false; // stamped after the attempt: begin() spins for its full timeout // when the slot is empty last_mount = millis(); @@ -201,8 +215,12 @@ void loop1() { // knowing the free space. Write-triggered refreshes wait a minute on // top of that. uint32_t interval = sd_stats_valid ? 60000 : 0; + // A steady stream of tile reads would keep the card busy forever, so + // stop waiting for an idle window after a while and just take the card: + // the requester retries a BUSY response. + bool waited_long_enough = millis() - stats_pending_since >= 30000; if ((last_scan == 0 || millis() - last_scan >= interval) && - millis() - sd_last_access >= 3000) { + (millis() - sd_last_access >= 3000 || waited_long_enough)) { FSInfo info; mutex_enter_blocking(&sd_mutex); SDFS.info(info); From 71a265e7ee36451e7e9d40d6240453fc71311ba2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thomas=20G=C3=B6ttgens?= Date: Mon, 13 Jul 2026 10:27:01 +0200 Subject: [PATCH 14/21] Detect a pulled card on reads, list directories without reopening entries A pulled card fails exactly like a missing file, and map tiles are probed constantly, so the open failure path (not just seek/read on a cached handle) now asks the card whether it is still there. Card level failures during write and delete are classified the same way. Directory listings iterate with the Dir API: openNextFile() re-opens every entry by full path, which made a walk O(n^2) and could block core0 for minutes on a directory full of tiles, watchdog included. The remount path closes cached handles under the mutex (core0 could re-open one after the card was marked dead), sd_get_info copies the identity cache under the mutex, a POST with a nonzero offset no longer truncates the file before rejecting the request, and the version mismatch is logged whenever the version we see changes rather than once per boot. --- src/main.cpp | 27 ++++++++---- src/serial_proto.cpp | 101 +++++++++++++++++++++++++++---------------- 2 files changed, 82 insertions(+), 46 deletions(-) diff --git a/src/main.cpp b/src/main.cpp index db17c28..80d63f1 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -111,17 +111,24 @@ void sd_get_info(meshtastic_SdCardInfo *out) { memset(out, 0, sizeof(*out)); if (!sd_mounted) return; - *out = sd_info_cache; // stable while mounted - out->present = true; - // used/free stay zero while the scan is still running (mutex held by - // core1) or has not completed yet + // under the mutex: core1 rewrites the identity cache on a remount, and a + // torn copy would report a mix of the old and the new card. used/free + // stay zero while its background scan has not finished. if (mutex_try_enter(&sd_mutex, NULL)) { - if (sd_stats_valid) { - out->used_bytes = sd_used_bytes; - out->free_bytes = sd_free_bytes; - out->stats_valid = true; + if (sd_mounted) { + *out = sd_info_cache; + out->present = true; + if (sd_stats_valid) { + out->used_bytes = sd_used_bytes; + out->free_bytes = sd_free_bytes; + out->stats_valid = true; + } } mutex_exit(&sd_mutex); + } else { + // core1 is busy with the card; the identity is still what it was + *out = sd_info_cache; + out->present = true; } } @@ -177,6 +184,9 @@ void loop1() { sd_mounted = false; sd_stats_valid = false; sd_generation++; + // core0 may have re-opened a handle between marking the card dead and + // this point; it must not outlive the volume it belongs to + sd_close_cached_files(); SD.end(false); mutex_exit(&sd_mutex); sd_recheck = false; @@ -201,6 +211,7 @@ void loop1() { if (ok) { // published after the info cache is fully written sd_mounted = true; + sd_last_access = millis(); // the idle window starts here Serial.println("card initialized."); } sd_mounting = false; diff --git a/src/serial_proto.cpp b/src/serial_proto.cpp index 553533a..b0bed45 100644 --- a/src/serial_proto.cpp +++ b/src/serial_proto.cpp @@ -82,6 +82,18 @@ static bool i2c_device_present(uint8_t addr) { return Wire.endTransmission() == 0; } +// An operation on a path can fail because the path is wrong or because the +// card is gone; only the card itself can tell us apart. Reading the root +// directory touches the card, so a mounted card that cannot produce it has +// been pulled. Called only on failure paths. +static bool card_still_there(void) { + if (SD.exists("/")) + return true; + Serial.println("SD card stopped responding"); + sd_mark_dead(); + return false; +} + // Parse a packet that came in, and handle it. Return true if we were able to // parse it. bool mt_handle_packet(size_t payload_len) { @@ -195,7 +207,11 @@ bool mt_handle_packet(size_t payload_len) { invalidate_read_cache(); read_cache = SD.open(request.filepath, FILE_READ); if (!read_cache) { - out->status = meshtastic_FileStatus_FILE_NOT_FOUND; + // a pulled card fails exactly like a missing file, and map tiles + // are probed constantly: this is the path that must notice + out->status = card_still_there() + ? meshtastic_FileStatus_FILE_NOT_FOUND + : meshtastic_FileStatus_FILE_NO_CARD; break; } if (read_cache.isDirectory()) { @@ -236,14 +252,21 @@ bool mt_handle_packet(size_t payload_len) { case meshtastic_FileOperation_POST: case meshtastic_FileOperation_PUT: { invalidate_read_cache(); - if (request.operation == meshtastic_FileOperation_POST && - SD.exists(request.filepath)) - SD.remove(request.filepath); + if (request.operation == meshtastic_FileOperation_POST) { + if (request.offset != 0) { + // a POST starts a new file; truncating for a chunk that is not + // the first one would destroy what is already there + out->status = meshtastic_FileStatus_FILE_OFFSET_CONFLICT; + break; + } + if (SD.exists(request.filepath)) + SD.remove(request.filepath); + } File f = SD.open(request.filepath, FILE_WRITE); // append mode, creates if (!f) { - // a full card or a bad path fails here just like a pulled card - // does, so do not unmount on it - out->status = meshtastic_FileStatus_FILE_IO_ERROR; + // a full card, a bad path and a pulled card all fail here + out->status = card_still_there() ? meshtastic_FileStatus_FILE_IO_ERROR + : meshtastic_FileStatus_FILE_NO_CARD; strncpy(out->message, "open failed", sizeof(out->message) - 1); break; } @@ -260,9 +283,10 @@ bool mt_handle_packet(size_t payload_len) { out->status = meshtastic_FileStatus_FILE_OK; sd_note_write(); } else { - // short write: card full, or the card went away. Do not unmount, - // a full card would thrash between remounts - out->status = meshtastic_FileStatus_FILE_IO_ERROR; + // short write: a full card (do not unmount, it would thrash between + // remounts) or a card that went away + out->status = card_still_there() ? meshtastic_FileStatus_FILE_IO_ERROR + : meshtastic_FileStatus_FILE_NO_CARD; strncpy(out->message, "write failed", sizeof(out->message) - 1); } count_cache_path[0] = '\0'; @@ -277,7 +301,8 @@ bool mt_handle_packet(size_t payload_len) { out->status = meshtastic_FileStatus_FILE_OK; sd_note_write(); } else { - out->status = meshtastic_FileStatus_FILE_IO_ERROR; + out->status = card_still_there() ? meshtastic_FileStatus_FILE_IO_ERROR + : meshtastic_FileStatus_FILE_NO_CARD; strncpy(out->message, "remove failed", sizeof(out->message) - 1); } count_cache_path[0] = '\0'; @@ -311,37 +336,38 @@ bool mt_handle_packet(size_t payload_len) { check_sd_generation(); File dir = SD.open(request.directory); - if (!dir) { - out->status = meshtastic_FileStatus_FILE_NOT_FOUND; - } else if (!dir.isDirectory()) { - out->status = meshtastic_FileStatus_FILE_NOT_A_FILE; + bool is_dir = dir && dir.isDirectory(); + if (dir) + dir.close(); + if (!is_dir) { + out->status = + SD.exists(request.directory) + ? meshtastic_FileStatus_FILE_NOT_A_FILE + : (card_still_there() ? meshtastic_FileStatus_FILE_NOT_FOUND + : meshtastic_FileStatus_FILE_NO_CARD); } else { const size_t max_names = sizeof(out->filenames) / sizeof(out->filenames[0]); - // Walking to the end of the directory just to count the entries is - // expensive (a full LFN-parsing walk per page); do it once on the - // first page and reuse the count for the follow-up pages + // Iterate with the Dir API: openNextFile() re-opens every entry by + // full path, which turns a directory walk into an O(n^2) scan and can + // block core0 for minutes on a directory full of map tiles. + Dir d = SDFS.openDir(request.directory); + // Counting the entries means walking the whole directory, so do it + // once on the first page and reuse the count for the follow-up pages bool have_total = request.offset > 0 && strcmp(count_cache_path, request.directory) == 0; uint32_t index = 0; - while (true) { - File entry = dir.openNextFile(); - if (!entry) - break; + while (d.next()) { if (index >= request.offset && out->filenames_count < max_names) { snprintf(out->filenames[out->filenames_count], - sizeof(out->filenames[0]), "%s%s", entry.name(), - entry.isDirectory() ? "/" : ""); + sizeof(out->filenames[0]), "%s%s", d.fileName().c_str(), + d.isDirectory() ? "/" : ""); out->filenames_count++; } index++; - entry.close(); if (have_total && out->filenames_count >= max_names) - break; // page full, the total is already known - // the core re-opens every entry by path, so a directory with - // thousands of tiles takes a while: keep the watchdog fed - if ((index & 0x3f) == 0) - rp2040.wdt_reset(); + break; // page full, the total is already known + rp2040.wdt_reset(); // a big directory still takes a while } if (have_total) { out->total_count = count_cache_total; @@ -353,8 +379,6 @@ bool mt_handle_packet(size_t payload_len) { } out->status = meshtastic_FileStatus_FILE_OK; } - if (dir) - dir.close(); sd_release(); return mt_send_uplink(response); } @@ -375,14 +399,15 @@ bool mt_handle_packet(size_t payload_len) { // Liveness probe and version handshake, must work with nothing // attached to this MCU. Always answer with the version we speak: the // main firmware compares it and shuts the bridge down on a mismatch. - static bool mismatch_logged = false; + static uint32_t logged_peer_version = UINT32_MAX; if (message.data.ping != meshtastic_InterdeviceVersion_INTERDEVICE_VERSION_CURRENT && - !mismatch_logged) { - // Once per boot: the peer probes repeatedly while the link is down, - // and a USB CDC write blocks for up to a second when a host is - // attached but not reading. - mismatch_logged = true; + message.data.ping != logged_peer_version) { + // Only when the version we see changes: the peer probes repeatedly + // while the link is down, and a USB CDC write blocks for up to a + // second when a host is attached but not reading. Reflashing the peer + // to another (still wrong) version does get logged again. + logged_peer_version = message.data.ping; Serial.printf( "Peer speaks interdevice protocol v%u, we speak v%u\r\n", (unsigned)message.data.ping, From deab3ec917f03e868e0276481162e5e87ca25fcf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thomas=20G=C3=B6ttgens?= Date: Mon, 13 Jul 2026 10:36:33 +0200 Subject: [PATCH 15/21] Announce ourselves at boot, account free space instead of rescanning The main firmware scans the bridged bus once during its setup, so a co-processor that only comes up afterwards would leave its sensors undiscovered; it now sends an unsolicited ping when it has booted, which also lets the main firmware notice a reboot. The free space scan holds the card for seconds on a large card. It now runs once per mount; later writes keep used/free current by accounting for the bytes they add or free, so serving map tiles is never interrupted by a rescan. A card that is being mounted reports busy rather than absent, the identity snapshot is taken without waiting for the card, and the I2C scan feeds the watchdog. --- protobufs | 2 +- src/main.cpp | 88 ++++++++++++++++++++------------- src/meshtastic/interdevice.pb.h | 42 +++++++++------- src/serial_proto.cpp | 36 ++++++++++++-- src/serial_proto.h | 6 ++- 5 files changed, 116 insertions(+), 58 deletions(-) diff --git a/protobufs b/protobufs index ef1f99d..f14f124 160000 --- a/protobufs +++ b/protobufs @@ -1 +1 @@ -Subproject commit ef1f99d4da67c56af29035db6792c6a8f67e38b1 +Subproject commit f14f124d23192800d516041921f8220bdb988bf8 diff --git a/src/main.cpp b/src/main.cpp index 80d63f1..4087572 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -97,11 +97,24 @@ void sd_mark_dead(void) { sd_recheck = true; } -// A write changed the filesystem; refresh the stats (rate-limited on core1) -void sd_note_write(void) { - if (!sd_stats_pending) { - stats_pending_since = millis(); - sd_stats_pending = true; +// A write changed the filesystem by this many bytes (negative when it freed +// them). The full scan behind used/free walks the whole FAT and holds the +// card for seconds, which would starve the map tiles the writes belong to, +// so keep the numbers current by accounting instead of rescanning. Cluster +// slack is ignored: this is a UI readout, not a budget. +// Called from a file operation, i.e. with the card mutex already held (it is +// not recursive, so this must not take it again). +void sd_account_bytes(int64_t delta) { + if (!sd_stats_valid || delta == 0) + return; + if (delta > 0) { + uint64_t d = (uint64_t)delta; + sd_used_bytes += d; + sd_free_bytes = sd_free_bytes > d ? sd_free_bytes - d : 0; + } else { + uint64_t d = (uint64_t)(-delta); + sd_used_bytes = sd_used_bytes > d ? sd_used_bytes - d : 0; + sd_free_bytes += d; } } @@ -109,26 +122,29 @@ void sd_note_write(void) { // response never waits on the card void sd_get_info(meshtastic_SdCardInfo *out) { memset(out, 0, sizeof(*out)); + if (sd_mounting) { + // no card mounted, but whether one is present is not decided yet: the + // requester must ask again instead of reporting an empty slot + out->busy = true; + return; + } if (!sd_mounted) return; - // under the mutex: core1 rewrites the identity cache on a remount, and a - // torn copy would report a mix of the old and the new card. used/free - // stay zero while its background scan has not finished. - if (mutex_try_enter(&sd_mutex, NULL)) { - if (sd_mounted) { - *out = sd_info_cache; - out->present = true; - if (sd_stats_valid) { - out->used_bytes = sd_used_bytes; - out->free_bytes = sd_free_bytes; - out->stats_valid = true; - } - } - mutex_exit(&sd_mutex); - } else { - // core1 is busy with the card; the identity is still what it was - *out = sd_info_cache; - out->present = true; + // Never waits for the card: core1 can hold the mutex for seconds. The + // identity cache is only rewritten while unmounted, so a generation that + // did not change across the copy proves it did not come from two cards. + uint32_t generation = sd_generation; + *out = sd_info_cache; + if (generation != sd_generation || !sd_mounted) { + memset(out, 0, sizeof(*out)); + out->busy = true; // remounting right now + return; + } + out->present = true; + if (sd_stats_valid) { + out->used_bytes = sd_used_bytes; + out->free_bytes = sd_free_bytes; + out->stats_valid = true; } } @@ -177,7 +193,6 @@ void setup1() { void loop1() { static uint32_t last_mount = 0; - static uint32_t last_scan = 0; if (sd_recheck) { mutex_enter_blocking(&sd_mutex); @@ -220,18 +235,17 @@ void loop1() { last_mount = millis(); } } else if (sd_stats_pending) { - // The free space scan walks the whole FAT (SdFat keeps no cached - // cluster count) and holds the card for seconds, so only start it when - // core0 has not touched the card for a while: serving map tiles beats - // knowing the free space. Write-triggered refreshes wait a minute on - // top of that. - uint32_t interval = sd_stats_valid ? 60000 : 0; + // The free space scan walks the whole FAT (SdFat keeps no cached cluster + // count) and holds the card for seconds, so it runs once per mount and + // only when core0 has not touched the card for a while: serving map + // tiles beats knowing the free space. Later writes keep the numbers + // current by accounting (sd_account_bytes), so no rescan follows them. + // // A steady stream of tile reads would keep the card busy forever, so - // stop waiting for an idle window after a while and just take the card: - // the requester retries a BUSY response. + // stop waiting for an idle window after a while and just take it: the + // requester retries the BUSY responses that costs. bool waited_long_enough = millis() - stats_pending_since >= 30000; - if ((last_scan == 0 || millis() - last_scan >= interval) && - (millis() - sd_last_access >= 3000 || waited_long_enough)) { + if (millis() - sd_last_access >= 3000 || waited_long_enough) { FSInfo info; mutex_enter_blocking(&sd_mutex); SDFS.info(info); @@ -249,7 +263,6 @@ void loop1() { mutex_exit(&sd_mutex); if (!ok) sd_recheck = true; // a FAT walk only fails when the card is gone - last_scan = millis(); } } delay(20); @@ -329,6 +342,11 @@ void setup() { Serial.printf(SENSECAP, VERSION); beep_on(50); + // Announce ourselves: the main firmware may have finished its own boot + // (and its peripheral scan) while we were still starting, and it has no + // other way of noticing that we came up or rebooted. + mt_send_hello(); + // This MCU is the sole path to GPS, sensors and SD card; a wedged // peripheral driver must reboot it rather than take them down for good. // The request loop on core0 feeds the watchdog, core1 may block freely. diff --git a/src/meshtastic/interdevice.pb.h b/src/meshtastic/interdevice.pb.h index e927dc5..fdc48e7 100644 --- a/src/meshtastic/interdevice.pb.h +++ b/src/meshtastic/interdevice.pb.h @@ -85,7 +85,6 @@ typedef struct _meshtastic_FileTransfer { meshtastic_FileOperation operation; /* File operation (GET, POST, PUT, DELETE) */ char filepath[256]; /* Path of the file on the SD card */ meshtastic_FileTransfer_filedata_t filedata; /* Chunk content (POST/PUT request, GET response) */ - meshtastic_FileStatus status; /* Response: outcome of the operation */ char message[255]; /* Response: human readable detail, may be empty */ uint64_t offset; /* Byte offset of this chunk within the file (ranged GET/PUT) */ /* GET request: number of bytes to read, 0 = max chunk size. A response @@ -93,6 +92,7 @@ typedef struct _meshtastic_FileTransfer { chunk; larger requests are truncated, visible in the filedata length. */ uint32_t length; uint64_t file_size; /* GET response: total size of the file */ + meshtastic_FileStatus status; /* Response: outcome of the operation */ } meshtastic_FileTransfer; /* Message for structured directory listing */ @@ -105,10 +105,10 @@ typedef struct _meshtastic_DirectoryListing { offset and total_count. */ pb_size_t filenames_count; char filenames[16][256]; - meshtastic_FileStatus status; /* Response: outcome of the operation */ char message[255]; /* Response: human readable detail, may be empty */ uint32_t offset; /* Request: skip this many entries (paging) */ uint32_t total_count; /* Response: total number of entries in the directory */ + meshtastic_FileStatus status; /* Response: outcome of the operation */ } meshtastic_DirectoryListing; typedef PB_BYTES_ARRAY_T(256) meshtastic_I2CTransaction_write_data_t; @@ -126,7 +126,9 @@ typedef struct _meshtastic_I2CTransaction { /* SD card statistics */ typedef struct _meshtastic_SdCardInfo { - bool present; /* Card initialized and usable */ + /* Card initialized and usable. False while `busy` is set does not mean + there is no card: the co-processor does not know yet. */ + bool present; meshtastic_SdCardInfo_CardType card_type; meshtastic_SdCardInfo_FatType fat_type; uint64_t card_size; /* Filesystem size in bytes */ @@ -136,6 +138,10 @@ typedef struct _meshtastic_SdCardInfo { them runs in the background after mount and can take a while, and a full card is otherwise indistinguishable from a scan in progress */ bool stats_valid; + /* The co-processor is mounting a card right now, so whether one is + present is not decided yet. Ask again rather than concluding the slot + is empty. */ + bool busy; } meshtastic_SdCardInfo; typedef PB_BYTES_ARRAY_T(256) meshtastic_I2CResult_read_data_t; @@ -222,16 +228,16 @@ extern "C" { /* Initializer values for message structs */ -#define meshtastic_FileTransfer_init_default {_meshtastic_FileOperation_MIN, "", {0, {0}}, _meshtastic_FileStatus_MIN, "", 0, 0, 0} -#define meshtastic_DirectoryListing_init_default {"", 0, {"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", ""}, _meshtastic_FileStatus_MIN, "", 0, 0} +#define meshtastic_FileTransfer_init_default {_meshtastic_FileOperation_MIN, "", {0, {0}}, "", 0, 0, 0, _meshtastic_FileStatus_MIN} +#define meshtastic_DirectoryListing_init_default {"", 0, {"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", ""}, "", 0, 0, _meshtastic_FileStatus_MIN} #define meshtastic_I2CTransaction_init_default {0, {0, {0}}, 0} -#define meshtastic_SdCardInfo_init_default {0, _meshtastic_SdCardInfo_CardType_MIN, _meshtastic_SdCardInfo_FatType_MIN, 0, 0, 0, 0} +#define meshtastic_SdCardInfo_init_default {0, _meshtastic_SdCardInfo_CardType_MIN, _meshtastic_SdCardInfo_FatType_MIN, 0, 0, 0, 0, 0} #define meshtastic_I2CResult_init_default {_meshtastic_I2CResult_Status_MIN, {0, {0}}} #define meshtastic_InterdeviceMessage_init_default {0, {""}, 0} -#define meshtastic_FileTransfer_init_zero {_meshtastic_FileOperation_MIN, "", {0, {0}}, _meshtastic_FileStatus_MIN, "", 0, 0, 0} -#define meshtastic_DirectoryListing_init_zero {"", 0, {"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", ""}, _meshtastic_FileStatus_MIN, "", 0, 0} +#define meshtastic_FileTransfer_init_zero {_meshtastic_FileOperation_MIN, "", {0, {0}}, "", 0, 0, 0, _meshtastic_FileStatus_MIN} +#define meshtastic_DirectoryListing_init_zero {"", 0, {"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", ""}, "", 0, 0, _meshtastic_FileStatus_MIN} #define meshtastic_I2CTransaction_init_zero {0, {0, {0}}, 0} -#define meshtastic_SdCardInfo_init_zero {0, _meshtastic_SdCardInfo_CardType_MIN, _meshtastic_SdCardInfo_FatType_MIN, 0, 0, 0, 0} +#define meshtastic_SdCardInfo_init_zero {0, _meshtastic_SdCardInfo_CardType_MIN, _meshtastic_SdCardInfo_FatType_MIN, 0, 0, 0, 0, 0} #define meshtastic_I2CResult_init_zero {_meshtastic_I2CResult_Status_MIN, {0, {0}}} #define meshtastic_InterdeviceMessage_init_zero {0, {""}, 0} @@ -239,17 +245,17 @@ extern "C" { #define meshtastic_FileTransfer_operation_tag 1 #define meshtastic_FileTransfer_filepath_tag 2 #define meshtastic_FileTransfer_filedata_tag 3 -#define meshtastic_FileTransfer_status_tag 4 #define meshtastic_FileTransfer_message_tag 5 #define meshtastic_FileTransfer_offset_tag 6 #define meshtastic_FileTransfer_length_tag 7 #define meshtastic_FileTransfer_file_size_tag 8 +#define meshtastic_FileTransfer_status_tag 9 #define meshtastic_DirectoryListing_directory_tag 1 #define meshtastic_DirectoryListing_filenames_tag 2 -#define meshtastic_DirectoryListing_status_tag 3 #define meshtastic_DirectoryListing_message_tag 4 #define meshtastic_DirectoryListing_offset_tag 5 #define meshtastic_DirectoryListing_total_count_tag 6 +#define meshtastic_DirectoryListing_status_tag 7 #define meshtastic_I2CTransaction_address_tag 1 #define meshtastic_I2CTransaction_write_data_tag 2 #define meshtastic_I2CTransaction_read_len_tag 3 @@ -260,6 +266,7 @@ extern "C" { #define meshtastic_SdCardInfo_used_bytes_tag 5 #define meshtastic_SdCardInfo_free_bytes_tag 6 #define meshtastic_SdCardInfo_stats_valid_tag 7 +#define meshtastic_SdCardInfo_busy_tag 8 #define meshtastic_I2CResult_status_tag 1 #define meshtastic_I2CResult_read_data_tag 2 #define meshtastic_InterdeviceMessage_nmea_tag 1 @@ -282,21 +289,21 @@ extern "C" { X(a, STATIC, SINGULAR, UENUM, operation, 1) \ X(a, STATIC, SINGULAR, STRING, filepath, 2) \ X(a, STATIC, SINGULAR, BYTES, filedata, 3) \ -X(a, STATIC, SINGULAR, UENUM, status, 4) \ X(a, STATIC, SINGULAR, STRING, message, 5) \ X(a, STATIC, SINGULAR, UINT64, offset, 6) \ X(a, STATIC, SINGULAR, UINT32, length, 7) \ -X(a, STATIC, SINGULAR, UINT64, file_size, 8) +X(a, STATIC, SINGULAR, UINT64, file_size, 8) \ +X(a, STATIC, SINGULAR, UENUM, status, 9) #define meshtastic_FileTransfer_CALLBACK NULL #define meshtastic_FileTransfer_DEFAULT NULL #define meshtastic_DirectoryListing_FIELDLIST(X, a) \ X(a, STATIC, SINGULAR, STRING, directory, 1) \ X(a, STATIC, REPEATED, STRING, filenames, 2) \ -X(a, STATIC, SINGULAR, UENUM, status, 3) \ X(a, STATIC, SINGULAR, STRING, message, 4) \ X(a, STATIC, SINGULAR, UINT32, offset, 5) \ -X(a, STATIC, SINGULAR, UINT32, total_count, 6) +X(a, STATIC, SINGULAR, UINT32, total_count, 6) \ +X(a, STATIC, SINGULAR, UENUM, status, 7) #define meshtastic_DirectoryListing_CALLBACK NULL #define meshtastic_DirectoryListing_DEFAULT NULL @@ -314,7 +321,8 @@ X(a, STATIC, SINGULAR, UENUM, fat_type, 3) \ X(a, STATIC, SINGULAR, UINT64, card_size, 4) \ X(a, STATIC, SINGULAR, UINT64, used_bytes, 5) \ X(a, STATIC, SINGULAR, UINT64, free_bytes, 6) \ -X(a, STATIC, SINGULAR, BOOL, stats_valid, 7) +X(a, STATIC, SINGULAR, BOOL, stats_valid, 7) \ +X(a, STATIC, SINGULAR, BOOL, busy, 8) #define meshtastic_SdCardInfo_CALLBACK NULL #define meshtastic_SdCardInfo_DEFAULT NULL @@ -369,7 +377,7 @@ extern const pb_msgdesc_t meshtastic_InterdeviceMessage_msg; #define meshtastic_I2CResult_size 261 #define meshtastic_I2CTransaction_size 271 #define meshtastic_InterdeviceMessage_size 4666 -#define meshtastic_SdCardInfo_size 41 +#define meshtastic_SdCardInfo_size 43 #ifdef __cplusplus } /* extern "C" */ diff --git a/src/serial_proto.cpp b/src/serial_proto.cpp index b0bed45..63407e9 100644 --- a/src/serial_proto.cpp +++ b/src/serial_proto.cpp @@ -63,6 +63,17 @@ bool mt_send(const char *buf, size_t len) { return false; } +// Unsolicited ping at boot: tells the main firmware we are (back) up and +// which protocol version we speak, without it having to poll for us. +bool mt_send_hello(void) { + meshtastic_InterdeviceMessage &response = tx_response; + memset(&response, 0, sizeof(response)); + response.which_data = meshtastic_InterdeviceMessage_ping_tag; + response.data.ping = + meshtastic_InterdeviceVersion_INTERDEVICE_VERSION_CURRENT; + return mt_send_uplink(response); +} + // The requester fails fast on a nack instead of burning its timeout. // id 0 when the request could not even be decoded. static bool mt_send_nack(uint32_t id) { @@ -82,6 +93,17 @@ static bool i2c_device_present(uint8_t addr) { return Wire.endTransmission() == 0; } +// Size of an existing file, 0 when it is not one. Used to keep the used/free +// accounting straight across writes and deletes. +static uint64_t file_size_of(const char *path) { + File f = SD.open(path, FILE_READ); + if (!f) + return 0; + uint64_t size = f.isDirectory() ? 0 : (uint64_t)f.size(); + f.close(); + return size; +} + // An operation on a path can fail because the path is wrong or because the // card is gone; only the card itself can tell us apart. Reading the root // directory touches the card, so a mounted card that cannot produce it has @@ -259,8 +281,10 @@ bool mt_handle_packet(size_t payload_len) { out->status = meshtastic_FileStatus_FILE_OFFSET_CONFLICT; break; } - if (SD.exists(request.filepath)) + if (SD.exists(request.filepath)) { + sd_account_bytes(-(int64_t)file_size_of(request.filepath)); SD.remove(request.filepath); + } } File f = SD.open(request.filepath, FILE_WRITE); // append mode, creates if (!f) { @@ -279,9 +303,9 @@ bool mt_handle_packet(size_t payload_len) { size_t wrote = f.write(request.filedata.bytes, request.filedata.size); out->file_size = f.size(); f.close(); + sd_account_bytes((int64_t)wrote); if (wrote == request.filedata.size) { out->status = meshtastic_FileStatus_FILE_OK; - sd_note_write(); } else { // short write: a full card (do not unmount, it would thrash between // remounts) or a card that went away @@ -293,13 +317,14 @@ bool mt_handle_packet(size_t payload_len) { break; } - case meshtastic_FileOperation_DELETE: + case meshtastic_FileOperation_DELETE: { invalidate_read_cache(); + uint64_t freed = file_size_of(request.filepath); // idempotent: a file that is already gone is the requested outcome, // so a retried delete after a lost response reports OK if (SD.remove(request.filepath) || !SD.exists(request.filepath)) { out->status = meshtastic_FileStatus_FILE_OK; - sd_note_write(); + sd_account_bytes(-(int64_t)freed); } else { out->status = card_still_there() ? meshtastic_FileStatus_FILE_IO_ERROR : meshtastic_FileStatus_FILE_NO_CARD; @@ -307,6 +332,7 @@ bool mt_handle_packet(size_t payload_len) { } count_cache_path[0] = '\0'; break; + } default: out->status = meshtastic_FileStatus_FILE_IO_ERROR; @@ -436,6 +462,8 @@ bool mt_handle_packet(size_t payload_len) { if (Wire.endTransmission() == 0) response.data.i2c_scan_result .bytes[response.data.i2c_scan_result.size++] = addr; + // a hung bus times out per address, which adds up past the watchdog + rp2040.wdt_reset(); } // a stuck-low SDA acks every probe; report a fault as an empty scan // rather than 112 phantom devices diff --git a/src/serial_proto.h b/src/serial_proto.h index b86ecbb..6c27889 100644 --- a/src/serial_proto.h +++ b/src/serial_proto.h @@ -13,6 +13,8 @@ #define MT_HEADER_SIZE 4 bool mt_send_uplink(const meshtastic_InterdeviceMessage &message); +// unsolicited ping at boot, so the main firmware notices us coming (back) up +bool mt_send_hello(void); void mt_set_nmea_callback(void (*callback)(char *nmea)); void mt_loop(); @@ -26,7 +28,9 @@ void sd_mark_dead(void); // drops cached file handles; called with the card mutex held, right before // core1 unmounts (implemented next to the caches, in serial_proto.cpp) void sd_close_cached_files(void); -void sd_note_write(void); +// a write changed the filesystem by this many bytes (negative when it freed +// them); keeps used/free current without another full FAT walk +void sd_account_bytes(int64_t delta); void sd_get_info(meshtastic_SdCardInfo *out); // bumped on every unmount, open file handles do not survive a remount extern volatile uint32_t sd_generation; \ No newline at end of file From 739887ea712da036c122b4f4fccce2a4a6940c31 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thomas=20G=C3=B6ttgens?= Date: Mon, 13 Jul 2026 11:16:00 +0200 Subject: [PATCH 16/21] Suppress library warnings --- platformio.ini | 2 ++ suppress_lib_warnings.py | 35 +++++++++++++++++++++++++++++++++++ 2 files changed, 37 insertions(+) create mode 100644 suppress_lib_warnings.py diff --git a/platformio.ini b/platformio.ini index 9e10056..051176b 100644 --- a/platformio.ini +++ b/platformio.ini @@ -15,6 +15,8 @@ framework = arduino board = seeed_indicator_rp2040 board_build.core = earlephilhower +extra_scripts = pre:suppress_lib_warnings.py + build_flags = -Wno-missing-field-initializers -Wno-format diff --git a/suppress_lib_warnings.py b/suppress_lib_warnings.py new file mode 100644 index 0000000..afcdb94 --- /dev/null +++ b/suppress_lib_warnings.py @@ -0,0 +1,35 @@ +import os + +Import("env") + +# Silence compiler warnings for everything that is not our own code +# (lib_deps, the Arduino core and other framework/platform sources). +# Files under src/ keep the warning flags from build_flags. + +src_dir = os.path.normcase(os.path.realpath(env.subst("$PROJECT_SRC_DIR"))) + + +def is_own_code(path): + path = os.path.normcase(os.path.realpath(path)) + return path == src_dir or path.startswith(src_dir + os.sep) + + +def suppress_warnings(target_env, node): + if is_own_code(node.srcnode().get_abspath()): + return node + # Rebuilding the object node resets the construction variables, so the + # include paths and defines of this specific library have to be carried + # over explicitly, otherwise its own headers stop resolving. + return target_env.Object( + node, + CFLAGS=target_env["CFLAGS"] + ["-w"], + CXXFLAGS=target_env["CXXFLAGS"] + ["-w"], + CCFLAGS=target_env["CCFLAGS"], + CPPPATH=target_env["CPPPATH"], + CPPDEFINES=target_env["CPPDEFINES"], + ASFLAGS=target_env["ASFLAGS"], + ASPPFLAGS=target_env["ASPPFLAGS"], + ) + + +env.AddBuildMiddleware(suppress_warnings, "*") From 8e32a0b9c38a7347e165192a1658f6432c091314 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thomas=20G=C3=B6ttgens?= Date: Mon, 13 Jul 2026 11:33:35 +0200 Subject: [PATCH 17/21] wire up github --- .github/workflows/build.yml | 45 +++++++++++++++++ .github/workflows/release.yml | 95 +++++++++++++++++++++++++++++++++++ 2 files changed, 140 insertions(+) create mode 100644 .github/workflows/build.yml create mode 100644 .github/workflows/release.yml diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 0000000..19cca12 --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,45 @@ +name: Build + +on: + pull_request: + push: + branches: + - main + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: build-${{ github.workflow }}-${{ github.head_ref || github.ref }} + cancel-in-progress: true + +jobs: + build: + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v7 + with: + submodules: recursive + + - name: Set up Python + uses: actions/setup-python@v6 + with: + python-version: "3.x" + + - name: Cache PlatformIO + uses: actions/cache@v6 + with: + path: | + ~/.platformio + ~/.cache/pip + key: pio-${{ runner.os }}-${{ hashFiles('platformio.ini') }} + restore-keys: pio-${{ runner.os }}- + + - name: Install PlatformIO + run: pip install --upgrade https://github.com/pioarduino/platformio-core/archive/develop.tar.gz + + - name: Build firmware + run: pio run -e seeed_indicator_rp2040 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..2301f7d --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,95 @@ +name: Release + +on: + push: + tags: + - "v*" + release: + types: [created, published] + workflow_dispatch: + inputs: + tag: + description: "Tag to build and attach the firmware to" + required: true + +permissions: + contents: write + +concurrency: + group: release-${{ github.event.release.tag_name || github.ref_name || inputs.tag }} + cancel-in-progress: false + +jobs: + build: + runs-on: ubuntu-latest + + steps: + - name: Resolve tag and version + id: version + env: + TAG: ${{ github.event.release.tag_name || inputs.tag || github.ref_name }} + run: | + echo "tag=$TAG" >> "$GITHUB_OUTPUT" + echo "version=${TAG#v}" >> "$GITHUB_OUTPUT" + + - name: Checkout + uses: actions/checkout@v7 + with: + ref: ${{ steps.version.outputs.tag }} + submodules: recursive + fetch-depth: 0 + + - name: Set up Python + uses: actions/setup-python@v6 + with: + python-version: "3.x" + + - name: Cache PlatformIO + uses: actions/cache@v6 + with: + path: | + ~/.platformio + ~/.cache/pip + key: pio-${{ runner.os }}-${{ hashFiles('platformio.ini') }} + restore-keys: pio-${{ runner.os }}- + + - name: Install PlatformIO + run: pip install --upgrade https://github.com/pioarduino/platformio-core/archive/develop.tar.gz + + - name: Build firmware + run: pio run -e seeed_indicator_rp2040 + + - name: Bundle release archive + id: bundle + env: + VERSION: ${{ steps.version.outputs.version }} + run: | + name="indicator_rp2040-${VERSION}" + build=.pio/build/seeed_indicator_rp2040 + mkdir -p "dist/${name}" + cp "${build}/firmware.uf2" "dist/${name}/${name}.uf2" + cp "${build}/firmware.elf" "dist/${name}/${name}.elf" + cp "${build}/firmware.bin" "dist/${name}/${name}.bin" + cp LICENSE README.md "dist/${name}/" + (cd dist && zip -r "${name}.zip" "${name}") + echo "name=${name}" >> "$GITHUB_OUTPUT" + + - name: Upload build artifact + uses: actions/upload-artifact@v7 + with: + name: ${{ steps.bundle.outputs.name }} + path: dist/${{ steps.bundle.outputs.name }}.zip + if-no-files-found: error + + - name: Publish release + uses: softprops/action-gh-release@v3 + with: + tag_name: ${{ steps.version.outputs.tag }} + # Only author the notes when this run is what creates the release. + # A release-triggered run must not overwrite notes that already exist. + generate_release_notes: ${{ github.event_name != 'release' }} + draft: false + fail_on_unmatched_files: true + files: | + dist/${{ steps.bundle.outputs.name }}.zip + .pio/build/seeed_indicator_rp2040/firmware.uf2 From 836e07e92529d5d55791b93d09d2b8148a2716fb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thomas=20G=C3=B6ttgens?= Date: Mon, 13 Jul 2026 11:37:55 +0200 Subject: [PATCH 18/21] Regen protos: FileStatus back on the original tags --- protobufs | 2 +- src/meshtastic/interdevice.pb.h | 24 ++++++++++++------------ 2 files changed, 13 insertions(+), 13 deletions(-) diff --git a/protobufs b/protobufs index f14f124..f19ac13 160000 --- a/protobufs +++ b/protobufs @@ -1 +1 @@ -Subproject commit f14f124d23192800d516041921f8220bdb988bf8 +Subproject commit f19ac13dd91cdd66c5c1a5dfd9ef9b2d5f589fdb diff --git a/src/meshtastic/interdevice.pb.h b/src/meshtastic/interdevice.pb.h index fdc48e7..61487b9 100644 --- a/src/meshtastic/interdevice.pb.h +++ b/src/meshtastic/interdevice.pb.h @@ -85,6 +85,7 @@ typedef struct _meshtastic_FileTransfer { meshtastic_FileOperation operation; /* File operation (GET, POST, PUT, DELETE) */ char filepath[256]; /* Path of the file on the SD card */ meshtastic_FileTransfer_filedata_t filedata; /* Chunk content (POST/PUT request, GET response) */ + meshtastic_FileStatus status; /* Response: outcome of the operation */ char message[255]; /* Response: human readable detail, may be empty */ uint64_t offset; /* Byte offset of this chunk within the file (ranged GET/PUT) */ /* GET request: number of bytes to read, 0 = max chunk size. A response @@ -92,7 +93,6 @@ typedef struct _meshtastic_FileTransfer { chunk; larger requests are truncated, visible in the filedata length. */ uint32_t length; uint64_t file_size; /* GET response: total size of the file */ - meshtastic_FileStatus status; /* Response: outcome of the operation */ } meshtastic_FileTransfer; /* Message for structured directory listing */ @@ -105,10 +105,10 @@ typedef struct _meshtastic_DirectoryListing { offset and total_count. */ pb_size_t filenames_count; char filenames[16][256]; + meshtastic_FileStatus status; /* Response: outcome of the operation */ char message[255]; /* Response: human readable detail, may be empty */ uint32_t offset; /* Request: skip this many entries (paging) */ uint32_t total_count; /* Response: total number of entries in the directory */ - meshtastic_FileStatus status; /* Response: outcome of the operation */ } meshtastic_DirectoryListing; typedef PB_BYTES_ARRAY_T(256) meshtastic_I2CTransaction_write_data_t; @@ -228,14 +228,14 @@ extern "C" { /* Initializer values for message structs */ -#define meshtastic_FileTransfer_init_default {_meshtastic_FileOperation_MIN, "", {0, {0}}, "", 0, 0, 0, _meshtastic_FileStatus_MIN} -#define meshtastic_DirectoryListing_init_default {"", 0, {"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", ""}, "", 0, 0, _meshtastic_FileStatus_MIN} +#define meshtastic_FileTransfer_init_default {_meshtastic_FileOperation_MIN, "", {0, {0}}, _meshtastic_FileStatus_MIN, "", 0, 0, 0} +#define meshtastic_DirectoryListing_init_default {"", 0, {"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", ""}, _meshtastic_FileStatus_MIN, "", 0, 0} #define meshtastic_I2CTransaction_init_default {0, {0, {0}}, 0} #define meshtastic_SdCardInfo_init_default {0, _meshtastic_SdCardInfo_CardType_MIN, _meshtastic_SdCardInfo_FatType_MIN, 0, 0, 0, 0, 0} #define meshtastic_I2CResult_init_default {_meshtastic_I2CResult_Status_MIN, {0, {0}}} #define meshtastic_InterdeviceMessage_init_default {0, {""}, 0} -#define meshtastic_FileTransfer_init_zero {_meshtastic_FileOperation_MIN, "", {0, {0}}, "", 0, 0, 0, _meshtastic_FileStatus_MIN} -#define meshtastic_DirectoryListing_init_zero {"", 0, {"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", ""}, "", 0, 0, _meshtastic_FileStatus_MIN} +#define meshtastic_FileTransfer_init_zero {_meshtastic_FileOperation_MIN, "", {0, {0}}, _meshtastic_FileStatus_MIN, "", 0, 0, 0} +#define meshtastic_DirectoryListing_init_zero {"", 0, {"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", ""}, _meshtastic_FileStatus_MIN, "", 0, 0} #define meshtastic_I2CTransaction_init_zero {0, {0, {0}}, 0} #define meshtastic_SdCardInfo_init_zero {0, _meshtastic_SdCardInfo_CardType_MIN, _meshtastic_SdCardInfo_FatType_MIN, 0, 0, 0, 0, 0} #define meshtastic_I2CResult_init_zero {_meshtastic_I2CResult_Status_MIN, {0, {0}}} @@ -245,17 +245,17 @@ extern "C" { #define meshtastic_FileTransfer_operation_tag 1 #define meshtastic_FileTransfer_filepath_tag 2 #define meshtastic_FileTransfer_filedata_tag 3 +#define meshtastic_FileTransfer_status_tag 4 #define meshtastic_FileTransfer_message_tag 5 #define meshtastic_FileTransfer_offset_tag 6 #define meshtastic_FileTransfer_length_tag 7 #define meshtastic_FileTransfer_file_size_tag 8 -#define meshtastic_FileTransfer_status_tag 9 #define meshtastic_DirectoryListing_directory_tag 1 #define meshtastic_DirectoryListing_filenames_tag 2 +#define meshtastic_DirectoryListing_status_tag 3 #define meshtastic_DirectoryListing_message_tag 4 #define meshtastic_DirectoryListing_offset_tag 5 #define meshtastic_DirectoryListing_total_count_tag 6 -#define meshtastic_DirectoryListing_status_tag 7 #define meshtastic_I2CTransaction_address_tag 1 #define meshtastic_I2CTransaction_write_data_tag 2 #define meshtastic_I2CTransaction_read_len_tag 3 @@ -289,21 +289,21 @@ extern "C" { X(a, STATIC, SINGULAR, UENUM, operation, 1) \ X(a, STATIC, SINGULAR, STRING, filepath, 2) \ X(a, STATIC, SINGULAR, BYTES, filedata, 3) \ +X(a, STATIC, SINGULAR, UENUM, status, 4) \ X(a, STATIC, SINGULAR, STRING, message, 5) \ X(a, STATIC, SINGULAR, UINT64, offset, 6) \ X(a, STATIC, SINGULAR, UINT32, length, 7) \ -X(a, STATIC, SINGULAR, UINT64, file_size, 8) \ -X(a, STATIC, SINGULAR, UENUM, status, 9) +X(a, STATIC, SINGULAR, UINT64, file_size, 8) #define meshtastic_FileTransfer_CALLBACK NULL #define meshtastic_FileTransfer_DEFAULT NULL #define meshtastic_DirectoryListing_FIELDLIST(X, a) \ X(a, STATIC, SINGULAR, STRING, directory, 1) \ X(a, STATIC, REPEATED, STRING, filenames, 2) \ +X(a, STATIC, SINGULAR, UENUM, status, 3) \ X(a, STATIC, SINGULAR, STRING, message, 4) \ X(a, STATIC, SINGULAR, UINT32, offset, 5) \ -X(a, STATIC, SINGULAR, UINT32, total_count, 6) \ -X(a, STATIC, SINGULAR, UENUM, status, 7) +X(a, STATIC, SINGULAR, UINT32, total_count, 6) #define meshtastic_DirectoryListing_CALLBACK NULL #define meshtastic_DirectoryListing_DEFAULT NULL From f0eb58a88ec4a6960bd74cbdbb1990da291f0f75 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thomas=20G=C3=B6ttgens?= Date: Mon, 13 Jul 2026 11:49:44 +0200 Subject: [PATCH 19/21] Regen protos: ping/pong carry the InterdeviceVersion enum --- protobufs | 2 +- src/meshtastic/interdevice.pb.h | 12 +++++++----- 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/protobufs b/protobufs index f19ac13..35729b3 160000 --- a/protobufs +++ b/protobufs @@ -1 +1 @@ -Subproject commit f19ac13dd91cdd66c5c1a5dfd9ef9b2d5f589fdb +Subproject commit 35729b359e5e1d151959c7445afca2991e2e7ed4 diff --git a/src/meshtastic/interdevice.pb.h b/src/meshtastic/interdevice.pb.h index 61487b9..7f2ec4e 100644 --- a/src/meshtastic/interdevice.pb.h +++ b/src/meshtastic/interdevice.pb.h @@ -168,11 +168,11 @@ typedef struct _meshtastic_InterdeviceMessage { meshtastic_SdCardInfo sd_info; /* Response */ /* Link liveness probe and version handshake. The receiver answers ping with pong, echoing the id. Touches no peripherals, so it works with - nothing attached. Both carry the sender's InterdeviceVersion; a peer + nothing attached. Both carry the version the sender speaks; a peer that answers with a different one speaks another protocol and must not be used. */ - uint32_t ping; - uint32_t pong; + meshtastic_InterdeviceVersion ping; + meshtastic_InterdeviceVersion pong; /* Response: the request could not be decoded or is of an unhandled type, so the requester fails fast instead of burning its timeout. Echoes the id when known, 0 when the frame was undecodable. Never @@ -225,6 +225,8 @@ extern "C" { #define meshtastic_I2CResult_status_ENUMTYPE meshtastic_I2CResult_Status +#define meshtastic_InterdeviceMessage_data_ping_ENUMTYPE meshtastic_InterdeviceVersion +#define meshtastic_InterdeviceMessage_data_pong_ENUMTYPE meshtastic_InterdeviceVersion /* Initializer values for message structs */ @@ -343,8 +345,8 @@ X(a, STATIC, ONEOF, MESSAGE, (data,file_transfer,data.file_transfer), 7) X(a, STATIC, ONEOF, MESSAGE, (data,directory_listing,data.directory_listing), 8) \ X(a, STATIC, ONEOF, BOOL, (data,get_sd_info,data.get_sd_info), 9) \ X(a, STATIC, ONEOF, MESSAGE, (data,sd_info,data.sd_info), 10) \ -X(a, STATIC, ONEOF, UINT32, (data,ping,data.ping), 11) \ -X(a, STATIC, ONEOF, UINT32, (data,pong,data.pong), 12) \ +X(a, STATIC, ONEOF, UENUM, (data,ping,data.ping), 11) \ +X(a, STATIC, ONEOF, UENUM, (data,pong,data.pong), 12) \ X(a, STATIC, ONEOF, BOOL, (data,nack,data.nack), 13) \ X(a, STATIC, SINGULAR, UINT32, id, 15) #define meshtastic_InterdeviceMessage_CALLBACK NULL From 5f308d0546fd615f6371a12fe7c89e7f16385130 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thomas=20G=C3=B6ttgens?= Date: Mon, 13 Jul 2026 12:02:34 +0200 Subject: [PATCH 20/21] Point the protobufs submodule at the merged upstream commit --- protobufs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/protobufs b/protobufs index 35729b3..da2fc41 160000 --- a/protobufs +++ b/protobufs @@ -1 +1 @@ -Subproject commit 35729b359e5e1d151959c7445afca2991e2e7ed4 +Subproject commit da2fc413931c81c1d2bc9b6a838d739e2bf35bc1 From dc17e2c5bc0cbe0de57a7443dc9145247bcf5703 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thomas=20G=C3=B6ttgens?= Date: Mon, 13 Jul 2026 12:22:09 +0200 Subject: [PATCH 21/21] Pin PlatformIO Core to a fixed commit in both workflows --- .github/workflows/build.yml | 2 +- .github/workflows/release.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 19cca12..f3275f9 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -39,7 +39,7 @@ jobs: restore-keys: pio-${{ runner.os }}- - name: Install PlatformIO - run: pip install --upgrade https://github.com/pioarduino/platformio-core/archive/develop.tar.gz + run: pip install --upgrade https://github.com/pioarduino/platformio-core/archive/487a894a713d36acbd9d858267076d0ab957d543.zip - name: Build firmware run: pio run -e seeed_indicator_rp2040 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 2301f7d..1cea849 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -54,7 +54,7 @@ jobs: restore-keys: pio-${{ runner.os }}- - name: Install PlatformIO - run: pip install --upgrade https://github.com/pioarduino/platformio-core/archive/develop.tar.gz + run: pip install --upgrade https://github.com/pioarduino/platformio-core/archive/487a894a713d36acbd9d858267076d0ab957d543.zip - name: Build firmware run: pio run -e seeed_indicator_rp2040