diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ae506da --- /dev/null +++ b/.gitignore @@ -0,0 +1,20 @@ +/build/ +/provisioning/ +/*.log +.pio/ +.pio +.vscode/ +*.elf +*.bin +*.map +# Emacs backup files +*~ +\#*\# +.\#* + +# Emacs dir locals (optional) +.dir-locals.el +/hold/ +.platformio_local/ + +.codex diff --git a/.gitmodules b/.gitmodules index af452a0..603b8f5 100644 --- a/.gitmodules +++ b/.gitmodules @@ -1,3 +1,15 @@ [submodule "external/microReticulum"] path = external/microReticulum url = https://github.com/attermann/microReticulum.git +[submodule "external/DebugLog"] + path = external/DebugLog + url = https://github.com/hideakitai/DebugLog.git +[submodule "external/ArxTypeTraits"] + path = external/ArxTypeTraits + url = https://github.com/hideakitai/ArxTypeTraits.git +[submodule "external/ArxContainer"] + path = external/ArxContainer + url = https://github.com/hideakitai/ArxContainer.git +[submodule "external/microReticulum_Firmware"] + path = external/microReticulum_Firmware + url = https://github.com/attermann/microReticulum_Firmware diff --git a/CMakeLists.txt b/CMakeLists.txt new file mode 100644 index 0000000..26fb0f4 --- /dev/null +++ b/CMakeLists.txt @@ -0,0 +1,68 @@ +cmake_minimum_required(VERSION 3.16) +project(microReticulumTbeam LANGUAGES C CXX) + +set(CMAKE_CXX_STANDARD 17) +set(CMAKE_CXX_STANDARD_REQUIRED ON) + +# -------------------------------------------------------------------- +# Portability shims +# +# microReticulum's CMake currently links against "msgpackc-cxx" and "MsgPack" +# as if they were system libraries, which breaks on machines that don't have +# those exact libs installed. +# +# Define shim targets so CMake treats them as targets (no "-l..."). +# If/when you want a real msgpack-cxx dependency, replace the shim with +# FetchContent/find_package and link to that instead. +# -------------------------------------------------------------------- +if(NOT TARGET msgpackc-cxx) + add_library(msgpackc-cxx INTERFACE) +endif() + +if(NOT TARGET MsgPack) + add_library(MsgPack INTERFACE) +endif() + + +# Pull in the microReticulum submodule build +add_subdirectory(external/microReticulum) + +# Provide DebugLog.h for microReticulum's MsgPack dependency +#set(DEBUGLOG_DIR ${CMAKE_SOURCE_DIR}/external/DebugLog) + +#if(TARGET ReticulumShared) +# target_include_directories(ReticulumShared PUBLIC ${DEBUGLOG_DIR}) +#endif() + +#if(TARGET ReticulumStatic) +# target_include_directories(ReticulumStatic PUBLIC ${DEBUGLOG_DIR}) +#endif() + +set(DEBUGLOG_DIR ${CMAKE_SOURCE_DIR}/external/DebugLog) +set(ARX_TYPETRAITS_DIR ${CMAKE_SOURCE_DIR}/external/ArxTypeTraits) +set(ARX_CONTAINER_DIR ${CMAKE_SOURCE_DIR}/external/ArxContainer) + +if(TARGET ReticulumShared) + target_include_directories(ReticulumShared PUBLIC + ${DEBUGLOG_DIR} + ${ARX_TYPETRAITS_DIR} + ${ARX_CONTAINER_DIR} + ) +endif() + +if(TARGET ReticulumStatic) + target_include_directories(ReticulumStatic PUBLIC + ${DEBUGLOG_DIR} + ${ARX_TYPETRAITS_DIR} + ${ARX_CONTAINER_DIR} + ) +endif() + +# We only need the static library for host-side tooling. +# The shared lib target requires system msgpack libs on some systems. +if(TARGET ReticulumShared) + set_target_properties(ReticulumShared PROPERTIES EXCLUDE_FROM_ALL YES) +endif() + +# Build our host-side tools +add_subdirectory(tools) diff --git a/README.md b/README.md index ebd0ead..250bee4 100644 --- a/README.md +++ b/README.md @@ -2,3 +2,14 @@ microReticulum For Field Testing With LilyGo T-Beam SUPREMES Field Testing Only. Used to specially program a group of T-Beams, each having the others' contact information and keys, which are then deployed in the field with people moving about to capture what was successfully sent and received and at what coordinates. Data is stored on SD cards and then retrieved at the end of the test an dumped into a PostgreSQL databse for analysis. + +## Dependency Direction +This repo is migrating from `external/microReticulum` to `external/microReticulum_Firmware`. + +Goal: +- Reuse upstream T-Beam SUPREME integration work in `microReticulum_Firmware`. +- Avoid reimplementing already-solved board integration (PMU, SD, RTC, GPS, LoRa setup). + +Status: +- Migration plan is tracked in `docs/microreticulum_firmware_migration.md`. +- Existing exercises remain functional during migration. diff --git a/docs/gps.md b/docs/gps.md new file mode 100644 index 0000000..3cffab4 --- /dev/null +++ b/docs/gps.md @@ -0,0 +1,18 @@ +The T-Beam ships with two options: +1) L76K +2) MAX-M10S + +NMEA (National Marine Electronics Association) format is a standard protocol used for communication between marine electronics devices, including GPS receivers, chart plotters, and other navigation equipment. It's a text-based, ASCII-encoded format that transmits data in "sentences" — short, comma-separated strings that start with a dollar sign ($) and end with a checksum. + +Key Characteristics: +Structure: Each sentence begins with $ followed by a talker ID (e.g., GP for GPS), a sentence type (e.g., GGA for Global Positioning System Fix Data), and data fields separated by commas. It ends with an asterisk (*) and a two-character hexadecimal checksum. +Example: $GPGGA,123519,4807.038,N,01131.000,E,1,08,0.9,545.4,M,46.9,M,,*47 +This is a GGA sentence providing time, latitude, longitude, fix quality, number of satellites, etc. +Common Sentence Types (relevant to GPS): +GGA: GPS fix data (position, time, satellites). +RMC: Recommended minimum navigation information (position, speed, course). +GSV: Satellites in view. +TXT: Text messages (e.g., module identification). +Baud Rate: Typically transmitted at 4800 or 9600 baud over serial interfaces. +Usage: Widely used in GPS modules like the L76K and UBLOX MAX-M10S, as seen in your project's code where NMEA sentences are parsed to extract location, time, and satellite data. +In your T-Beam exercises, the code reads serial data from the GPS module, buffers it into lines, and parses these NMEA sentences to detect the module type and extract useful information like UTC time and satellite counts. Both L76K and UBLOX modules output NMEA by default, though UBLOX can also support other formats like UBX binary. If you need details on parsing specific sentences or differences between modules, let me know! \ No newline at end of file diff --git a/docs/microreticulum_firmware_migration.md b/docs/microreticulum_firmware_migration.md new file mode 100644 index 0000000..d7c2584 --- /dev/null +++ b/docs/microreticulum_firmware_migration.md @@ -0,0 +1,45 @@ +# microReticulum Firmware Migration Plan + +## Objective +Migrate this project from the current submodule: +- `external/microReticulum` + +to: +- `external/microReticulum_Firmware` + +so this repo consumes existing T-Beam SUPREME integration instead of duplicating it. + +## Current State +- `.gitmodules` currently declares `external/microReticulum`. +- `.gitmodules` now also declares `external/microReticulum_Firmware`: + - URL: `https://github.com/attermann/microReticulum_Firmware` + - Current commit: `5dc607fc7227c46ccb19244e456782fbb7775eae` +- Exercises under `exercises/` are self-contained and currently compile independently. + +## Planned Migration Steps +1. Add the new submodule at `external/microReticulum_Firmware`. +2. Keep `external/microReticulum` temporarily for side-by-side validation. +3. Inventory reusable components from `microReticulum_Firmware`: + - board init / PMU power sequencing + - LoRa interface setup + - SD/RTC/GPS integration glue +4. Refactor local firmware entry points to call upstream components where possible. +5. Update exercise docs to distinguish: + - hardware smoke tests (local exercises) + - integration paths (from `microReticulum_Firmware`) +6. After parity validation, remove or archive `external/microReticulum`. + +Step-2 inventory output: +- `docs/microreticulum_firmware_step2_adoption_matrix.md` + +## Validation Checklist +- Build passes for all key exercises. +- SD/RTC/GPS startup behavior remains stable. +- LoRa send/receive smoke tests still pass. +- Fieldtest beacon path compiles and boots. + +## Submodule Commands Used +```bash +git submodule add https://github.com/attermann/microReticulum_Firmware external/microReticulum_Firmware +git submodule update --init --recursive +``` diff --git a/docs/microreticulum_firmware_step2_adoption_matrix.md b/docs/microreticulum_firmware_step2_adoption_matrix.md new file mode 100644 index 0000000..ea614d8 --- /dev/null +++ b/docs/microreticulum_firmware_step2_adoption_matrix.md @@ -0,0 +1,62 @@ +# Step 2 Adoption Matrix + +## Scope +Repository: `microReticulumTbeam` +Submodule source: `external/microReticulum_Firmware` + +## What Step 2 Means +Identify and wire the first low-risk points where this repo should consume existing board-integration logic from `microReticulum_Firmware`, instead of maintaining duplicate local assumptions. + +## High-Value Upstream Sources +- `external/microReticulum_Firmware/Boards.h` + - T-Beam Supreme pin map and feature flags. + - Includes SD pins (`SD_CS=47`, `SD_CLK=36`, `SD_MISO=37`, `SD_MOSI=35`), PMU I2C pins (`I2C_SDA=42`, `I2C_SCL=41`), LoRa pins. +- `external/microReticulum_Firmware/Power.h` + - AXP2101 setup sequence for T-Beam Supreme (`BOARD_TBEAM_S_V1`), including SD rail (BLDO1), ALDO rails, charging config. +- `external/microReticulum_Firmware/platformio.ini` + - `env:ttgo-t-beam-supreme` build model and dependency pattern. + +## Local Targets And First Consumers +1. `firmware/fieldtest_beacon/src/main.cpp` +- Why first: this is the integration entry point, not just a smoke test. +- Step-2 change made: SD CS now comes from `Boards.h` instead of hardcoded `10`. + +2. `exercises/04_SD_card/src/main.cpp` +- Why second: duplicates PMU + SD pin assumptions already present upstream. +- Planned consume-first item: PMU rail setup pattern from `Power.h`. + +3. `exercises/05_SD_Card_Watcher/src/main.cpp` +- Why third: extends `04` and should share the same PMU/pin source strategy. +- Planned consume-first item: same board/power source as `04`. + +4. `exercises/06_RTC_check/src/main.cpp` +- Why fourth: depends on PMU + I2C pin assumptions that overlap upstream. +- Planned consume-first item: board I2C pin source and PMU readiness sequence. + +## Current Wiring Done +- Added submodule: + - `external/microReticulum_Firmware` +- Wired one concrete consumer: + - `firmware/fieldtest_beacon/src/main.cpp` now includes `Boards.h` and uses `SD_CS`. + - `firmware/fieldtest_beacon/platformio.ini` now includes `external/microReticulum_Firmware` headers and sets `BOARD_MODEL=BOARD_TBEAM_S_V1`. +- Added local adapter: + - `shared/boards/tbeam_supreme_adapter.h` + - Exposes board pins and PMU setup based on upstream T-Beam Supreme definitions. +- Refactored exercises to consume adapter: + - `exercises/04_SD_card` + - `exercises/05_SD_Card_Watcher` + - `exercises/06_RTC_check` + +## Validation Status +- `firmware/fieldtest_beacon` now builds after adding required deps: + - `ArduinoJson` 7.x + - `MsgPack` + - `Crypto` (provides `Ed25519.h` and `Curve25519.h`) +- `exercises/04_SD_card` builds. +- `exercises/05_SD_Card_Watcher` builds. +- `exercises/06_RTC_check` builds. + +## Next Step Candidate +Reduce macro redefinition warnings emitted by `Boards.h` in adapter consumers: +- Option A: small local board-map header with only required constants copied from upstream board model. +- Option B: upstream contribution to make board capability macros conditional/override-safe. diff --git a/docs/pio.md b/docs/pio.md new file mode 100644 index 0000000..6b0a14c --- /dev/null +++ b/docs/pio.md @@ -0,0 +1,141 @@ +# pio +pio stands for "PlatformIO". + +In your context (ESP32 / T-Beam / PlatformIO workflows), **“pio” stands for:** + +> **PlatformIO** + +More precisely, `pio` is the **command-line interface (CLI)** for the PlatformIO environment. + +--- + +## What PlatformIO is (practically speaking) + +PlatformIO is an embedded development framework that wraps: + +* toolchains (GCC for ESP32, ARM, etc.) +* build system +* library manager +* uploader (esptool, etc.) +* serial monitor + +So instead of juggling: + +```bash +xtensa-esp32-elf-gcc +esptool.py +arduino-cli +``` + +…you use: + +```bash +pio run +pio run -t upload +pio device monitor +``` + +--- + +## What `pio` actually does in your workflow + +Given your T-Beam usage (as shown in the LilyGO docs, which explicitly include PlatformIO as a supported environment ), `pio` is orchestrating: + +### Build + +```bash +pio run +``` + +* compiles your firmware using the environment in `platformio.ini` + +### Upload + +```bash +pio run -t upload +``` + +* invokes the correct uploader (ESP32 → `esptool`) +* uses the selected `/dev/tty*` device + +### Monitor + +```bash +pio device monitor +``` + +* serial console at configured baud + +--- + +## Why it exists (the “systems view”) + +PlatformIO abstracts away: + +* board definitions +* pin mappings +* compiler flags +* library dependencies + +In your `platformio.ini`, for example: + +```ini +[env:node_a] +platform = espressif32 +board = esp32-s3-devkitc-1 +``` + +That single block drives: + +* toolchain selection +* linker scripts +* upload protocol +* memory layout + +--- + +## Important nuance (since you’re working low-level) + +`pio` is **not**: + +* a compiler +* a flasher +* an IDE + +It is a **build orchestration layer** (think “make + package manager + uploader + board database”). + +--- + +## Quick mental model + +If we translate to Unix analogies: + +| Concept | Analogy | +| ------------------- | -------------------------------------------- | +| PlatformIO | `portage + make + gcc + flash tool` combined | +| `pio run` | `make` | +| `pio run -t upload` | `make install` (but to hardware) | +| `platformio.ini` | hybrid of `Makefile` + config | + +--- + +## Bottom line + +When you type: + +```bash +pio run -t upload +``` + +You are saying: + +> “Use PlatformIO to build this firmware for the specified environment and flash it to the target device.” + + +## PlatformIO configuration file +platformio.ini is the configuration file. Each unit should have it's own entry so that the code can automaticaly be modified. + +INSERT IMAGE + +## Uploading Image +pio run -e flo -t upload --upload-port /dev/ttytFLO \ No newline at end of file diff --git a/exercises/00_usb_radio_check/README.md b/exercises/00_usb_radio_check/README.md new file mode 100644 index 0000000..0a51157 --- /dev/null +++ b/exercises/00_usb_radio_check/README.md @@ -0,0 +1,116 @@ +Command: + + pio run -e node_a -t upload --upload-port /dev/ttyACM0 +Here's a sample compile & upload session: + + (rnsenv) jlpoole@jp /usr/local/src/microreticulum/microReticulumTbeam/exercises/00_usb_radio_check $ pio run -e node_a -t upload --upload-port /dev/ttyACM0 + Processing node_a (platform: espressif32; framework: arduino; board: esp32-s3-devkitc-1) + ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ + Verbose mode can be enabled via `-v, --verbose` option + CONFIGURATION: https://docs.platformio.org/page/boards/espressif32/esp32-s3-devkitc-1.html + PLATFORM: Espressif 32 (6.12.0) > Espressif ESP32-S3-DevKitC-1-N8 (8 MB QD, No PSRAM) + HARDWARE: ESP32S3 240MHz, 320KB RAM, 8MB Flash + DEBUG: Current (esp-builtin) On-board (esp-builtin) External (cmsis-dap, esp-bridge, esp-prog, iot-bus-jtag, jlink, minimodule, olimex-arm-usb-ocd, olimex-arm-usb-ocd-h, olimex-arm-usb-tiny-h, olimex-jtag-tiny, tumpa) + PACKAGES: + - framework-arduinoespressif32 @ 3.20017.241212+sha.dcc1105b + - tool-esptoolpy @ 2.40900.250804 (4.9.0) + - tool-mkfatfs @ 2.0.1 + - tool-mklittlefs @ 1.203.210628 (2.3) + - tool-mkspiffs @ 2.230.0 (2.30) + - toolchain-riscv32-esp @ 8.4.0+2021r2-patch5 + - toolchain-xtensa-esp32s3 @ 8.4.0+2021r2-patch5 + LDF: Library Dependency Finder -> https://bit.ly/configure-pio-ldf + LDF Modes: Finder ~ chain, Compatibility ~ soft + Found 34 compatible libraries + Scanning dependencies... + Dependency Graph + |-- RadioLib @ 6.6.0 + |-- SPI @ 2.0.0 + Building in release mode + Retrieving maximum program size .pio/build/node_a/firmware.elf + Checking size .pio/build/node_a/firmware.elf + Advanced Memory Usage is available via "PlatformIO Home > Project Inspect" + RAM: [= ] 6.0% (used 19768 bytes from 327680 bytes) + Flash: [= ] 8.8% (used 294065 bytes from 3342336 bytes) + Configuring upload protocol... + AVAILABLE: cmsis-dap, esp-bridge, esp-builtin, esp-prog, espota, esptool, iot-bus-jtag, jlink, minimodule, olimex-arm-usb-ocd, olimex-arm-usb-ocd-h, olimex-arm-usb-tiny-h, olimex-jtag-tiny, tumpa + CURRENT: upload_protocol = esptool + Looking for upload port... + Using manually specified: /dev/ttyACM0 + Uploading .pio/build/node_a/firmware.bin + esptool.py v4.9.0 + Serial port /dev/ttyACM0 + Connecting... + Chip is ESP32-S3 (QFN56) (revision v0.2) + Features: WiFi, BLE, Embedded Flash 8MB (GD) + Crystal is 40MHz + USB mode: USB-Serial/JTAG + MAC: 48:ca:43:5a:93:a0 + Uploading stub... + Running stub... + Stub running... + Changing baud rate to 460800 + Changed. + Configuring flash size... + Flash will be erased from 0x00000000 to 0x00003fff... + Flash will be erased from 0x00008000 to 0x00008fff... + Flash will be erased from 0x0000e000 to 0x0000ffff... + Flash will be erased from 0x00010000 to 0x00057fff... + SHA digest in image updated + Compressed 15104 bytes to 10430... + Writing at 0x00000000... (100 %) + Wrote 15104 bytes (10430 compressed) at 0x00000000 in 0.2 seconds (effective 519.1 kbit/s)... + Hash of data verified. + Compressed 3072 bytes to 146... + Writing at 0x00008000... (100 %) + Wrote 3072 bytes (146 compressed) at 0x00008000 in 0.0 seconds (effective 584.3 kbit/s)... + Hash of data verified. + Compressed 8192 bytes to 47... + Writing at 0x0000e000... (100 %) + Wrote 8192 bytes (47 compressed) at 0x0000e000 in 0.1 seconds (effective 721.9 kbit/s)... + Hash of data verified. + Compressed 294432 bytes to 164378... + Writing at 0x00010000... (9 %) + Writing at 0x0001bc31... (18 %) + Writing at 0x00024a76... (27 %) + Writing at 0x0002a8b3... (36 %) + Writing at 0x0002fd85... (45 %) + Writing at 0x000350b4... (54 %) + Writing at 0x0003b4b4... (63 %) + Writing at 0x000455f6... (72 %) + Writing at 0x0004c5eb... (81 %) + Writing at 0x00051c54... (90 %) + Writing at 0x00057b42... (100 %) + Wrote 294432 bytes (164378 compressed) at 0x00010000 in 1.9 seconds (effective 1241.1 kbit/s)... + Hash of data verified. + + Leaving... + Hard resetting via RTS pin... + ==================================================================================== [SUCCESS] Took 8.73 seconds ==================================================================================== + + Environment Status Duration + ------------- -------- ------------ + node_a SUCCESS 00:00:08.731 + ==================================================================================== 1 succeeded in 00:00:08.731 ==================================================================================== + (rnsenv) jlpoole@jp /usr/local/src/microreticulum/microReticulumTbeam/exercises/00_usb_radio_check $ + +Here's an example of what displays in the console: + +Booting LoRa test... + +Initializing radio... +Radio chip: SX1262 +Frequency: 915.000 MHz +SF: 7 BW: 125 CR: 5 +radio.begin returned: 0 +alive 0 + Sending test frame... +TX state: 0 +Starting receive... +startReceive returned: 0 +alive 1 + Sending test frame... +TX state: 0 +Starting receive... +startReceive returned: 0 +alive 2 diff --git a/exercises/00_usb_radio_check/platformio.ini b/exercises/00_usb_radio_check/platformio.ini new file mode 100644 index 0000000..c50ba88 --- /dev/null +++ b/exercises/00_usb_radio_check/platformio.ini @@ -0,0 +1,43 @@ +; 20260212 ChatGPT +; $Id$ +; $HeadURL$ + +[platformio] +default_envs = node_a + +[env] +platform = espressif32 +framework = arduino +board = esp32-s3-devkitc-1 +monitor_speed = 115200 +lib_deps = + jgromes/RadioLib@^6.6.0 + +; Common build flags (pins from Meshtastic tbeam-s3-core variant.h) +build_flags = + -D LORA_CS=10 + -D LORA_MOSI=11 + -D LORA_SCK=12 + -D LORA_MISO=13 + -D LORA_RESET=5 + -D LORA_DIO1=1 + -D LORA_BUSY=4 + -D LORA_TCXO_VOLTAGE=1.8 + -D ARDUINO_USB_MODE=1 + -D ARDUINO_USB_CDC_ON_BOOT=1 + +; Radio params for println/printf + RadioLib init + -D LORA_FREQ=915.000 + -D LORA_SF=7 + -D LORA_BW=125 + -D LORA_CR=5 + +[env:node_a] +build_flags = + ${env.build_flags} + -D NODE_LABEL=\"A\" + +[env:node_b] +build_flags = + ${env.build_flags} + -D NODE_LABEL=\"B\" diff --git a/exercises/00_usb_radio_check/src/main.cpp b/exercises/00_usb_radio_check/src/main.cpp new file mode 100644 index 0000000..108ba16 --- /dev/null +++ b/exercises/00_usb_radio_check/src/main.cpp @@ -0,0 +1,60 @@ +#include +#include +#include + +#ifndef LORA_FREQ +#define LORA_FREQ 915.000 +#endif + +#ifndef LORA_SF +#define LORA_SF 7 +#endif + +#ifndef LORA_BW +#define LORA_BW 125 +#endif + +#ifndef LORA_CR +#define LORA_CR 5 +#endif + + +// SX1262 on T-Beam Supreme (tbeam-s3-core pinout) +SX1262 radio = new Module(LORA_CS, LORA_DIO1, LORA_RESET, LORA_BUSY); +int state; // = radio.begin(915.0, 125.0, 7, 5, 0x12, 14); + +void setup() { + Serial.begin(115200); + delay(2000); // give USB time to enumerate + Serial.println("Booting LoRa test..."); + Serial.println(); + + Serial.println("Initializing radio..."); + SPI.begin(LORA_SCK, LORA_MISO, LORA_MOSI, LORA_CS); + Serial.printf("Radio chip: SX1262\r\n"); + Serial.printf("Frequency: %.3f MHz\r\n", (double)LORA_FREQ); + Serial.printf("SF: %d BW: %d CR: %d\r\n", LORA_SF, LORA_BW, LORA_CR); + + int state = radio.begin(915.0, 125.0, 7, 5, 0x12, 14); + + Serial.printf("radio.begin returned: %d\r\n", state); + + + +} + +void loop() { + static uint32_t counter = 0; + Serial.printf("alive %lu\n", counter++); + + Serial.println("Sending test frame..."); + int tx = radio.transmit("USB RADIO CHECK"); + Serial.printf("TX state: %d\r\n", tx); + + // we're not expecting to receive anything, just testing that we + // can call Receive() + Serial.println("Starting receive..."); + state = radio.startReceive(); + Serial.printf("startReceive returned: %d\r\n", state); + delay(1000); +} diff --git a/exercises/01_lora_ascii_pingpong/README.md b/exercises/01_lora_ascii_pingpong/README.md new file mode 100644 index 0000000..b769466 --- /dev/null +++ b/exercises/01_lora_ascii_pingpong/README.md @@ -0,0 +1,249 @@ +# Exercise: LoRa Transmission Validation (SX1262) + +## Overview + +This exercise validates raw LoRa packet transmission from the **LILYGO T-Beam SUPREME V3.0** using the onboard **SX1262** radio. + +The objective is to: + +1. Transmit deterministic LoRa packets at known parameters. +2. Confirm successful reception using: + + * A second T-Beam + * A Waveshare SX1303 concentrator sniffer + * Or any SDR/LoRa receiver configured with identical PHY settings +3. Verify correct alignment of frequency, spreading factor, bandwidth, and coding rate. + +This is a **PHY-layer validation exercise**, not LoRaWAN. + +--- + +## Hardware + +### Transmitter + +* Board: **LILYGO T-Beam SUPREME V3.0** +* MCU: ESP32-S3 +* Radio: SX1262 +* Antenna: 915 MHz tuned antenna +* Power: USB-C or 18650 battery + +### Receiver / Sniffer + +Any device capable of raw LoRa reception with manual PHY configuration: + +* Second T-Beam (SX1262) +* Waveshare SX1303 + `lora_pkt_fwd` +* SDR with LoRa demodulator + +--- + +## LoRa Radio Specifications + +The sniffer **must** match these parameters exactly. + +| Parameter | Value | +| ---------------- | ------------------ | +| Radio Chip | SX1262 | +| Frequency | **915.000 MHz** | +| Modulation | LoRa | +| Bandwidth | **125 kHz** | +| Spreading Factor | **SF8** | +| Coding Rate | **4/5** | +| Preamble Length | 8 symbols | +| Sync Word | 0x12 (Public LoRa) | +| CRC | Enabled | +| IQ Inversion | Disabled | +| Output Power | 14 dBm (default) | + +--- + +## Important Notes for Sniffer Operators + +### 1. Frequency + +Ensure your sniffer JSON or configuration file contains: + +``` +"freq": 915000000 +``` + +If using SX130x HAL: + +``` +915000000 +``` + +No offset. No channel hopping. + +--- + +### 2. Spreading Factor + +Must be: + +``` +SF8 +``` + +If the sniffer is set to multi-SF mode, confirm that SF8 is enabled. + +--- + +### 3. Bandwidth + +``` +125000 Hz +``` + +Not 250 kHz. Not 500 kHz. + +--- + +### 4. Coding Rate + +``` +4/5 +``` + +Some interfaces represent this as: + +``` +CR = 1 +``` + +--- + +### 5. Sync Word + +If your sniffer filters on sync word: + +``` +0x12 +``` + +This is the public LoRa sync word (not LoRaWAN private). + +--- + +## Expected Packet Behavior + +The transmitter: + +* Sends a short ASCII payload +* Repeats at a fixed interval +* Does not use LoRaWAN +* Does not use encryption +* Does not use MAC layer framing + +Sniffer output should display: + +* RSSI +* SNR +* SF8 +* BW125 +* Payload length matching transmitter + +--- + +## Confirming Correct Alignment + +A properly aligned sniffer will show: + +* Stable RSSI +* Correct SF detection (SF8) +* Clean CRC pass +* No excessive packet loss at short range + +If you see: + +* No packets → Check frequency mismatch first. +* Packets but CRC fail → Check bandwidth mismatch. +* Packets only intermittently → Check spreading factor. + +--- + +## SX1262 SPI Mapping (T-Beam SUPREME) + +For reference, the radio is connected as follows: + +| Signal | ESP32-S3 Pin | +| ------ | ------------ | +| SCK | 12 | +| MISO | 13 | +| MOSI | 11 | +| CS | 10 | +| RESET | 5 | +| BUSY | 4 | +| DIO1 | 1 | + +These match the board’s hardware routing. + +--- + +## Build & Flash + +### PlatformIO + +1. Open project folder +2. Select correct environment +3. Compile +4. Upload via USB-C +5. Monitor serial output + +### Arduino IDE + +* Board: ESP32S3 Dev Module +* Flash: 8MB +* PSRAM: QSPI +* Upload speed: 921600 +* USB Mode: CDC and JTAG + +--- + +## Purpose of This Exercise + +This exercise verifies: + +* SPI communication with SX1262 +* Radio configuration correctness +* Antenna functionality +* Sniffer alignment +* Baseline RF performance + +It is intended as the foundational RF validation step before: + +* Reticulum interface integration +* microReticulum radio abstraction +* LoRa time-synchronized experiments +* Multi-node field testing + +--- + +## If You Cannot See Packets + +Work through this checklist: + +1. Confirm antenna attached. +2. Confirm sniffer at 915 MHz. +3. Confirm SF8. +4. Confirm BW125. +5. Reduce distance to < 2 meters. +6. Increase TX power to 17–20 dBm for testing. +7. Confirm no regional regulatory lock mismatch. + +--- + +## Relationship to `main.cpp` + +This README corresponds to the current exercise implementation in: + +``` +main.cpp +``` + +See source for definitive parameter values + +If you modify radio parameters in code, update this README accordingly. + + diff --git a/exercises/01_lora_ascii_pingpong/platformio.ini b/exercises/01_lora_ascii_pingpong/platformio.ini new file mode 100644 index 0000000..999de85 --- /dev/null +++ b/exercises/01_lora_ascii_pingpong/platformio.ini @@ -0,0 +1,57 @@ +; 20260212 ChatGPT +; $Id$ +; $HeadURL$ + +[platformio] +default_envs = amy + +[env] +platform = espressif32 +framework = arduino +board = esp32-s3-devkitc-1 +monitor_speed = 115200 +lib_deps = + jgromes/RadioLib@^6.6.0 + +; Common build flags (pins from Meshtastic tbeam-s3-core variant.h) +build_flags = + -D LORA_CS=10 + -D LORA_MOSI=11 + -D LORA_SCK=12 + -D LORA_MISO=13 + -D LORA_RESET=5 + -D LORA_DIO1=1 + -D LORA_BUSY=4 + -D LORA_TCXO_VOLTAGE=1.8 + -D ARDUINO_USB_MODE=1 + -D ARDUINO_USB_CDC_ON_BOOT=1 + +[env:amy] +extends = env +build_flags = + ${env.build_flags} + -D NODE_LABEL=\"Amy\" + +[env:bob] +extends = env +build_flags = + ${env.build_flags} + -D NODE_LABEL=\"Bob\" + +[env:cy] +extends = env +build_flags = + ${env.build_flags} + -D NODE_LABEL=\"Cy\" + +[env:dan] +extends = env +build_flags = + ${env.build_flags} + -D NODE_LABEL=\"Dan\" + +[env:ed] +extends = env +build_flags = + ${env.build_flags} + -D NODE_LABEL=\"Ed\" \ No newline at end of file diff --git a/exercises/01_lora_ascii_pingpong/src/main.cpp b/exercises/01_lora_ascii_pingpong/src/main.cpp new file mode 100644 index 0000000..b13fd1e --- /dev/null +++ b/exercises/01_lora_ascii_pingpong/src/main.cpp @@ -0,0 +1,151 @@ +// 20260212 ChatGPT +// $Id$ +// $HeadURL$ + +#include +#include +#include + +// --- Compile-time label --- +#ifndef NODE_LABEL +#define NODE_LABEL "?" +#endif +#ifndef UNIT_NAME + #define UNIT_NAME "UNNAMED" +#endif + +// --- Pins injected via platformio.ini build_flags --- +#ifndef LORA_CS +#error "LORA_CS not defined" +#endif +#ifndef LORA_DIO1 +#error "LORA_DIO1 not defined" +#endif +#ifndef LORA_RESET +#error "LORA_RESET not defined" +#endif +#ifndef LORA_BUSY +#error "LORA_BUSY not defined" +#endif + +// SX1262 on T-Beam Supreme (tbeam-s3-core pinout) +SX1262 radio = new Module(LORA_CS, LORA_DIO1, LORA_RESET, LORA_BUSY); + +static volatile bool g_rx_flag = false; + + +static void onDio1Rise() { + g_rx_flag = true; +} + +static void print_config() { + Serial.printf("Node=%s\n", NODE_LABEL); + Serial.printf("Pins: CS=%d DIO1=%d RST=%d BUSY=%d SCK=%d MISO=%d MOSI=%d\r\n", + (int)LORA_CS, (int)LORA_DIO1, (int)LORA_RESET, (int)LORA_BUSY, + (int)LORA_SCK, (int)LORA_MISO, (int)LORA_MOSI); + Serial.printf("LoRa: freq=915.0 BW=125 SF=7 CR=5 txp=14\r\n"); +} + +void setup() { + Serial.begin(115200); + delay(250); + Serial.println(); + Serial.println("Exercise 01: LoRa ASCII ping-pong (serial only)"); + + // Ensure SPI pins match the variant + SPI.begin(LORA_SCK, LORA_MISO, LORA_MOSI, LORA_CS); + + int state = radio.begin(915.0 /* MHz */, 125.0 /* kHz */, 7 /* SF */, 5 /* CR */, 0x12 /* sync */, 14 /* dBm */); + if (state != RADIOLIB_ERR_NONE) { + Serial.printf("ERROR: radio.begin failed, code=%d\r\n", state); + while (true) delay(1000); + } + + // Match Meshtastic-like wiring assumptions for SX1262 modules: + // - DIO2 used as RF switch + // - TCXO at 1.8V + //OLD radio.setDio2AsRfSwitch(true); + //OLD radio.setTcxoVoltage((float)LORA_TCXO_VOLTAGE); + // below is replacement for above 2 lines: + + //maybe_setDio2AsRfSwitch(radio, true); + //maybe_setTcxoVoltage(radio, (float)LORA_TCXO_VOLTAGE); + + // end of replacement + + // Set up RX interrupt + radio.setDio1Action(onDio1Rise); + + // Start receiving + state = radio.startReceive(); + if (state != RADIOLIB_ERR_NONE) { + Serial.printf("ERROR: startReceive failed, code=%d\r\n", state); + while (true) delay(1000); + } + + print_config(); +} + +void loop() { + // Periodic TX (with a label-based offset to reduce collisions) + static uint32_t next_tx_ms = 0; + static uint32_t iter = 0; + uint32_t now = millis(); + + if (next_tx_ms == 0) { + // Offset A and B so they don't always collide + uint32_t offset = (NODE_LABEL[0] == 'A') ? 500 : 1500; + next_tx_ms = now + offset; + } + + if ((int32_t)(now - next_tx_ms) >= 0) { + next_tx_ms = now + 2000; // 2 seconds for this smoke test + + // String msg = String("I am ") + NODE_LABEL + " iter=" + String(iter++); + String msg = String("") + NODE_LABEL + " says hi. iter=" + String(iter++); + Serial.printf("TX: %s\r\n", msg.c_str()); + + //int tx = radio.transmit(msg); + //if (tx != RADIOLIB_ERR_NONE) { + // Serial.printf("TX ERROR code=%d\r\n", tx); + // } + + // After transmit, resume RX + //radio.startReceive(); + // DIO1 triggers on TX-done as well as RX-done. + // If left armed, TX completion looks like RX. + g_rx_flag = false; + radio.clearDio1Action(); + + int tx = radio.transmit(msg); + if (tx != RADIOLIB_ERR_NONE) { + Serial.printf("TX ERROR code=%d\r\n", tx); + } + + // Re-arm RX interrupt and resume RX + g_rx_flag = false; + radio.setDio1Action(onDio1Rise); + radio.startReceive(); + + } + + // RX handling + if (g_rx_flag) { + g_rx_flag = false; + + String rx; + int state = radio.readData(rx); + if (state == RADIOLIB_ERR_NONE) { + Serial.printf("RX: %s | RSSI=%.1f SNR=%.1f\r\n", + rx.c_str(), radio.getRSSI(), radio.getSNR()); + } else { + Serial.printf("RX ERROR code=%d\r\n", state); + } + + // Keep receiving + radio.startReceive(); + } + + delay(5); +} + diff --git a/exercises/02_oled_display/README.md b/exercises/02_oled_display/README.md new file mode 100644 index 0000000..e69d14b --- /dev/null +++ b/exercises/02_oled_display/README.md @@ -0,0 +1,55 @@ +## Exercise 02: OLED Display + +This exercise demonstrates multiple OLED rendering patterns on the LilyGO T-Beam Supreme (SH1106). + +The firmware runs 3 demos in sequence, then shows a restart banner and repeats forever. + +## Demo Sequence + +### Demo 1: Scrolling Text +- Shows `Hello #` lines. +- New lines are added below prior lines. +- Up to 5 lines are visible; old lines roll off the top. + +### Demo 2: Fill Then Erase +- Displays `Count 1` through `Count 10`. +- Shows a short `Clearing screen...` message. +- Erases the OLED to demonstrate explicit clear behavior. + +### Demo 3: Multi-Page Rotation +- Rotates through 3 distinct text pages. +- Each page has different content/layout to demonstrate refresh transitions. +- Includes a changing packet counter field on one page. + +### Restart Banner +- Shows `Restarting 3 demos` for 5 seconds. +- Sequence restarts at Demo 1. + +## Expected Serial Output +Serial output (115200) prints phase changes, for example: +- `Demo 1/3: scrolling Hello #` +- `Demo 2/3: clear screen after 10` +- `Demo 3/3: three-page rotation` +- `Restarting 3 demos` + +### Build +From this directory: + +```bash +source /home/jlpoole/rnsenv/bin/activate +pio run -e node_a +``` + +### Upload +Set your USB port: + +```bash +source /home/jlpoole/rnsenv/bin/activate +pio run -e node_a -t upload --upload-port /dev/ttyACM0 +``` + +### Serial Monitor + +```bash +screen /dev/ttyACM0 115200 +``` diff --git a/exercises/02_oled_display/platformio.ini b/exercises/02_oled_display/platformio.ini new file mode 100644 index 0000000..7016042 --- /dev/null +++ b/exercises/02_oled_display/platformio.ini @@ -0,0 +1,32 @@ +; 20260213 ChatGPT +; $Id$ +; $HeadURL$ + +[platformio] +default_envs = node_a + +[env] +platform = espressif32 +framework = arduino +board = esp32-s3-devkitc-1 +monitor_speed = 115200 +lib_deps = + olikraus/U8g2@^2.36.4 + +; Common build flags (pins from Meshtastic tbeam-s3-core variant.h) +build_flags = + -D OLED_SDA=17 + -D OLED_SCL=18 + -D OLED_ADDR=0x3C + -D ARDUINO_USB_MODE=1 + -D ARDUINO_USB_CDC_ON_BOOT=1 + +[env:node_a] +build_flags = + ${env.build_flags} + -D NODE_LABEL=\"A\" + +[env:node_b] +build_flags = + ${env.build_flags} + -D NODE_LABEL=\"B\" diff --git a/exercises/02_oled_display/src/main.cpp b/exercises/02_oled_display/src/main.cpp new file mode 100644 index 0000000..f45cd5f --- /dev/null +++ b/exercises/02_oled_display/src/main.cpp @@ -0,0 +1,162 @@ +// 20260213 ChatGPT +// $Id$ +// $HeadURL$ + +#include +#include +#include + +#ifndef OLED_SDA +#define OLED_SDA 17 +#endif + +#ifndef OLED_SCL +#define OLED_SCL 18 +#endif + +#ifndef OLED_ADDR +#define OLED_ADDR 0x3C +#endif + +// LilyGO T-Beam Supreme uses SH1106 OLED on I2C. +U8G2_SH1106_128X64_NONAME_F_HW_I2C oled(U8G2_R0, /* reset=*/U8X8_PIN_NONE); + +static const uint8_t kMaxLines = 5; +static String g_lines[kMaxLines]; +static uint32_t g_iteration = 1; +static uint32_t g_pageCounter = 1; + +static void addLine(const String& line) { + for (uint8_t i = 0; i < kMaxLines - 1; ++i) { + g_lines[i] = g_lines[i + 1]; + } + g_lines[kMaxLines - 1] = line; +} + +static void drawLines() { + oled.clearBuffer(); + oled.setFont(u8g2_font_6x10_tf); + + const int yStart = 12; + const int yStep = 12; + for (uint8_t i = 0; i < kMaxLines; ++i) { + if (g_lines[i].length() == 0) { + continue; + } + oled.drawUTF8(0, yStart + (i * yStep), g_lines[i].c_str()); + } + oled.sendBuffer(); +} + +static void clearLines() { + for (uint8_t i = 0; i < kMaxLines; ++i) { + g_lines[i] = ""; + } +} + +static void showCentered(const char* line1, const char* line2 = nullptr, const char* line3 = nullptr) { + oled.clearBuffer(); + oled.setFont(u8g2_font_6x10_tf); + + if (line1) oled.drawUTF8(0, 16, line1); + if (line2) oled.drawUTF8(0, 32, line2); + if (line3) oled.drawUTF8(0, 48, line3); + + oled.sendBuffer(); +} + +static void demo1_scrollingHello() { + Serial.println("Demo 1/3: scrolling Hello #"); + clearLines(); + for (uint8_t i = 0; i < 10; ++i) { + String line = "Hello #" + String(g_iteration++); + addLine(line); + drawLines(); + delay(1000); + } +} + +static void demo2_clearAfterTen() { + Serial.println("Demo 2/3: clear screen after 10"); + clearLines(); + for (uint8_t i = 1; i <= 10; ++i) { + String line = "Count " + String(i); + addLine(line); + drawLines(); + delay(500); + } + + showCentered("Demo 2", "Clearing screen..."); + delay(1000); + oled.clearDisplay(); + oled.sendBuffer(); + delay(1000); +} + +static void drawPage(uint8_t page) { + oled.clearBuffer(); + oled.setFont(u8g2_font_6x10_tf); + + if (page == 0) { + oled.drawUTF8(0, 12, "Demo 3 - Page 1/3"); + oled.drawUTF8(0, 28, "GPS: 37.7749 N"); + oled.drawUTF8(0, 40, "LON: 122.4194 W"); + oled.drawUTF8(0, 56, "Fix: 3D"); + } else if (page == 1) { + oled.drawUTF8(0, 12, "Demo 3 - Page 2/3"); + oled.drawUTF8(0, 28, "LoRa RSSI: -92 dBm"); + oled.drawUTF8(0, 40, "LoRa SNR: +8.5 dB"); + oled.drawUTF8(0, 56, "Pkts: " ); + oled.setCursor(38, 56); + oled.print(g_pageCounter++); + } else { + oled.drawUTF8(0, 12, "Demo 3 - Page 3/3"); + oled.drawUTF8(0, 28, "Node: TBM-SUPREME"); + oled.drawUTF8(0, 40, "Mode: Field Test"); + oled.drawUTF8(0, 56, "OLED refresh demo"); + } + + oled.sendBuffer(); +} + +static void demo3_threePages() { + Serial.println("Demo 3/3: three-page rotation"); + for (uint8_t round = 0; round < 3; ++round) { + drawPage(0); + delay(700); + drawPage(1); + delay(700); + drawPage(2); + delay(700); + } +} + +static void showRestartBanner() { + Serial.println("Restarting 3 demos"); + showCentered("Restarting 3 demos", "Cycle begins again", "in 5 seconds..."); + delay(5000); +} + +void setup() { + Serial.begin(115200); + delay(250); + + Wire.begin(OLED_SDA, OLED_SCL); + oled.setI2CAddress(OLED_ADDR << 1); // U8g2 expects 8-bit address. + oled.begin(); + + oled.clearBuffer(); + oled.setFont(u8g2_font_6x10_tf); + oled.drawUTF8(0, 12, "Exercise 02 OLED"); + oled.sendBuffer(); + + Serial.println("Exercise 02: OLED display loop"); + Serial.printf("OLED SDA=%d SCL=%d ADDR=0x%02X\r\n", OLED_SDA, OLED_SCL, OLED_ADDR); +} + +void loop() { + demo1_scrollingHello(); + demo2_clearAfterTen(); + demo3_threePages(); + showRestartBanner(); +} diff --git a/exercises/04_SD_card/README.md b/exercises/04_SD_card/README.md new file mode 100644 index 0000000..4ef7a53 --- /dev/null +++ b/exercises/04_SD_card/README.md @@ -0,0 +1,37 @@ +## Exercise 04: SD Card + +This exercise loops forever. Each cycle: + +1. Prints `Sleeping 10 seconds` and waits 10 seconds. +2. Detects and mounts the SD card. +3. Prints card and filesystem info (type/size/used). +4. Writes `/Exercise_04_test.txt` with: + - `This is a test` +5. Ensures nested directories exist: + - `/test/testsub1/testsubsub1` +6. Writes nested file: + - `/test/testsub1/testsubsub1/Exercise_04_test.txt` +7. If either target file already exists, prints warning, erases it, then recreates it. +8. Demonstrates permission behavior: + - Notes FAT does not provide Unix `chmod/chown`. + - Shows access behavior via `FILE_READ` vs `FILE_WRITE` modes. + +## Build + +```bash +source /home/jlpoole/rnsenv/bin/activate +pio run -e node_a +``` + +## Upload + +```bash +source /home/jlpoole/rnsenv/bin/activate +pio run -e node_a -t upload --upload-port /dev/ttyACM0 +``` + +## Monitor + +```bash +screen /dev/ttyACM0 115200 +``` diff --git a/exercises/04_SD_card/platformio.ini b/exercises/04_SD_card/platformio.ini new file mode 100644 index 0000000..500e012 --- /dev/null +++ b/exercises/04_SD_card/platformio.ini @@ -0,0 +1,33 @@ +; 20260213 ChatGPT +; $Id$ +; $HeadURL$ + +[platformio] +default_envs = node_a + +[env] +platform = espressif32 +framework = arduino +board = esp32-s3-devkitc-1 +monitor_speed = 115200 +lib_deps = + lewisxhe/XPowersLib@0.3.3 + Wire + +; SD pins based on T-Beam S3 core pin mapping +build_flags = + -I ../../shared/boards + -I ../../external/microReticulum_Firmware + -D BOARD_MODEL=BOARD_TBEAM_S_V1 + -D ARDUINO_USB_MODE=1 + -D ARDUINO_USB_CDC_ON_BOOT=1 + +[env:node_a] +build_flags = + ${env.build_flags} + -D NODE_LABEL=\"A\" + +[env:node_b] +build_flags = + ${env.build_flags} + -D NODE_LABEL=\"B\" diff --git a/exercises/04_SD_card/src/main.cpp b/exercises/04_SD_card/src/main.cpp new file mode 100644 index 0000000..3b64391 --- /dev/null +++ b/exercises/04_SD_card/src/main.cpp @@ -0,0 +1,228 @@ +// 20260213 ChatGPT +// $Id$ +// $HeadURL$ + +#include +#include +#include +#include +#include "tbeam_supreme_adapter.h" + +static SPIClass sdSpiH(HSPI); +static SPIClass sdSpiF(FSPI); +static SPIClass* g_sdSpi = nullptr; +static const char* g_sdBusName = "none"; +static uint32_t g_sdFreq = 0; +static XPowersLibInterface* g_pmu = nullptr; + +static const char* kRootTestFile = "/Exercise_04_test.txt"; +static const char* kNestedDir = "/test/testsub1/testsubsub1"; +static const char* kNestedTestFile = "/test/testsub1/testsubsub1/Exercise_04_test.txt"; +static const char* kPayload = "This is a test"; + +static bool initPmuForSdPower() { + return tbeam_supreme::initPmuForPeripherals(g_pmu, &Serial); +} + +static const char* cardTypeToString(uint8_t type) { + switch (type) { + case CARD_MMC: return "MMC"; + case CARD_SD: return "SDSC"; + case CARD_SDHC: return "SDHC/SDXC"; + default: return "UNKNOWN"; + } +} + +static bool tryMountWithBus(SPIClass& bus, const char* busName, uint32_t hz) { + SD.end(); + bus.end(); + delay(10); + + // Keep inactive devices deselected on shared bus lines. + pinMode(tbeam_supreme::sdCs(), OUTPUT); + digitalWrite(tbeam_supreme::sdCs(), HIGH); + pinMode(tbeam_supreme::imuCs(), OUTPUT); + digitalWrite(tbeam_supreme::imuCs(), HIGH); + + bus.begin(tbeam_supreme::sdSck(), tbeam_supreme::sdMiso(), tbeam_supreme::sdMosi(), tbeam_supreme::sdCs()); + delay(2); + + Serial.printf("SD: trying bus=%s freq=%lu Hz\r\n", busName, (unsigned long)hz); + if (!SD.begin(tbeam_supreme::sdCs(), bus, hz)) { + Serial.println("SD: mount failed (possible non-FAT format, power, or bus issue)"); + return false; + } + + uint8_t cardType = SD.cardType(); + if (cardType == CARD_NONE) { + SD.end(); + return false; + } + + g_sdSpi = &bus; + g_sdBusName = busName; + g_sdFreq = hz; + return true; +} + +static bool mountCard() { + const uint32_t freqs[] = {400000, 1000000, 4000000, 10000000}; + + for (uint8_t i = 0; i < (sizeof(freqs) / sizeof(freqs[0])); ++i) { + if (tryMountWithBus(sdSpiH, "HSPI", freqs[i])) { + Serial.println("SD: card detected and mounted"); + return true; + } + } + for (uint8_t i = 0; i < (sizeof(freqs) / sizeof(freqs[0])); ++i) { + if (tryMountWithBus(sdSpiF, "FSPI", freqs[i])) { + Serial.println("SD: card detected and mounted"); + return true; + } + } + + Serial.println("SD: begin() failed on all bus/frequency attempts"); + Serial.println(" likely card absent, bad format, pin mismatch, or hardware issue"); + return false; +} + +static void printCardInfo() { + uint8_t cardType = SD.cardType(); + uint64_t cardSizeMB = SD.cardSize() / (1024ULL * 1024ULL); + uint64_t totalMB = SD.totalBytes() / (1024ULL * 1024ULL); + uint64_t usedMB = SD.usedBytes() / (1024ULL * 1024ULL); + + Serial.printf("SD type: %s\r\n", cardTypeToString(cardType)); + Serial.printf("SD size: %llu MB\r\n", cardSizeMB); + Serial.printf("FS total: %llu MB\r\n", totalMB); + Serial.printf("FS used : %llu MB\r\n", usedMB); + Serial.printf("SPI bus: %s @ %lu Hz\r\n", g_sdBusName, (unsigned long)g_sdFreq); +} + +static bool ensureDirRecursive(const char* path) { + String full(path); + if (!full.startsWith("/")) { + full = "/" + full; + } + + int start = 1; + while (start > 0 && start < (int)full.length()) { + int slash = full.indexOf('/', start); + String partial = (slash < 0) ? full : full.substring(0, slash); + + if (!SD.exists(partial.c_str())) { + Serial.printf("Creating directory: %s\r\n", partial.c_str()); + if (!SD.mkdir(partial.c_str())) { + Serial.printf("ERROR: mkdir failed for %s\r\n", partial.c_str()); + return false; + } + } + + if (slash < 0) { + break; + } + start = slash + 1; + } + + return true; +} + +static bool rewriteFile(const char* path, const char* payload) { + if (SD.exists(path)) { + Serial.printf("WARNING: %s exists ... erasing\r\n", path); + if (!SD.remove(path)) { + Serial.printf("ERROR: failed to erase %s\r\n", path); + return false; + } + } + + File f = SD.open(path, FILE_WRITE); + if (!f) { + Serial.printf("ERROR: failed to create %s\r\n", path); + return false; + } + + size_t wrote = f.println(payload); + f.close(); + + if (wrote == 0) { + Serial.printf("ERROR: write failed for %s\r\n", path); + return false; + } + + Serial.printf("Wrote file: %s\r\n", path); + return true; +} + +static void permissionsDemo(const char* path) { + Serial.println("Permissions demo:"); + Serial.println(" SD/FAT does not support Unix permissions (chmod/chown)."); + Serial.println(" Access control is by open mode (FILE_READ/FILE_WRITE)."); + + File r = SD.open(path, FILE_READ); + if (!r) { + Serial.printf(" Could not open %s as FILE_READ\r\n", path); + return; + } + + Serial.printf(" FILE_READ open succeeded, size=%u bytes\r\n", (unsigned)r.size()); + size_t writeInReadMode = r.print("attempt write while opened read-only"); + if (writeInReadMode == 0) { + Serial.println(" As expected, write via FILE_READ handle was blocked."); + } else { + Serial.printf(" NOTE: write via FILE_READ returned %u (unexpected)\r\n", (unsigned)writeInReadMode); + } + r.close(); +} + +void setup() { + Serial.begin(115200); + Serial.println("Sleeping for 5 seconds to allow Serial Monitor connection..."); + delay(5000); // Time to open Serial Monitor after reset + + Serial.println("\r\n=================================================="); + Serial.println("Exercise 04: SD card test loop"); + Serial.println("=================================================="); + Serial.printf("Pins: CS=%d SCK=%d MISO=%d MOSI=%d\r\n", + tbeam_supreme::sdCs(), tbeam_supreme::sdSck(), + tbeam_supreme::sdMiso(), tbeam_supreme::sdMosi()); + Serial.printf("PMU I2C: SDA1=%d SCL1=%d\r\n", + tbeam_supreme::i2cSda(), tbeam_supreme::i2cScl()); + Serial.println("Note: SD must be FAT16/FAT32 for Arduino SD library.\r\n"); + + initPmuForSdPower(); +} + +void loop() { + Serial.println("Sleeping 10 seconds"); + delay(10000); + + if (!mountCard()) { + Serial.println("SD step skipped this cycle.\r\n"); + return; + } + + printCardInfo(); + + if (!rewriteFile(kRootTestFile, kPayload)) { + SD.end(); + Serial.println("Cycle ended due to root file error.\r\n"); + return; + } + + if (!ensureDirRecursive(kNestedDir)) { + SD.end(); + Serial.println("Cycle ended due to directory creation error.\r\n"); + return; + } + + if (!rewriteFile(kNestedTestFile, kPayload)) { + SD.end(); + Serial.println("Cycle ended due to nested file error.\r\n"); + return; + } + + permissionsDemo(kRootTestFile); + SD.end(); + Serial.println("Cycle complete.\r\n"); +} diff --git a/exercises/05_SD_Card_Watcher/README.md b/exercises/05_SD_Card_Watcher/README.md new file mode 100644 index 0000000..d993448 --- /dev/null +++ b/exercises/05_SD_Card_Watcher/README.md @@ -0,0 +1,39 @@ +## Exercise 05: SD Card Watcher + +This exercise continuously watches SD card presence and prints state-change events. + +Watcher behavior: + +1. Initializes PMU and enables SD power rail (AXP2101 BLDO1). +2. Polls for card changes with debounced state transitions. +3. Emits events only on change: + - `EVENT: card inserted/mounted` + - `EVENT: card removed/unavailable` + - `EVENT: no card detected` +4. On mount event, prints card info and runs SD write workflow. +5. Every 15 seconds while mounted, runs a periodic write/permission check. +6. Uses fast preferred probe (`HSPI @ 400k`) and occasional full fallback scan. + +Files used in this exercise: +- `/Exercise_05_test.txt` +- `/test/testsub1/testsubsub1/Exercise_05_test.txt` + +## Build + +```bash +source /home/jlpoole/rnsenv/bin/activate +pio run -e node_a +``` + +## Upload + +```bash +source /home/jlpoole/rnsenv/bin/activate +pio run -e node_a -t upload --upload-port /dev/ttyACM0 +``` + +## Monitor + +```bash +screen /dev/ttyACM0 115200 +``` diff --git a/exercises/05_SD_Card_Watcher/platformio.ini b/exercises/05_SD_Card_Watcher/platformio.ini new file mode 100644 index 0000000..500e012 --- /dev/null +++ b/exercises/05_SD_Card_Watcher/platformio.ini @@ -0,0 +1,33 @@ +; 20260213 ChatGPT +; $Id$ +; $HeadURL$ + +[platformio] +default_envs = node_a + +[env] +platform = espressif32 +framework = arduino +board = esp32-s3-devkitc-1 +monitor_speed = 115200 +lib_deps = + lewisxhe/XPowersLib@0.3.3 + Wire + +; SD pins based on T-Beam S3 core pin mapping +build_flags = + -I ../../shared/boards + -I ../../external/microReticulum_Firmware + -D BOARD_MODEL=BOARD_TBEAM_S_V1 + -D ARDUINO_USB_MODE=1 + -D ARDUINO_USB_CDC_ON_BOOT=1 + +[env:node_a] +build_flags = + ${env.build_flags} + -D NODE_LABEL=\"A\" + +[env:node_b] +build_flags = + ${env.build_flags} + -D NODE_LABEL=\"B\" diff --git a/exercises/05_SD_Card_Watcher/src/main.cpp b/exercises/05_SD_Card_Watcher/src/main.cpp new file mode 100644 index 0000000..f3f4373 --- /dev/null +++ b/exercises/05_SD_Card_Watcher/src/main.cpp @@ -0,0 +1,485 @@ +// 20260214 ChatGPT +// $Id$ +// $HeadURL$ + +#include +#include +#include +#include +#include +#include "driver/gpio.h" // gpio_get_level() +#include "tbeam_supreme_adapter.h" + +// ------------------------- +// Configuration toggles +// ------------------------- +#define ENABLE_SD_RAIL_CYCLE 1 // Power-cycle AXP2101 BLDO1 (SD rail) at boot. +#define ENABLE_PIN_DUMPS 1 // Log SPI pin logic levels at key points (NON-INTRUSIVE). +#define STARTUP_SERIAL_DELAY_MS 5000 + +// ------------------------- +// Globals +// ------------------------- +static SPIClass sdSpiH(HSPI); +static SPIClass sdSpiF(FSPI); +static SPIClass* g_sdSpi = nullptr; +static const char* g_sdBusName = "none"; +static uint32_t g_sdFreq = 0; +static XPowersLibInterface* g_pmu = nullptr; + +static const char* kRootTestFile = "/Exercise_05_test.txt"; +static const char* kNestedDir = "/test/testsub1/testsubsub1"; +static const char* kNestedTestFile = "/test/testsub1/testsubsub1/Exercise_05_test.txt"; +static const char* kPayload = "This is a test"; + +enum class WatchState : uint8_t { + UNKNOWN = 0, + ABSENT, + MOUNTED +}; + +static WatchState g_watchState = WatchState::UNKNOWN; +static uint8_t g_presentVotes = 0; +static uint8_t g_absentVotes = 0; +static uint32_t g_lastPollMs = 0; +static uint32_t g_lastFullScanMs = 0; +static uint32_t g_lastPeriodicActionMs = 0; + +static const uint32_t kPollIntervalAbsentMs = 1000; +static const uint32_t kPollIntervalMountedMs = 2000; +static const uint32_t kFullScanIntervalMs = 10000; +static const uint32_t kPeriodicActionMs = 15000; + +static const uint8_t kVotesToPresent = 2; +static const uint8_t kVotesToAbsent = 5; // More votes needed to declare absent to prevent false removes. +static const uint32_t kStartupWarmupMs = 1500; // Allow PMU and SD rail to stabilize. + +static uint32_t g_logSeq = 0; + +// ------------------------- +// Logging helpers +// ------------------------- +static void logf(const char* fmt, ...) { + char msg[192]; + va_list args; + va_start(args, fmt); + vsnprintf(msg, sizeof(msg), fmt, args); + va_end(args); + Serial.printf("[%10lu][%06lu] %s\r\n", + (unsigned long)millis(), + (unsigned long)g_logSeq++, + msg); +} + +static void dumpPmu(const char* tag, XPowersLibInterface* pmu) { + if (!pmu) { + logf("PMU(%s): pmu=null", tag); + return; + } + + bool bldo1 = pmu->isPowerChannelEnable(XPOWERS_BLDO1); + int vbus = pmu->getVbusVoltage(); + int batt = pmu->getBattVoltage(); + + logf("PMU(%s): BLDO1(SD)=%s VBUS=%dmV VBAT=%dmV", + tag, bldo1 ? "ON" : "OFF", vbus, batt); +} + +// IMPORTANT: this function MUST NOT modify pin modes (regression cause). +static void dumpSdPins(const char* tag) { +#if ENABLE_PIN_DUMPS + const gpio_num_t CS = (gpio_num_t)tbeam_supreme::sdCs(); + const gpio_num_t SCK = (gpio_num_t)tbeam_supreme::sdSck(); + const gpio_num_t MISO = (gpio_num_t)tbeam_supreme::sdMiso(); + const gpio_num_t MOSI = (gpio_num_t)tbeam_supreme::sdMosi(); + + int cs = gpio_get_level(CS); + int sck = gpio_get_level(SCK); + int miso = gpio_get_level(MISO); + int mosi = gpio_get_level(MOSI); + + logf("PINS(%s): CS=%d SCK=%d MISO=%d MOSI=%d", tag, cs, sck, miso, mosi); +#else + (void)tag; +#endif +} + +// ------------------------- +// Power + bus conditioning +// ------------------------- +static void forceSpiDeselected() { + pinMode(tbeam_supreme::sdCs(), OUTPUT); + digitalWrite(tbeam_supreme::sdCs(), HIGH); + + pinMode(tbeam_supreme::imuCs(), OUTPUT); + digitalWrite(tbeam_supreme::imuCs(), HIGH); +} + +static bool initPmuForSdPower() { + bool ok = tbeam_supreme::initPmuForPeripherals(g_pmu, &Serial); + if (!ok) { + logf("ERROR: PMU init failed"); + return false; + } + + logf("PMU adapter: AXP2101 ready, BLDO1(SD)=%s", + g_pmu && g_pmu->isPowerChannelEnable(XPOWERS_BLDO1) ? "ON" : "OFF"); + return true; +} + +static void cycleSdRail(XPowersLibInterface* pmu, + uint32_t off_ms = 250, + uint32_t on_settle_ms = 600) { +#if ENABLE_SD_RAIL_CYCLE + if (!pmu) { + logf("SD rail cycle skipped: pmu=null"); + return; + } + + dumpPmu("pre-sd-cycle", pmu); + + // Ensure the card is NOT selected while power is unstable. + forceSpiDeselected(); + dumpSdPins("pre-sd-cycle"); + + pmu->disablePowerOutput(XPOWERS_BLDO1); + delay(off_ms); + + pmu->setPowerChannelVoltage(XPOWERS_BLDO1, 3300); + pmu->enablePowerOutput(XPOWERS_BLDO1); + delay(on_settle_ms); + + dumpPmu("post-sd-cycle", pmu); + dumpSdPins("post-sd-cycle"); +#else + (void)pmu; (void)off_ms; (void)on_settle_ms; +#endif +} + +// ------------------------- +// SD helpers +// ------------------------- +static const char* cardTypeToString(uint8_t type) { + switch (type) { + case CARD_MMC: return "MMC"; + case CARD_SD: return "SDSC"; + case CARD_SDHC: return "SDHC/SDXC"; + default: return "UNKNOWN"; + } +} + +static bool tryMountWithBus(SPIClass& bus, const char* busName, uint32_t hz, bool verbose) { + SD.end(); + bus.end(); + delay(10); + + forceSpiDeselected(); + + bus.begin(tbeam_supreme::sdSck(), tbeam_supreme::sdMiso(), tbeam_supreme::sdMosi(), tbeam_supreme::sdCs()); + + // SD SPI "idle clocks" ritual: CS HIGH + >= 74 clocks with MOSI high (0xFF). + digitalWrite(tbeam_supreme::sdCs(), HIGH); + delay(2); + for (int i = 0; i < 10; i++) { + bus.transfer(0xFF); // 80 clocks total + } + delay(2); + + dumpSdPins("after-idle-clocks"); + + if (verbose) { + logf("SD: trying bus=%s freq=%lu Hz", busName, (unsigned long)hz); + } + + if (!SD.begin(tbeam_supreme::sdCs(), bus, hz)) { + if (verbose) logf("SD: mount failed (possible non-FAT format, power, or bus issue)"); + return false; + } + + uint8_t cardType = SD.cardType(); + if (cardType == CARD_NONE) { + SD.end(); + return false; + } + + g_sdSpi = &bus; + g_sdBusName = busName; + g_sdFreq = hz; + return true; +} + +static bool mountPreferred(bool verbose) { + return tryMountWithBus(sdSpiH, "HSPI", 400000, verbose); +} + +static bool mountCardFullScan() { + const uint32_t freqs[] = {400000, 1000000, 4000000, 10000000}; + + for (uint8_t i = 0; i < (sizeof(freqs) / sizeof(freqs[0])); ++i) { + if (tryMountWithBus(sdSpiH, "HSPI", freqs[i], true)) { + logf("SD: card detected and mounted"); + return true; + } + } + for (uint8_t i = 0; i < (sizeof(freqs) / sizeof(freqs[0])); ++i) { + if (tryMountWithBus(sdSpiF, "FSPI", freqs[i], true)) { + logf("SD: card detected and mounted"); + return true; + } + } + + logf("SD: begin() failed on all bus/frequency attempts"); + logf(" likely card absent, bad format, pin mismatch, or hardware issue"); + return false; +} + +static void printCardInfo() { + uint8_t cardType = SD.cardType(); + uint64_t cardSizeMB = SD.cardSize() / (1024ULL * 1024ULL); + uint64_t totalMB = SD.totalBytes() / (1024ULL * 1024ULL); + uint64_t usedMB = SD.usedBytes() / (1024ULL * 1024ULL); + + logf("SD type: %s", cardTypeToString(cardType)); + logf("SD size: %llu MB", cardSizeMB); + logf("FS total: %llu MB", totalMB); + logf("FS used : %llu MB", usedMB); + logf("SPI bus: %s @ %lu Hz", g_sdBusName, (unsigned long)g_sdFreq); +} + +static bool ensureDirRecursive(const char* path) { + String full(path); + if (!full.startsWith("/")) full = "/" + full; + + int start = 1; + while (start > 0 && start < (int)full.length()) { + int slash = full.indexOf('/', start); + String partial = (slash < 0) ? full : full.substring(0, slash); + + if (!SD.exists(partial.c_str())) { + logf("Creating directory: %s", partial.c_str()); + if (!SD.mkdir(partial.c_str())) { + logf("ERROR: mkdir failed for %s", partial.c_str()); + return false; + } + } + + if (slash < 0) break; + start = slash + 1; + } + return true; +} + +static bool rewriteFile(const char* path, const char* payload) { + if (SD.exists(path)) { + logf("WARNING: %s exists ... erasing", path); + if (!SD.remove(path)) { + logf("ERROR: failed to erase %s", path); + return false; + } + } + + File f = SD.open(path, FILE_WRITE); + if (!f) { + logf("ERROR: failed to create %s", path); + return false; + } + + size_t wrote = f.println(payload); + f.close(); + + if (wrote == 0) { + logf("ERROR: write failed for %s", path); + return false; + } + + logf("Wrote file: %s", path); + return true; +} + +static void permissionsDemo(const char* path) { + logf("Permissions demo:"); + logf(" SD/FAT does not support Unix permissions (chmod/chown)."); + logf(" Access control is by open mode (FILE_READ/FILE_WRITE)."); + + File r = SD.open(path, FILE_READ); + if (!r) { + logf(" Could not open %s as FILE_READ", path); + return; + } + + logf(" FILE_READ open succeeded, size=%u bytes", (unsigned)r.size()); + size_t writeInReadMode = r.print("attempt write while opened read-only"); + if (writeInReadMode == 0) { + logf(" As expected, write via FILE_READ handle was blocked."); + } else { + logf(" NOTE: write via FILE_READ returned %u (unexpected)", (unsigned)writeInReadMode); + } + r.close(); +} + +static bool verifyMountedCard() { + File root = SD.open("/", FILE_READ); + if (!root) return false; + root.close(); + return true; +} + +static void runCardWorkflow() { + printCardInfo(); + + if (!rewriteFile(kRootTestFile, kPayload)) { + logf("Watcher action: root file write failed"); + return; + } + if (!ensureDirRecursive(kNestedDir)) { + logf("Watcher action: directory creation failed"); + return; + } + if (!rewriteFile(kNestedTestFile, kPayload)) { + logf("Watcher action: nested file write failed"); + return; + } + + permissionsDemo(kRootTestFile); +} + +// ------------------------- +// Watcher state transitions +// ------------------------- +static void setStateMounted() { + if (g_watchState != WatchState::MOUNTED) { + logf("EVENT: card inserted/mounted"); + runCardWorkflow(); + g_lastPeriodicActionMs = millis(); + } + g_watchState = WatchState::MOUNTED; +} + +static void setStateAbsent() { + if (g_watchState == WatchState::MOUNTED) { + logf("EVENT: card removed/unavailable"); + } else if (g_watchState != WatchState::ABSENT) { + logf("EVENT: no card detected"); + } + SD.end(); + g_watchState = WatchState::ABSENT; +} + +// ------------------------- +// Arduino entry points +// ------------------------- +void setup() { + Serial.begin(115200); + Serial.println("[WATCHER: startup]"); + + // De-select SPI devices immediately. + forceSpiDeselected(); + dumpSdPins("very-early"); + + logf("Sleeping for %lu ms to allow Serial Monitor connection...", (unsigned long)STARTUP_SERIAL_DELAY_MS); + delay(STARTUP_SERIAL_DELAY_MS); + + Serial.println(); + Serial.println("=================================================="); + Serial.println("Exercise 05: SD Card Watcher"); + Serial.println("=================================================="); + Serial.printf("Pins: CS=%d SCK=%d MISO=%d MOSI=%d\r\n", + tbeam_supreme::sdCs(), tbeam_supreme::sdSck(), + tbeam_supreme::sdMiso(), tbeam_supreme::sdMosi()); + Serial.printf("PMU I2C: SDA1=%d SCL1=%d\r\n", + tbeam_supreme::i2cSda(), tbeam_supreme::i2cScl()); + Serial.println("Note: SD must be FAT16/FAT32 for Arduino SD library."); + Serial.println(); + + initPmuForSdPower(); + dumpPmu("post-pmu-init", g_pmu); + + // Software equivalent of "remove/insert card". + cycleSdRail(g_pmu); + + logf("Watcher: waiting %lu ms for SD rail/card stabilization", (unsigned long)kStartupWarmupMs); + delay(kStartupWarmupMs); + + dumpSdPins("pre-warmup-mount"); + + bool warmMounted = false; + for (uint8_t i = 0; i < 3; ++i) { + if (mountPreferred(false)) { + warmMounted = true; + break; + } + delay(200); + } + + if (warmMounted) { + logf("Watcher: startup warmup mount succeeded"); + setStateMounted(); + } else { + logf("Watcher: startup warmup did not mount card"); + setStateAbsent(); + } +} + +void loop() { + const uint32_t now = millis(); + const uint32_t pollInterval = + (g_watchState == WatchState::MOUNTED) ? kPollIntervalMountedMs : kPollIntervalAbsentMs; + + if ((uint32_t)(now - g_lastPollMs) < pollInterval) { + delay(10); + return; + } + g_lastPollMs = now; + + if (g_watchState == WatchState::MOUNTED) { + if (verifyMountedCard()) { + if ((uint32_t)(now - g_lastPeriodicActionMs) >= kPeriodicActionMs) { + logf("Watcher: periodic mounted check action"); + runCardWorkflow(); + g_lastPeriodicActionMs = now; + } + g_presentVotes = 0; + g_absentVotes = 0; + return; + } + + // One immediate remount attempt prevents false removes on transient SPI errors. + if (mountPreferred(false) && verifyMountedCard()) { + g_presentVotes = 0; + g_absentVotes = 0; + return; + } + + g_absentVotes++; + g_presentVotes = 0; + if (g_absentVotes >= kVotesToAbsent) { + setStateAbsent(); + g_absentVotes = 0; + } + return; + } + + // ABSENT/UNKNOWN state + bool mounted = mountPreferred(false); + if (!mounted && (uint32_t)(now - g_lastFullScanMs) >= kFullScanIntervalMs) { + g_lastFullScanMs = now; + logf("Watcher: preferred probe failed, running full scan"); + mounted = mountCardFullScan(); + } + + if (mounted) { + g_presentVotes++; + g_absentVotes = 0; + if (g_presentVotes >= kVotesToPresent) { + setStateMounted(); + g_presentVotes = 0; + } + } else { + g_absentVotes++; + g_presentVotes = 0; + if (g_absentVotes >= kVotesToAbsent) { + setStateAbsent(); + g_absentVotes = 0; + } + } +} diff --git a/exercises/06_RTC_check/README.md b/exercises/06_RTC_check/README.md new file mode 100644 index 0000000..e783cc9 --- /dev/null +++ b/exercises/06_RTC_check/README.md @@ -0,0 +1,43 @@ +## Exercise 06: RTC Check (PCF8563) + +This exercise validates RTC read/write and power-off persistence on the T-Beam Supreme. + +It: +- Initializes PMU + I2C bus used by RTC. +- Reads RTC at startup. +- Prints RTC every 10 seconds. +- Accepts serial commands: + - `show` + - `set YYYY-MM-DD HH:MM:SS` + - `help` + +## Build + +```bash +source /home/jlpoole/rnsenv/bin/activate +pio run -e node_a +``` + +## Upload + +```bash +source /home/jlpoole/rnsenv/bin/activate +pio run -e node_a -t upload --upload-port /dev/ttyACM0 +``` + +## Monitor + +```bash +screen /dev/ttyACM0 115200 +``` + +## Suggested Persistence Test + +1. Set the RTC: + - `set 2026-02-14 17:30:00` +2. Confirm: + - `show` +3. Power off the unit for a few minutes. +4. Power on and run: + - `show` +5. Compare expected elapsed time vs RTC output. diff --git a/exercises/06_RTC_check/platformio.ini b/exercises/06_RTC_check/platformio.ini new file mode 100644 index 0000000..178384b --- /dev/null +++ b/exercises/06_RTC_check/platformio.ini @@ -0,0 +1,32 @@ +; 20260214 ChatGPT +; $Id$ +; $HeadURL$ + +[platformio] +default_envs = node_a + +[env] +platform = espressif32 +framework = arduino +board = esp32-s3-devkitc-1 +monitor_speed = 115200 +lib_deps = + lewisxhe/XPowersLib@0.3.3 + +build_flags = + -I ../../shared/boards + -I ../../external/microReticulum_Firmware + -D BOARD_MODEL=BOARD_TBEAM_S_V1 + -D RTC_I2C_ADDR=0x51 + -D ARDUINO_USB_MODE=1 + -D ARDUINO_USB_CDC_ON_BOOT=1 + +[env:node_a] +build_flags = + ${env.build_flags} + -D NODE_LABEL=\"A\" + +[env:node_b] +build_flags = + ${env.build_flags} + -D NODE_LABEL=\"B\" diff --git a/exercises/06_RTC_check/src/main.cpp b/exercises/06_RTC_check/src/main.cpp new file mode 100644 index 0000000..3024133 --- /dev/null +++ b/exercises/06_RTC_check/src/main.cpp @@ -0,0 +1,224 @@ +// 20260214 ChatGPT +// $Id$ +// $HeadURL$ + +#include +#include +#include "tbeam_supreme_adapter.h" + +#ifndef RTC_I2C_ADDR +#define RTC_I2C_ADDR 0x51 +#endif + +static XPowersLibInterface* g_pmu = nullptr; +static uint32_t g_logSeq = 0; +static uint32_t g_lastPrintMs = 0; +static String g_cmdBuf; +static bool g_lastWasCR = false; + +struct RtcDateTime { + uint16_t year; + uint8_t month; + uint8_t day; + uint8_t hour; + uint8_t minute; + uint8_t second; + uint8_t weekday; +}; + +static uint8_t toBcd(uint8_t v) { + return ((v / 10U) << 4U) | (v % 10U); +} + +static uint8_t fromBcd(uint8_t b) { + return ((b >> 4U) * 10U) + (b & 0x0FU); +} + +static void logf(const char* fmt, ...) { + char msg[196]; + va_list args; + va_start(args, fmt); + vsnprintf(msg, sizeof(msg), fmt, args); + va_end(args); + Serial.printf("[%10lu][%06lu] %s\r\n", (unsigned long)millis(), (unsigned long)g_logSeq++, msg); +} + +static bool initPmuForRtc() { + return tbeam_supreme::initPmuForPeripherals(g_pmu, &Serial); +} + +static bool rtcRead(RtcDateTime& out, bool& lowVoltageFlag) { + Wire1.beginTransmission(RTC_I2C_ADDR); + Wire1.write(0x02); // seconds register + if (Wire1.endTransmission(false) != 0) { + return false; + } + + const uint8_t need = 7; + uint8_t got = Wire1.requestFrom((int)RTC_I2C_ADDR, (int)need); + if (got != need) { + return false; + } + + uint8_t sec = Wire1.read(); + uint8_t min = Wire1.read(); + uint8_t hour = Wire1.read(); + uint8_t day = Wire1.read(); + uint8_t weekday = Wire1.read(); + uint8_t month = Wire1.read(); + uint8_t year = Wire1.read(); + + lowVoltageFlag = (sec & 0x80U) != 0; + out.second = fromBcd(sec & 0x7FU); + out.minute = fromBcd(min & 0x7FU); + out.hour = fromBcd(hour & 0x3FU); + out.day = fromBcd(day & 0x3FU); + out.weekday = fromBcd(weekday & 0x07U); + out.month = fromBcd(month & 0x1FU); + uint8_t yy = fromBcd(year); + bool century = (month & 0x80U) != 0; + out.year = century ? (1900U + yy) : (2000U + yy); + + return true; +} + +static bool rtcWrite(const RtcDateTime& in) { + bool century = in.year < 2000; + uint8_t yy = (uint8_t)(in.year % 100); + uint8_t monthReg = toBcd(in.month) & 0x1FU; + if (century) { + monthReg |= 0x80U; + } + + Wire1.beginTransmission(RTC_I2C_ADDR); + Wire1.write(0x02); // seconds register + Wire1.write(toBcd(in.second) & 0x7FU); + Wire1.write(toBcd(in.minute) & 0x7FU); + Wire1.write(toBcd(in.hour) & 0x3FU); + Wire1.write(toBcd(in.day) & 0x3FU); + Wire1.write(toBcd(in.weekday) & 0x07U); + Wire1.write(monthReg); + Wire1.write(toBcd(yy)); + return Wire1.endTransmission() == 0; +} + +static bool parseSetCommand(const String& line, RtcDateTime& dt) { + int y, mo, d, h, mi, s; + if (sscanf(line.c_str(), "set %d-%d-%d %d:%d:%d", &y, &mo, &d, &h, &mi, &s) != 6) { + return false; + } + + if (y < 1900 || y > 2099) return false; + if (mo < 1 || mo > 12) return false; + if (d < 1 || d > 31) return false; + if (h < 0 || h > 23) return false; + if (mi < 0 || mi > 59) return false; + if (s < 0 || s > 59) return false; + + dt.year = (uint16_t)y; + dt.month = (uint8_t)mo; + dt.day = (uint8_t)d; + dt.hour = (uint8_t)h; + dt.minute = (uint8_t)mi; + dt.second = (uint8_t)s; + dt.weekday = 0; // Not critical for this persistence check. + return true; +} + +static void printRtcNow(const char* label) { + RtcDateTime now{}; + bool lowV = false; + if (!rtcRead(now, lowV)) { + logf("%s: RTC read failed", label); + return; + } + logf("%s: %04u-%02u-%02u %02u:%02u:%02u weekday=%u%s", + label, + (unsigned)now.year, (unsigned)now.month, (unsigned)now.day, + (unsigned)now.hour, (unsigned)now.minute, (unsigned)now.second, + (unsigned)now.weekday, + lowV ? " [LOW_VOLTAGE_FLAG]" : ""); +} + +static void handleCommand(const String& raw) { + String line = raw; + line.trim(); + if (line.length() == 0) { + return; + } + + if (line == "help") { + logf("Commands:"); + logf(" show"); + logf(" set YYYY-MM-DD HH:MM:SS"); + logf(" help"); + return; + } + if (line == "show") { + printRtcNow("RTC"); + return; + } + + RtcDateTime dt{}; + if (parseSetCommand(line, dt)) { + if (rtcWrite(dt)) { + logf("RTC set succeeded"); + printRtcNow("RTC"); + } else { + logf("RTC set failed"); + } + return; + } + + logf("Unknown command: %s", line.c_str()); +} + +void setup() { + Serial.begin(115200); + Serial.println("Sleeping for 5 seconds to allow Serial Monitor connection..."); + delay(5000); + + Serial.println("\r\n=================================================="); + Serial.println("Exercise 06: RTC check (PCF8563)"); + Serial.println("=================================================="); + Serial.printf("RTC I2C: SDA1=%d SCL1=%d ADDR=0x%02X\r\n", + tbeam_supreme::i2cSda(), tbeam_supreme::i2cScl(), RTC_I2C_ADDR); + + initPmuForRtc(); + + logf("Type 'help' for commands."); + logf("Power-off persistence test:"); + logf(" 1) set time"); + logf(" 2) power off for a few minutes"); + logf(" 3) power on and run 'show' before any GPS sync"); + printRtcNow("RTC startup"); +} + +void loop() { + while (Serial.available() > 0) { + char c = (char)Serial.read(); + if (c == '\r' || c == '\n') { + // Handle CR, LF, and CRLF/LFCR cleanly as one line ending. + if ((c == '\n' && g_lastWasCR) || (c == '\r' && !g_lastWasCR && g_cmdBuf.length() == 0)) { + g_lastWasCR = (c == '\r'); + continue; + } + handleCommand(g_cmdBuf); + g_cmdBuf = ""; + g_lastWasCR = (c == '\r'); + } else { + g_lastWasCR = false; + g_cmdBuf += c; + if (g_cmdBuf.length() > 120) { + g_cmdBuf = ""; + logf("Input line too long, buffer cleared"); + } + } + } + + uint32_t nowMs = millis(); + if ((uint32_t)(nowMs - g_lastPrintMs) >= 10000) { + g_lastPrintMs = nowMs; + printRtcNow("RTC periodic"); + } +} diff --git a/exercises/07_SD_Startup_Watcher/README.md b/exercises/07_SD_Startup_Watcher/README.md new file mode 100644 index 0000000..494c6a0 --- /dev/null +++ b/exercises/07_SD_Startup_Watcher/README.md @@ -0,0 +1,51 @@ +## Exercise 07: SD Startup Watcher + +This exercise is derived from `Exercise 05` and keeps that original exercise intact. +The focus here is isolating reusable SD startup and hot-insert watcher logic into a library-style structure. + +This exercise now has two parts: + +1. A reusable SD startup/watcher library in `lib/startup_sd`. +2. A harness app in `src/main.cpp` that demonstrates how to use that library. + +Watcher behavior: + +1. Initializes PMU and enables SD power rail (AXP2101 BLDO1). +2. Polls for card changes with debounced state transitions. +3. Emits events only on change: + - `EVENT: card inserted/mounted` + - `EVENT: card removed/unavailable` + - `EVENT: no card detected` +4. On mount event, emits callback status (`SdEvent`) and runs SD write workflow. +5. Every 15 seconds while mounted, runs a periodic write/permission check. +6. Uses fast preferred probe (`HSPI @ 400k`) and occasional full fallback scan. + +Status callback usage: + +- `SdEvent::NO_CARD` -> show "Missing SD card / Please insert card to proceed" +- `SdEvent::CARD_MOUNTED` -> card ready +- `SdEvent::CARD_REMOVED` -> card removed, wait for insert + +Files used in this exercise: +- `/Exercise_07_test.txt` +- `/test/testsub1/testsubsub1/Exercise_07_test.txt` + +## Build + +```bash +source /home/jlpoole/rnsenv/bin/activate +pio run -e node_a +``` + +## Upload + +```bash +source /home/jlpoole/rnsenv/bin/activate +pio run -e node_a -t upload --upload-port /dev/ttyACM0 +``` + +## Monitor + +```bash +screen /dev/ttyACM0 115200 +``` diff --git a/exercises/07_SD_Startup_Watcher/lib/startup_sd/StartupSdManager.cpp b/exercises/07_SD_Startup_Watcher/lib/startup_sd/StartupSdManager.cpp new file mode 100644 index 0000000..a77f92e --- /dev/null +++ b/exercises/07_SD_Startup_Watcher/lib/startup_sd/StartupSdManager.cpp @@ -0,0 +1,351 @@ +#include "StartupSdManager.h" + +#include +#include "driver/gpio.h" + +StartupSdManager::StartupSdManager(Print& serial) : serial_(serial) {} + +bool StartupSdManager::begin(const SdWatcherConfig& cfg, SdStatusCallback callback) { + cfg_ = cfg; + callback_ = callback; + + forceSpiDeselected(); + dumpSdPins("very-early"); + + if (!initPmuForSdPower()) { + return false; + } + + cycleSdRail(); + delay(cfg_.startupWarmupMs); + + bool warmMounted = false; + for (uint8_t i = 0; i < 3; ++i) { + if (mountPreferred(false)) { + warmMounted = true; + break; + } + delay(200); + } + + if (warmMounted) { + setStateMounted(); + } else { + setStateAbsent(); + } + return true; +} + +void StartupSdManager::update() { + const uint32_t now = millis(); + const uint32_t pollInterval = + (watchState_ == SdWatchState::MOUNTED) ? cfg_.pollIntervalMountedMs : cfg_.pollIntervalAbsentMs; + + if ((uint32_t)(now - lastPollMs_) < pollInterval) { + return; + } + lastPollMs_ = now; + + if (watchState_ == SdWatchState::MOUNTED) { + if (verifyMountedCard()) { + presentVotes_ = 0; + absentVotes_ = 0; + return; + } + + if (mountPreferred(false) && verifyMountedCard()) { + presentVotes_ = 0; + absentVotes_ = 0; + return; + } + + absentVotes_++; + presentVotes_ = 0; + if (absentVotes_ >= cfg_.votesToAbsent) { + setStateAbsent(); + absentVotes_ = 0; + } + return; + } + + bool mounted = mountPreferred(false); + if (!mounted && (uint32_t)(now - lastFullScanMs_) >= cfg_.fullScanIntervalMs) { + lastFullScanMs_ = now; + if (cfg_.recoveryRailCycleOnFullScan) { + logf("Watcher: recovery rail cycle before full scan"); + cycleSdRail(cfg_.recoveryRailOffMs, cfg_.recoveryRailOnSettleMs); + delay(150); + } + logf("Watcher: preferred probe failed, running full scan"); + mounted = mountCardFullScan(); + } + + if (mounted) { + presentVotes_++; + absentVotes_ = 0; + if (presentVotes_ >= cfg_.votesToPresent) { + setStateMounted(); + presentVotes_ = 0; + } + } else { + absentVotes_++; + presentVotes_ = 0; + if (absentVotes_ >= cfg_.votesToAbsent) { + setStateAbsent(); + absentVotes_ = 0; + } + } +} + +bool StartupSdManager::consumeMountedEvent() { + bool out = mountedEventPending_; + mountedEventPending_ = false; + return out; +} + +bool StartupSdManager::consumeRemovedEvent() { + bool out = removedEventPending_; + removedEventPending_ = false; + return out; +} + +void StartupSdManager::logf(const char* fmt, ...) { + char msg[196]; + va_list args; + va_start(args, fmt); + vsnprintf(msg, sizeof(msg), fmt, args); + va_end(args); + serial_.printf("[%10lu][%06lu] %s\r\n", + (unsigned long)millis(), + (unsigned long)logSeq_++, + msg); +} + +void StartupSdManager::notify(SdEvent event, const char* message) { + if (callback_ != nullptr) { + callback_(event, message); + } +} + +void StartupSdManager::forceSpiDeselected() { + pinMode(tbeam_supreme::sdCs(), OUTPUT); + digitalWrite(tbeam_supreme::sdCs(), HIGH); + pinMode(tbeam_supreme::imuCs(), OUTPUT); + digitalWrite(tbeam_supreme::imuCs(), HIGH); +} + +void StartupSdManager::dumpSdPins(const char* tag) { + if (!cfg_.enablePinDumps) { + (void)tag; + return; + } + + const gpio_num_t cs = (gpio_num_t)tbeam_supreme::sdCs(); + const gpio_num_t sck = (gpio_num_t)tbeam_supreme::sdSck(); + const gpio_num_t miso = (gpio_num_t)tbeam_supreme::sdMiso(); + const gpio_num_t mosi = (gpio_num_t)tbeam_supreme::sdMosi(); + logf("PINS(%s): CS=%d SCK=%d MISO=%d MOSI=%d", + tag, gpio_get_level(cs), gpio_get_level(sck), gpio_get_level(miso), gpio_get_level(mosi)); +} + +bool StartupSdManager::initPmuForSdPower() { + if (!tbeam_supreme::initPmuForPeripherals(pmu_, &serial_)) { + logf("ERROR: PMU init failed"); + return false; + } + return true; +} + +void StartupSdManager::cycleSdRail(uint32_t offMs, uint32_t onSettleMs) { + if (!cfg_.enableSdRailCycle) { + return; + } + if (!pmu_) { + logf("SD rail cycle skipped: pmu=null"); + return; + } + + forceSpiDeselected(); + pmu_->disablePowerOutput(XPOWERS_BLDO1); + delay(offMs); + pmu_->setPowerChannelVoltage(XPOWERS_BLDO1, 3300); + pmu_->enablePowerOutput(XPOWERS_BLDO1); + delay(onSettleMs); +} + +bool StartupSdManager::tryMountWithBus(SPIClass& bus, const char* busName, uint32_t hz, bool verbose) { + SD.end(); + bus.end(); + delay(10); + forceSpiDeselected(); + + bus.begin(tbeam_supreme::sdSck(), tbeam_supreme::sdMiso(), tbeam_supreme::sdMosi(), tbeam_supreme::sdCs()); + digitalWrite(tbeam_supreme::sdCs(), HIGH); + delay(2); + for (int i = 0; i < 10; i++) { + bus.transfer(0xFF); + } + delay(2); + + if (verbose) { + logf("SD: trying bus=%s freq=%lu Hz", busName, (unsigned long)hz); + } + + if (!SD.begin(tbeam_supreme::sdCs(), bus, hz)) { + if (verbose) { + logf("SD: mount failed"); + } + return false; + } + + if (SD.cardType() == CARD_NONE) { + SD.end(); + return false; + } + + sdSpi_ = &bus; + sdBusName_ = busName; + sdFreq_ = hz; + return true; +} + +bool StartupSdManager::mountPreferred(bool verbose) { + return tryMountWithBus(sdSpiH_, "HSPI", 400000, verbose); +} + +bool StartupSdManager::mountCardFullScan() { + const uint32_t freqs[] = {400000, 1000000, 4000000, 10000000}; + + for (uint8_t i = 0; i < (sizeof(freqs) / sizeof(freqs[0])); ++i) { + if (tryMountWithBus(sdSpiH_, "HSPI", freqs[i], true)) { + logf("SD: card detected and mounted"); + return true; + } + } + for (uint8_t i = 0; i < (sizeof(freqs) / sizeof(freqs[0])); ++i) { + if (tryMountWithBus(sdSpiF_, "FSPI", freqs[i], true)) { + logf("SD: card detected and mounted"); + return true; + } + } + + logf("SD: begin() failed on all bus/frequency attempts"); + return false; +} + +bool StartupSdManager::verifyMountedCard() { + File root = SD.open("/", FILE_READ); + if (!root) { + return false; + } + root.close(); + return true; +} + +const char* StartupSdManager::cardTypeToString(uint8_t type) { + switch (type) { + case CARD_MMC: + return "MMC"; + case CARD_SD: + return "SDSC"; + case CARD_SDHC: + return "SDHC/SDXC"; + default: + return "UNKNOWN"; + } +} + +void StartupSdManager::printCardInfo() { + uint8_t cardType = SD.cardType(); + uint64_t cardSizeMB = SD.cardSize() / (1024ULL * 1024ULL); + uint64_t totalMB = SD.totalBytes() / (1024ULL * 1024ULL); + uint64_t usedMB = SD.usedBytes() / (1024ULL * 1024ULL); + + logf("SD type: %s", cardTypeToString(cardType)); + logf("SD size: %llu MB", cardSizeMB); + logf("FS total: %llu MB", totalMB); + logf("FS used : %llu MB", usedMB); + logf("SPI bus: %s @ %lu Hz", sdBusName_, (unsigned long)sdFreq_); +} + +bool StartupSdManager::ensureDirRecursive(const char* path) { + String full(path); + if (!full.startsWith("/")) { + full = "/" + full; + } + + int start = 1; + while (start > 0 && start < (int)full.length()) { + int slash = full.indexOf('/', start); + String partial = (slash < 0) ? full : full.substring(0, slash); + if (!SD.exists(partial.c_str()) && !SD.mkdir(partial.c_str())) { + logf("ERROR: mkdir failed for %s", partial.c_str()); + return false; + } + if (slash < 0) { + break; + } + start = slash + 1; + } + + return true; +} + +bool StartupSdManager::rewriteFile(const char* path, const char* payload) { + if (SD.exists(path) && !SD.remove(path)) { + logf("ERROR: failed to erase %s", path); + return false; + } + + File f = SD.open(path, FILE_WRITE); + if (!f) { + logf("ERROR: failed to create %s", path); + return false; + } + + size_t wrote = f.println(payload); + f.close(); + if (wrote == 0) { + logf("ERROR: write failed for %s", path); + return false; + } + return true; +} + +void StartupSdManager::permissionsDemo(const char* path) { + logf("Permissions demo: FAT has no Unix chmod/chown, use open mode only."); + File r = SD.open(path, FILE_READ); + if (!r) { + logf("Could not open %s as FILE_READ", path); + return; + } + size_t writeInReadMode = r.print("attempt write while opened read-only"); + if (writeInReadMode == 0) { + logf("As expected, FILE_READ write was blocked."); + } else { + logf("NOTE: FILE_READ write returned %u (unexpected)", (unsigned)writeInReadMode); + } + r.close(); +} + +void StartupSdManager::setStateMounted() { + if (watchState_ != SdWatchState::MOUNTED) { + logf("EVENT: card inserted/mounted"); + mountedEventPending_ = true; + notify(SdEvent::CARD_MOUNTED, "SD card mounted"); + } + watchState_ = SdWatchState::MOUNTED; +} + +void StartupSdManager::setStateAbsent() { + if (watchState_ == SdWatchState::MOUNTED) { + logf("EVENT: card removed/unavailable"); + removedEventPending_ = true; + notify(SdEvent::CARD_REMOVED, "SD card removed"); + } else if (watchState_ != SdWatchState::ABSENT) { + logf("EVENT: no card detected"); + notify(SdEvent::NO_CARD, "Missing SD card / Please insert card to proceed"); + } + SD.end(); + watchState_ = SdWatchState::ABSENT; +} diff --git a/exercises/07_SD_Startup_Watcher/lib/startup_sd/StartupSdManager.h b/exercises/07_SD_Startup_Watcher/lib/startup_sd/StartupSdManager.h new file mode 100644 index 0000000..be9ef27 --- /dev/null +++ b/exercises/07_SD_Startup_Watcher/lib/startup_sd/StartupSdManager.h @@ -0,0 +1,90 @@ +#pragma once + +#include +#include +#include +#include +#include "tbeam_supreme_adapter.h" + +enum class SdWatchState : uint8_t { + UNKNOWN = 0, + ABSENT, + MOUNTED +}; + +enum class SdEvent : uint8_t { + NO_CARD, + CARD_MOUNTED, + CARD_REMOVED +}; + +using SdStatusCallback = void (*)(SdEvent event, const char* message); + +struct SdWatcherConfig { + bool enableSdRailCycle = true; + bool enablePinDumps = true; + bool recoveryRailCycleOnFullScan = true; + uint32_t recoveryRailOffMs = 250; + uint32_t recoveryRailOnSettleMs = 700; + uint32_t startupWarmupMs = 1500; + uint32_t pollIntervalAbsentMs = 1000; + uint32_t pollIntervalMountedMs = 2000; + uint32_t fullScanIntervalMs = 10000; + uint8_t votesToPresent = 2; + uint8_t votesToAbsent = 5; +}; + +class StartupSdManager { + public: + explicit StartupSdManager(Print& serial = Serial); + + bool begin(const SdWatcherConfig& cfg, SdStatusCallback callback = nullptr); + void update(); + + bool isMounted() const { return watchState_ == SdWatchState::MOUNTED; } + SdWatchState state() const { return watchState_; } + + bool consumeMountedEvent(); + bool consumeRemovedEvent(); + + void printCardInfo(); + bool ensureDirRecursive(const char* path); + bool rewriteFile(const char* path, const char* payload); + void permissionsDemo(const char* path); + + private: + void logf(const char* fmt, ...); + void notify(SdEvent event, const char* message); + void forceSpiDeselected(); + void dumpSdPins(const char* tag); + bool initPmuForSdPower(); + void cycleSdRail(uint32_t offMs = 250, uint32_t onSettleMs = 600); + bool tryMountWithBus(SPIClass& bus, const char* busName, uint32_t hz, bool verbose); + bool mountPreferred(bool verbose); + bool mountCardFullScan(); + bool verifyMountedCard(); + const char* cardTypeToString(uint8_t type); + void setStateMounted(); + void setStateAbsent(); + + Print& serial_; + SdWatcherConfig cfg_{}; + SdStatusCallback callback_ = nullptr; + + SPIClass sdSpiH_{HSPI}; + SPIClass sdSpiF_{FSPI}; + SPIClass* sdSpi_ = nullptr; + const char* sdBusName_ = "none"; + uint32_t sdFreq_ = 0; + XPowersLibInterface* pmu_ = nullptr; + + SdWatchState watchState_ = SdWatchState::UNKNOWN; + uint8_t presentVotes_ = 0; + uint8_t absentVotes_ = 0; + uint32_t lastPollMs_ = 0; + uint32_t lastFullScanMs_ = 0; + uint32_t logSeq_ = 0; + + bool mountedEventPending_ = false; + bool removedEventPending_ = false; +}; diff --git a/exercises/07_SD_Startup_Watcher/lib/startup_sd/library.json b/exercises/07_SD_Startup_Watcher/lib/startup_sd/library.json new file mode 100644 index 0000000..4978fdd --- /dev/null +++ b/exercises/07_SD_Startup_Watcher/lib/startup_sd/library.json @@ -0,0 +1,12 @@ +{ + "name": "startup_sd", + "version": "0.1.0", + "dependencies": [ + { + "name": "XPowersLib" + }, + { + "name": "Wire" + } + ] +} diff --git a/exercises/07_SD_Startup_Watcher/platformio.ini b/exercises/07_SD_Startup_Watcher/platformio.ini new file mode 100644 index 0000000..e760c56 --- /dev/null +++ b/exercises/07_SD_Startup_Watcher/platformio.ini @@ -0,0 +1,37 @@ +; 20260213 ChatGPT +; $Id$ +; $HeadURL$ + +[platformio] +default_envs = node_a + +[env] +platform = espressif32 +framework = arduino +board = esp32-s3-devkitc-1 +monitor_speed = 115200 +lib_deps = + lewisxhe/XPowersLib@0.3.3 + Wire + olikraus/U8g2@^2.36.4 + +; SD pins based on T-Beam S3 core pin mapping +build_flags = + -I ../../shared/boards + -I ../../external/microReticulum_Firmware + -D BOARD_MODEL=BOARD_TBEAM_S_V1 + -D OLED_SDA=17 + -D OLED_SCL=18 + -D OLED_ADDR=0x3C + -D ARDUINO_USB_MODE=1 + -D ARDUINO_USB_CDC_ON_BOOT=1 + +[env:node_a] +build_flags = + ${env.build_flags} + -D NODE_LABEL=\"A\" + +[env:node_b] +build_flags = + ${env.build_flags} + -D NODE_LABEL=\"B\" diff --git a/exercises/07_SD_Startup_Watcher/src/main.cpp b/exercises/07_SD_Startup_Watcher/src/main.cpp new file mode 100644 index 0000000..ccc9d88 --- /dev/null +++ b/exercises/07_SD_Startup_Watcher/src/main.cpp @@ -0,0 +1,135 @@ +// 20260215 ChatGPT +// $Id$ +// $HeadURL$ + +#include +#include +#include +#include "StartupSdManager.h" +#include "tbeam_supreme_adapter.h" + +#define STARTUP_SERIAL_DELAY_MS 5000 +#ifndef OLED_SDA +#define OLED_SDA 17 +#endif +#ifndef OLED_SCL +#define OLED_SCL 18 +#endif +#ifndef OLED_ADDR +#define OLED_ADDR 0x3C +#endif + +static const char* kRootTestFile = "/Exercise_07_test.txt"; +static const char* kNestedDir = "/test/testsub1/testsubsub1"; +static const char* kNestedTestFile = "/test/testsub1/testsubsub1/Exercise_07_test.txt"; +static const char* kPayload = "This is a test"; +static const uint32_t kPeriodicActionMs = 15000; + +static U8G2_SH1106_128X64_NONAME_F_HW_I2C g_oled(U8G2_R0, /* reset=*/U8X8_PIN_NONE); +static StartupSdManager g_sd(Serial); +static uint32_t g_lastPeriodicActionMs = 0; + +static void oledShow3(const char* l1, const char* l2 = nullptr, const char* l3 = nullptr) { + g_oled.clearBuffer(); + g_oled.setFont(u8g2_font_6x10_tf); + if (l1) g_oled.drawUTF8(0, 16, l1); + if (l2) g_oled.drawUTF8(0, 32, l2); + if (l3) g_oled.drawUTF8(0, 48, l3); + g_oled.sendBuffer(); +} + +static void onSdStatus(SdEvent event, const char* message) { + Serial.printf("[SD-STATUS] %s\r\n", message); + + if (event == SdEvent::NO_CARD) { + oledShow3("Missing SD card", "Please insert card", "to proceed"); + } else if (event == SdEvent::CARD_MOUNTED) { + oledShow3("SD card ready", "Mounted OK"); + } else if (event == SdEvent::CARD_REMOVED) { + oledShow3("SD card removed", "Please re-insert"); + } +} + +static void runCardWorkflow() { + g_sd.printCardInfo(); + + if (!g_sd.rewriteFile(kRootTestFile, kPayload)) { + Serial.println("Watcher action: root file write failed"); + return; + } + if (!g_sd.ensureDirRecursive(kNestedDir)) { + Serial.println("Watcher action: directory creation failed"); + return; + } + if (!g_sd.rewriteFile(kNestedTestFile, kPayload)) { + Serial.println("Watcher action: nested file write failed"); + return; + } + + g_sd.permissionsDemo(kRootTestFile); +} + +void setup() { + Serial.begin(115200); + Wire.begin(OLED_SDA, OLED_SCL); + g_oled.setI2CAddress(OLED_ADDR << 1); + g_oled.begin(); + oledShow3("Exercise 07", "SD startup watcher", "Booting..."); + + Serial.println("[WATCHER: startup]"); + Serial.printf("Sleeping for %lu ms to allow Serial Monitor connection...\r\n", + (unsigned long)STARTUP_SERIAL_DELAY_MS); + delay(STARTUP_SERIAL_DELAY_MS); + + Serial.println(); + Serial.println("=================================================="); + Serial.println("Exercise 07: SD Startup Watcher (Library Harness)"); + Serial.println("=================================================="); + Serial.printf("Pins: CS=%d SCK=%d MISO=%d MOSI=%d\r\n", + tbeam_supreme::sdCs(), tbeam_supreme::sdSck(), + tbeam_supreme::sdMiso(), tbeam_supreme::sdMosi()); + Serial.printf("PMU I2C: SDA1=%d SCL1=%d\r\n", + tbeam_supreme::i2cSda(), tbeam_supreme::i2cScl()); + Serial.println("Note: SD must be FAT16/FAT32 for Arduino SD library.\r\n"); + + SdWatcherConfig cfg; + cfg.enableSdRailCycle = true; + cfg.enablePinDumps = true; + cfg.startupWarmupMs = 1500; + cfg.pollIntervalAbsentMs = 1000; + cfg.pollIntervalMountedMs = 2000; + cfg.fullScanIntervalMs = 10000; + cfg.votesToPresent = 2; + cfg.votesToAbsent = 5; + + if (!g_sd.begin(cfg, onSdStatus)) { + Serial.println("ERROR: SD watcher init failed"); + } + + if (g_sd.isMounted()) { + runCardWorkflow(); + g_lastPeriodicActionMs = millis(); + } +} + +void loop() { + g_sd.update(); + + if (g_sd.consumeMountedEvent()) { + runCardWorkflow(); + g_lastPeriodicActionMs = millis(); + } + + if (g_sd.consumeRemovedEvent()) { + Serial.println("SD removed, waiting for re-insert..."); + } + + const uint32_t now = millis(); + if (g_sd.isMounted() && (uint32_t)(now - g_lastPeriodicActionMs) >= kPeriodicActionMs) { + Serial.println("Watcher: periodic mounted check action"); + runCardWorkflow(); + g_lastPeriodicActionMs = now; + } + + delay(10); +} diff --git a/exercises/08_SystemStartup/README.md b/exercises/08_SystemStartup/README.md new file mode 100644 index 0000000..c9b1a8d --- /dev/null +++ b/exercises/08_SystemStartup/README.md @@ -0,0 +1,43 @@ +## Exercise 08: SystemStartup Package Scaffold + +This exercise starts a reusable `SystemStartup` package that is intended to be shared by future exercises and field firmware. + +Current package responsibilities: + +1. Initialize OLED and show boot/status messages. +2. Initialize SD startup watcher. +3. Keep SD monitoring active in `loop()` with a single call. + +Current integration pattern: + +```cpp +#include "SystemStartup.h" + +static SystemStartup g_systemStartup(Serial); + +void setup() { + Serial.begin(115200); + g_systemStartup.begin(); +} + +void loop() { + g_systemStartup.update(); + delay(10); +} +``` + +This is the foundation for adding more startup subsystems (RTC sync/check, etc.) behind the same `begin()/update()` API. + +## Build + +```bash +source /home/jlpoole/rnsenv/bin/activate +pio run -e node_a +``` + +## Upload + +```bash +source /home/jlpoole/rnsenv/bin/activate +pio run -e node_a -t upload --upload-port /dev/ttyACM0 +``` diff --git a/exercises/08_SystemStartup/lib/startup_sd/StartupSdManager.cpp b/exercises/08_SystemStartup/lib/startup_sd/StartupSdManager.cpp new file mode 100644 index 0000000..1e8791c --- /dev/null +++ b/exercises/08_SystemStartup/lib/startup_sd/StartupSdManager.cpp @@ -0,0 +1,360 @@ +#include "StartupSdManager.h" + +#include +#include "driver/gpio.h" + +StartupSdManager::StartupSdManager(Print& serial) : serial_(serial) {} + +bool StartupSdManager::begin(const SdWatcherConfig& cfg, SdStatusCallback callback) { + cfg_ = cfg; + callback_ = callback; + + forceSpiDeselected(); + dumpSdPins("very-early"); + + if (!initPmuForSdPower()) { + return false; + } + + cycleSdRail(); + delay(cfg_.startupWarmupMs); + + bool warmMounted = false; + for (uint8_t i = 0; i < 3; ++i) { + if (mountPreferred(false)) { + warmMounted = true; + break; + } + delay(200); + } + + // Some cards need a longer power/settle window after cold boot. + // Before declaring ABSENT, retry with extended settle and a full scan. + if (!warmMounted) { + logf("Watcher: startup preferred mount failed, retrying with extended settle"); + cycleSdRail(400, 1200); + delay(cfg_.startupWarmupMs + 1500); + warmMounted = mountCardFullScan(); + } + + if (warmMounted) { + setStateMounted(); + } else { + setStateAbsent(); + } + return true; +} + +void StartupSdManager::update() { + const uint32_t now = millis(); + const uint32_t pollInterval = + (watchState_ == SdWatchState::MOUNTED) ? cfg_.pollIntervalMountedMs : cfg_.pollIntervalAbsentMs; + + if ((uint32_t)(now - lastPollMs_) < pollInterval) { + return; + } + lastPollMs_ = now; + + if (watchState_ == SdWatchState::MOUNTED) { + if (verifyMountedCard()) { + presentVotes_ = 0; + absentVotes_ = 0; + return; + } + + if (mountPreferred(false) && verifyMountedCard()) { + presentVotes_ = 0; + absentVotes_ = 0; + return; + } + + absentVotes_++; + presentVotes_ = 0; + if (absentVotes_ >= cfg_.votesToAbsent) { + setStateAbsent(); + absentVotes_ = 0; + } + return; + } + + bool mounted = mountPreferred(false); + if (!mounted && (uint32_t)(now - lastFullScanMs_) >= cfg_.fullScanIntervalMs) { + lastFullScanMs_ = now; + if (cfg_.recoveryRailCycleOnFullScan) { + logf("Watcher: recovery rail cycle before full scan"); + cycleSdRail(cfg_.recoveryRailOffMs, cfg_.recoveryRailOnSettleMs); + delay(150); + } + logf("Watcher: preferred probe failed, running full scan"); + mounted = mountCardFullScan(); + } + + if (mounted) { + presentVotes_++; + absentVotes_ = 0; + if (presentVotes_ >= cfg_.votesToPresent) { + setStateMounted(); + presentVotes_ = 0; + } + } else { + absentVotes_++; + presentVotes_ = 0; + if (absentVotes_ >= cfg_.votesToAbsent) { + setStateAbsent(); + absentVotes_ = 0; + } + } +} + +bool StartupSdManager::consumeMountedEvent() { + bool out = mountedEventPending_; + mountedEventPending_ = false; + return out; +} + +bool StartupSdManager::consumeRemovedEvent() { + bool out = removedEventPending_; + removedEventPending_ = false; + return out; +} + +void StartupSdManager::logf(const char* fmt, ...) { + char msg[196]; + va_list args; + va_start(args, fmt); + vsnprintf(msg, sizeof(msg), fmt, args); + va_end(args); + serial_.printf("[%10lu][%06lu] %s\r\n", + (unsigned long)millis(), + (unsigned long)logSeq_++, + msg); +} + +void StartupSdManager::notify(SdEvent event, const char* message) { + if (callback_ != nullptr) { + callback_(event, message); + } +} + +void StartupSdManager::forceSpiDeselected() { + pinMode(tbeam_supreme::sdCs(), OUTPUT); + digitalWrite(tbeam_supreme::sdCs(), HIGH); + pinMode(tbeam_supreme::imuCs(), OUTPUT); + digitalWrite(tbeam_supreme::imuCs(), HIGH); +} + +void StartupSdManager::dumpSdPins(const char* tag) { + if (!cfg_.enablePinDumps) { + (void)tag; + return; + } + + const gpio_num_t cs = (gpio_num_t)tbeam_supreme::sdCs(); + const gpio_num_t sck = (gpio_num_t)tbeam_supreme::sdSck(); + const gpio_num_t miso = (gpio_num_t)tbeam_supreme::sdMiso(); + const gpio_num_t mosi = (gpio_num_t)tbeam_supreme::sdMosi(); + logf("PINS(%s): CS=%d SCK=%d MISO=%d MOSI=%d", + tag, gpio_get_level(cs), gpio_get_level(sck), gpio_get_level(miso), gpio_get_level(mosi)); +} + +bool StartupSdManager::initPmuForSdPower() { + if (!tbeam_supreme::initPmuForPeripherals(pmu_, &serial_)) { + logf("ERROR: PMU init failed"); + return false; + } + return true; +} + +void StartupSdManager::cycleSdRail(uint32_t offMs, uint32_t onSettleMs) { + if (!cfg_.enableSdRailCycle) { + return; + } + if (!pmu_) { + logf("SD rail cycle skipped: pmu=null"); + return; + } + + forceSpiDeselected(); + pmu_->disablePowerOutput(XPOWERS_BLDO1); + delay(offMs); + pmu_->setPowerChannelVoltage(XPOWERS_BLDO1, 3300); + pmu_->enablePowerOutput(XPOWERS_BLDO1); + delay(onSettleMs); +} + +bool StartupSdManager::tryMountWithBus(SPIClass& bus, const char* busName, uint32_t hz, bool verbose) { + SD.end(); + bus.end(); + delay(10); + forceSpiDeselected(); + + bus.begin(tbeam_supreme::sdSck(), tbeam_supreme::sdMiso(), tbeam_supreme::sdMosi(), tbeam_supreme::sdCs()); + digitalWrite(tbeam_supreme::sdCs(), HIGH); + delay(2); + for (int i = 0; i < 10; i++) { + bus.transfer(0xFF); + } + delay(2); + + if (verbose) { + logf("SD: trying bus=%s freq=%lu Hz", busName, (unsigned long)hz); + } + + if (!SD.begin(tbeam_supreme::sdCs(), bus, hz)) { + if (verbose) { + logf("SD: mount failed (possible non-FAT format, power, or bus issue)"); + } + return false; + } + + if (SD.cardType() == CARD_NONE) { + SD.end(); + return false; + } + + sdSpi_ = &bus; + sdBusName_ = busName; + sdFreq_ = hz; + return true; +} + +bool StartupSdManager::mountPreferred(bool verbose) { + return tryMountWithBus(sdSpiH_, "HSPI", 400000, verbose); +} + +bool StartupSdManager::mountCardFullScan() { + const uint32_t freqs[] = {400000, 1000000, 4000000, 10000000}; + + for (uint8_t i = 0; i < (sizeof(freqs) / sizeof(freqs[0])); ++i) { + if (tryMountWithBus(sdSpiH_, "HSPI", freqs[i], true)) { + logf("SD: card detected and mounted"); + return true; + } + } + for (uint8_t i = 0; i < (sizeof(freqs) / sizeof(freqs[0])); ++i) { + if (tryMountWithBus(sdSpiF_, "FSPI", freqs[i], true)) { + logf("SD: card detected and mounted"); + return true; + } + } + + logf("SD: begin() failed on all bus/frequency attempts"); + return false; +} + +bool StartupSdManager::verifyMountedCard() { + File root = SD.open("/", FILE_READ); + if (!root) { + return false; + } + root.close(); + return true; +} + +const char* StartupSdManager::cardTypeToString(uint8_t type) { + switch (type) { + case CARD_MMC: + return "MMC"; + case CARD_SD: + return "SDSC"; + case CARD_SDHC: + return "SDHC/SDXC"; + default: + return "UNKNOWN"; + } +} + +void StartupSdManager::printCardInfo() { + uint8_t cardType = SD.cardType(); + uint64_t cardSizeMB = SD.cardSize() / (1024ULL * 1024ULL); + uint64_t totalMB = SD.totalBytes() / (1024ULL * 1024ULL); + uint64_t usedMB = SD.usedBytes() / (1024ULL * 1024ULL); + + logf("SD type: %s", cardTypeToString(cardType)); + logf("SD size: %llu MB", cardSizeMB); + logf("FS total: %llu MB", totalMB); + logf("FS used : %llu MB", usedMB); + logf("SPI bus: %s @ %lu Hz", sdBusName_, (unsigned long)sdFreq_); +} + +bool StartupSdManager::ensureDirRecursive(const char* path) { + String full(path); + if (!full.startsWith("/")) { + full = "/" + full; + } + + int start = 1; + while (start > 0 && start < (int)full.length()) { + int slash = full.indexOf('/', start); + String partial = (slash < 0) ? full : full.substring(0, slash); + if (!SD.exists(partial.c_str()) && !SD.mkdir(partial.c_str())) { + logf("ERROR: mkdir failed for %s", partial.c_str()); + return false; + } + if (slash < 0) { + break; + } + start = slash + 1; + } + + return true; +} + +bool StartupSdManager::rewriteFile(const char* path, const char* payload) { + if (SD.exists(path) && !SD.remove(path)) { + logf("ERROR: failed to erase %s", path); + return false; + } + + File f = SD.open(path, FILE_WRITE); + if (!f) { + logf("ERROR: failed to create %s", path); + return false; + } + + size_t wrote = f.println(payload); + f.close(); + if (wrote == 0) { + logf("ERROR: write failed for %s", path); + return false; + } + return true; +} + +void StartupSdManager::permissionsDemo(const char* path) { + logf("Permissions demo: FAT has no Unix chmod/chown, use open mode only."); + File r = SD.open(path, FILE_READ); + if (!r) { + logf("Could not open %s as FILE_READ", path); + return; + } + size_t writeInReadMode = r.print("attempt write while opened read-only"); + if (writeInReadMode == 0) { + logf("As expected, FILE_READ write was blocked."); + } else { + logf("NOTE: FILE_READ write returned %u (unexpected)", (unsigned)writeInReadMode); + } + r.close(); +} + +void StartupSdManager::setStateMounted() { + if (watchState_ != SdWatchState::MOUNTED) { + logf("EVENT: card inserted/mounted"); + mountedEventPending_ = true; + notify(SdEvent::CARD_MOUNTED, "SD card mounted"); + } + watchState_ = SdWatchState::MOUNTED; +} + +void StartupSdManager::setStateAbsent() { + if (watchState_ == SdWatchState::MOUNTED) { + logf("EVENT: card removed/unavailable"); + removedEventPending_ = true; + notify(SdEvent::CARD_REMOVED, "SD card removed"); + } else if (watchState_ != SdWatchState::ABSENT) { + logf("EVENT: no card detected"); + notify(SdEvent::NO_CARD, "Missing SD card or invalid FAT16/FAT32 format"); + } + SD.end(); + watchState_ = SdWatchState::ABSENT; +} diff --git a/exercises/08_SystemStartup/lib/startup_sd/StartupSdManager.h b/exercises/08_SystemStartup/lib/startup_sd/StartupSdManager.h new file mode 100644 index 0000000..be9ef27 --- /dev/null +++ b/exercises/08_SystemStartup/lib/startup_sd/StartupSdManager.h @@ -0,0 +1,90 @@ +#pragma once + +#include +#include +#include +#include +#include "tbeam_supreme_adapter.h" + +enum class SdWatchState : uint8_t { + UNKNOWN = 0, + ABSENT, + MOUNTED +}; + +enum class SdEvent : uint8_t { + NO_CARD, + CARD_MOUNTED, + CARD_REMOVED +}; + +using SdStatusCallback = void (*)(SdEvent event, const char* message); + +struct SdWatcherConfig { + bool enableSdRailCycle = true; + bool enablePinDumps = true; + bool recoveryRailCycleOnFullScan = true; + uint32_t recoveryRailOffMs = 250; + uint32_t recoveryRailOnSettleMs = 700; + uint32_t startupWarmupMs = 1500; + uint32_t pollIntervalAbsentMs = 1000; + uint32_t pollIntervalMountedMs = 2000; + uint32_t fullScanIntervalMs = 10000; + uint8_t votesToPresent = 2; + uint8_t votesToAbsent = 5; +}; + +class StartupSdManager { + public: + explicit StartupSdManager(Print& serial = Serial); + + bool begin(const SdWatcherConfig& cfg, SdStatusCallback callback = nullptr); + void update(); + + bool isMounted() const { return watchState_ == SdWatchState::MOUNTED; } + SdWatchState state() const { return watchState_; } + + bool consumeMountedEvent(); + bool consumeRemovedEvent(); + + void printCardInfo(); + bool ensureDirRecursive(const char* path); + bool rewriteFile(const char* path, const char* payload); + void permissionsDemo(const char* path); + + private: + void logf(const char* fmt, ...); + void notify(SdEvent event, const char* message); + void forceSpiDeselected(); + void dumpSdPins(const char* tag); + bool initPmuForSdPower(); + void cycleSdRail(uint32_t offMs = 250, uint32_t onSettleMs = 600); + bool tryMountWithBus(SPIClass& bus, const char* busName, uint32_t hz, bool verbose); + bool mountPreferred(bool verbose); + bool mountCardFullScan(); + bool verifyMountedCard(); + const char* cardTypeToString(uint8_t type); + void setStateMounted(); + void setStateAbsent(); + + Print& serial_; + SdWatcherConfig cfg_{}; + SdStatusCallback callback_ = nullptr; + + SPIClass sdSpiH_{HSPI}; + SPIClass sdSpiF_{FSPI}; + SPIClass* sdSpi_ = nullptr; + const char* sdBusName_ = "none"; + uint32_t sdFreq_ = 0; + XPowersLibInterface* pmu_ = nullptr; + + SdWatchState watchState_ = SdWatchState::UNKNOWN; + uint8_t presentVotes_ = 0; + uint8_t absentVotes_ = 0; + uint32_t lastPollMs_ = 0; + uint32_t lastFullScanMs_ = 0; + uint32_t logSeq_ = 0; + + bool mountedEventPending_ = false; + bool removedEventPending_ = false; +}; diff --git a/exercises/08_SystemStartup/lib/startup_sd/library.json b/exercises/08_SystemStartup/lib/startup_sd/library.json new file mode 100644 index 0000000..4978fdd --- /dev/null +++ b/exercises/08_SystemStartup/lib/startup_sd/library.json @@ -0,0 +1,12 @@ +{ + "name": "startup_sd", + "version": "0.1.0", + "dependencies": [ + { + "name": "XPowersLib" + }, + { + "name": "Wire" + } + ] +} diff --git a/exercises/08_SystemStartup/lib/system_startup/SystemStartup.cpp b/exercises/08_SystemStartup/lib/system_startup/SystemStartup.cpp new file mode 100644 index 0000000..3a82e1f --- /dev/null +++ b/exercises/08_SystemStartup/lib/system_startup/SystemStartup.cpp @@ -0,0 +1,96 @@ +#include "SystemStartup.h" + +#include +#include + +#ifndef OLED_SDA +#define OLED_SDA 17 +#endif + +#ifndef OLED_SCL +#define OLED_SCL 18 +#endif + +#ifndef OLED_ADDR +#define OLED_ADDR 0x3C +#endif + +static const bool kEnableOled = true; + +static U8G2_SH1106_128X64_NONAME_F_HW_I2C g_oled(U8G2_R0, /* reset=*/U8X8_PIN_NONE); +static SystemStartup* g_activeSystemStartup = nullptr; + +static void forceSpiDeselectedEarly() { + pinMode(tbeam_supreme::sdCs(), OUTPUT); + digitalWrite(tbeam_supreme::sdCs(), HIGH); + pinMode(tbeam_supreme::imuCs(), OUTPUT); + digitalWrite(tbeam_supreme::imuCs(), HIGH); +} + +SystemStartup::SystemStartup(Print& serial) : serial_(serial), sd_(serial) {} + +bool SystemStartup::begin(const SystemStartupConfig& cfg, SystemEventCallback callback) { + cfg_ = cfg; + callback_ = callback; + g_activeSystemStartup = this; + + // Match Exercise 05 behavior: deselect SPI devices immediately at startup. + forceSpiDeselectedEarly(); + + if (kEnableOled) { + Wire.begin(OLED_SDA, OLED_SCL); + g_oled.setI2CAddress(OLED_ADDR << 1); + g_oled.begin(); + } + + emit(SystemEvent::BOOTING, "System startup booting"); + oledShow3("System Startup", "Booting..."); + + serial_.printf("Sleeping for %lu ms to allow Serial Monitor connection...\r\n", + (unsigned long)cfg_.serialDelayMs); + delay(cfg_.serialDelayMs); + + return sd_.begin(cfg_.sd, &SystemStartup::onSdEventThunk); +} + +void SystemStartup::update() { + sd_.update(); +} + +void SystemStartup::onSdEventThunk(SdEvent event, const char* message) { + if (g_activeSystemStartup != nullptr) { + g_activeSystemStartup->onSdEvent(event, message); + } +} + +void SystemStartup::onSdEvent(SdEvent event, const char* message) { + if (event == SdEvent::NO_CARD) { + oledShow3("SD missing or", "invalid FAT16/32", "Insert/format card"); + emit(SystemEvent::SD_MISSING, message); + } else if (event == SdEvent::CARD_MOUNTED) { + oledShow3("SD card ready", "Mounted OK"); + emit(SystemEvent::SD_READY, message); + } else if (event == SdEvent::CARD_REMOVED) { + oledShow3("SD card removed", "Please re-insert"); + emit(SystemEvent::SD_REMOVED, message); + } +} + +void SystemStartup::emit(SystemEvent event, const char* message) { + serial_.printf("[SYSTEM] %s\r\n", message); + if (callback_ != nullptr) { + callback_(event, message); + } +} + +void SystemStartup::oledShow3(const char* l1, const char* l2, const char* l3) { + if (!kEnableOled) { + return; + } + g_oled.clearBuffer(); + g_oled.setFont(u8g2_font_6x10_tf); + if (l1) g_oled.drawUTF8(0, 16, l1); + if (l2) g_oled.drawUTF8(0, 32, l2); + if (l3) g_oled.drawUTF8(0, 48, l3); + g_oled.sendBuffer(); +} diff --git a/exercises/08_SystemStartup/lib/system_startup/SystemStartup.h b/exercises/08_SystemStartup/lib/system_startup/SystemStartup.h new file mode 100644 index 0000000..cd83857 --- /dev/null +++ b/exercises/08_SystemStartup/lib/system_startup/SystemStartup.h @@ -0,0 +1,46 @@ +#pragma once + +#include +#include "StartupSdManager.h" + +// Convenience alias so sketches can use System.println(...) style logging. +// Arduino exposes Serial, not System, so map System -> Serial. +#ifndef System +#define System Serial +#endif + +enum class SystemEvent : uint8_t { + BOOTING = 0, + SD_MISSING, + SD_READY, + SD_REMOVED +}; + +using SystemEventCallback = void (*)(SystemEvent event, const char* message); + +struct SystemStartupConfig { + uint32_t serialDelayMs = 5000; + SdWatcherConfig sd{}; +}; + +class SystemStartup { + public: + explicit SystemStartup(Print& serial = Serial); + + bool begin(const SystemStartupConfig& cfg = SystemStartupConfig{}, SystemEventCallback callback = nullptr); + void update(); + + bool isSdMounted() const { return sd_.isMounted(); } + StartupSdManager& sdManager() { return sd_; } + + private: + static void onSdEventThunk(SdEvent event, const char* message); + void onSdEvent(SdEvent event, const char* message); + void emit(SystemEvent event, const char* message); + void oledShow3(const char* l1, const char* l2 = nullptr, const char* l3 = nullptr); + + Print& serial_; + SystemStartupConfig cfg_{}; + SystemEventCallback callback_ = nullptr; + StartupSdManager sd_; +}; diff --git a/exercises/08_SystemStartup/lib/system_startup/library.json b/exercises/08_SystemStartup/lib/system_startup/library.json new file mode 100644 index 0000000..0c2c1cf --- /dev/null +++ b/exercises/08_SystemStartup/lib/system_startup/library.json @@ -0,0 +1,15 @@ +{ + "name": "system_startup", + "version": "0.1.0", + "dependencies": [ + { + "name": "startup_sd" + }, + { + "name": "U8g2" + }, + { + "name": "Wire" + } + ] +} diff --git a/exercises/08_SystemStartup/platformio.ini b/exercises/08_SystemStartup/platformio.ini new file mode 100644 index 0000000..e760c56 --- /dev/null +++ b/exercises/08_SystemStartup/platformio.ini @@ -0,0 +1,37 @@ +; 20260213 ChatGPT +; $Id$ +; $HeadURL$ + +[platformio] +default_envs = node_a + +[env] +platform = espressif32 +framework = arduino +board = esp32-s3-devkitc-1 +monitor_speed = 115200 +lib_deps = + lewisxhe/XPowersLib@0.3.3 + Wire + olikraus/U8g2@^2.36.4 + +; SD pins based on T-Beam S3 core pin mapping +build_flags = + -I ../../shared/boards + -I ../../external/microReticulum_Firmware + -D BOARD_MODEL=BOARD_TBEAM_S_V1 + -D OLED_SDA=17 + -D OLED_SCL=18 + -D OLED_ADDR=0x3C + -D ARDUINO_USB_MODE=1 + -D ARDUINO_USB_CDC_ON_BOOT=1 + +[env:node_a] +build_flags = + ${env.build_flags} + -D NODE_LABEL=\"A\" + +[env:node_b] +build_flags = + ${env.build_flags} + -D NODE_LABEL=\"B\" diff --git a/exercises/08_SystemStartup/src/main.cpp b/exercises/08_SystemStartup/src/main.cpp new file mode 100644 index 0000000..5f766e7 --- /dev/null +++ b/exercises/08_SystemStartup/src/main.cpp @@ -0,0 +1,21 @@ +// 20260216 ChatGPT +// $Id$ +// $HeadURL$ + +#include +#include "SystemStartup.h" + +static SystemStartup g_systemStartup(Serial); + +void setup() { + Serial.begin(115200); + System.println("Example 08: setup() called. About to call g_systemStartup.begin()."); + g_systemStartup.begin(); + System.println("After g_systemStartup() in setup()."); +} + +void loop() { + g_systemStartup.update(); + System.println("Example 08 loop() called and after g_systemStartup.update()."); + delay(1000); +} diff --git a/exercises/09_GPS_Time/README.md b/exercises/09_GPS_Time/README.md new file mode 100644 index 0000000..f3e2889 --- /dev/null +++ b/exercises/09_GPS_Time/README.md @@ -0,0 +1,52 @@ +## Exercise 09: GPS Time (L76K + UBLOX) + +This exercise boots the T-Beam Supreme and verifies GPS behavior at startup. + +Important sequence note: + +- Exercise 10 (`10_Simple_GPS`) should be completed before this exercise. +- Exercise 10 README contains the detailed pin-configuration explanation and troubleshooting rationale for why explicit GPS pin mapping is critical on this hardware. +- If GPS behavior is unexpected here, review Exercise 10 README first, then return to Exercise 9. + +Implemented behavior: + +1. Initializes PMU, OLED, and SD startup watcher (same startup SD path used in Exercise 08). +2. Probes GPS at startup for NMEA traffic, module identity, satellite count, and UTC time availability. + - Uses explicit GPS UART pins and an active startup probe (multi-baud + common GPS query commands), aligned with the approach validated in Exercise 10. +3. Supports both module profiles via `platformio.ini` build flags: + - `node_a` / `node_b`: `GPS_L76K` + - `node_c`: `GPS_UBLOX` +4. If detected module data conflicts with the selected node profile, OLED shows a `GPS module mismatch` error. +5. Every minute: + - If GPS UTC is valid: shows GPS UTC time and satellites on OLED. + - If satellites are seen but UTC is not valid yet: shows that condition and RTC time. + - If no satellites: shows: + - "Unable to acquire" + - "satellites" + - "Take me outside so I" + - "can see satellites" + - plus current RTC time. + +Notes: + +- GPS time displayed is UTC from NMEA RMC with valid status. +- Satellite count uses best available from GGA/GSA/GSV. +- RTC fallback reads PCF8563 via Wire1. +- For UBLOX hardware use `-e node_c`. +- The UBLOX MAX-M10S path is given a longer startup window than L76K because cold starts are slower, especially if backup power/orbit data are unavailable. +- On T-Beam Supreme, `GPS_WAKEUP_PIN=7` is relevant for the L76K variant; the UBLOX MAX-M10S does not use that wake pin in the same way. +- For fastest UBLOX reacquisition, test with the 18650 attached so the GNSS backup domain can preserve assistance state across resets/power cycles. + +## Build + +```bash +source /home/jlpoole/rnsenv/bin/activate +pio run -e node_a +``` + +## Upload + +```bash +source /home/jlpoole/rnsenv/bin/activate +pio run -e node_a -t upload --upload-port /dev/ttyACM0 +``` diff --git a/exercises/09_GPS_Time/lib/startup_sd/StartupSdManager.cpp b/exercises/09_GPS_Time/lib/startup_sd/StartupSdManager.cpp new file mode 100644 index 0000000..1e8791c --- /dev/null +++ b/exercises/09_GPS_Time/lib/startup_sd/StartupSdManager.cpp @@ -0,0 +1,360 @@ +#include "StartupSdManager.h" + +#include +#include "driver/gpio.h" + +StartupSdManager::StartupSdManager(Print& serial) : serial_(serial) {} + +bool StartupSdManager::begin(const SdWatcherConfig& cfg, SdStatusCallback callback) { + cfg_ = cfg; + callback_ = callback; + + forceSpiDeselected(); + dumpSdPins("very-early"); + + if (!initPmuForSdPower()) { + return false; + } + + cycleSdRail(); + delay(cfg_.startupWarmupMs); + + bool warmMounted = false; + for (uint8_t i = 0; i < 3; ++i) { + if (mountPreferred(false)) { + warmMounted = true; + break; + } + delay(200); + } + + // Some cards need a longer power/settle window after cold boot. + // Before declaring ABSENT, retry with extended settle and a full scan. + if (!warmMounted) { + logf("Watcher: startup preferred mount failed, retrying with extended settle"); + cycleSdRail(400, 1200); + delay(cfg_.startupWarmupMs + 1500); + warmMounted = mountCardFullScan(); + } + + if (warmMounted) { + setStateMounted(); + } else { + setStateAbsent(); + } + return true; +} + +void StartupSdManager::update() { + const uint32_t now = millis(); + const uint32_t pollInterval = + (watchState_ == SdWatchState::MOUNTED) ? cfg_.pollIntervalMountedMs : cfg_.pollIntervalAbsentMs; + + if ((uint32_t)(now - lastPollMs_) < pollInterval) { + return; + } + lastPollMs_ = now; + + if (watchState_ == SdWatchState::MOUNTED) { + if (verifyMountedCard()) { + presentVotes_ = 0; + absentVotes_ = 0; + return; + } + + if (mountPreferred(false) && verifyMountedCard()) { + presentVotes_ = 0; + absentVotes_ = 0; + return; + } + + absentVotes_++; + presentVotes_ = 0; + if (absentVotes_ >= cfg_.votesToAbsent) { + setStateAbsent(); + absentVotes_ = 0; + } + return; + } + + bool mounted = mountPreferred(false); + if (!mounted && (uint32_t)(now - lastFullScanMs_) >= cfg_.fullScanIntervalMs) { + lastFullScanMs_ = now; + if (cfg_.recoveryRailCycleOnFullScan) { + logf("Watcher: recovery rail cycle before full scan"); + cycleSdRail(cfg_.recoveryRailOffMs, cfg_.recoveryRailOnSettleMs); + delay(150); + } + logf("Watcher: preferred probe failed, running full scan"); + mounted = mountCardFullScan(); + } + + if (mounted) { + presentVotes_++; + absentVotes_ = 0; + if (presentVotes_ >= cfg_.votesToPresent) { + setStateMounted(); + presentVotes_ = 0; + } + } else { + absentVotes_++; + presentVotes_ = 0; + if (absentVotes_ >= cfg_.votesToAbsent) { + setStateAbsent(); + absentVotes_ = 0; + } + } +} + +bool StartupSdManager::consumeMountedEvent() { + bool out = mountedEventPending_; + mountedEventPending_ = false; + return out; +} + +bool StartupSdManager::consumeRemovedEvent() { + bool out = removedEventPending_; + removedEventPending_ = false; + return out; +} + +void StartupSdManager::logf(const char* fmt, ...) { + char msg[196]; + va_list args; + va_start(args, fmt); + vsnprintf(msg, sizeof(msg), fmt, args); + va_end(args); + serial_.printf("[%10lu][%06lu] %s\r\n", + (unsigned long)millis(), + (unsigned long)logSeq_++, + msg); +} + +void StartupSdManager::notify(SdEvent event, const char* message) { + if (callback_ != nullptr) { + callback_(event, message); + } +} + +void StartupSdManager::forceSpiDeselected() { + pinMode(tbeam_supreme::sdCs(), OUTPUT); + digitalWrite(tbeam_supreme::sdCs(), HIGH); + pinMode(tbeam_supreme::imuCs(), OUTPUT); + digitalWrite(tbeam_supreme::imuCs(), HIGH); +} + +void StartupSdManager::dumpSdPins(const char* tag) { + if (!cfg_.enablePinDumps) { + (void)tag; + return; + } + + const gpio_num_t cs = (gpio_num_t)tbeam_supreme::sdCs(); + const gpio_num_t sck = (gpio_num_t)tbeam_supreme::sdSck(); + const gpio_num_t miso = (gpio_num_t)tbeam_supreme::sdMiso(); + const gpio_num_t mosi = (gpio_num_t)tbeam_supreme::sdMosi(); + logf("PINS(%s): CS=%d SCK=%d MISO=%d MOSI=%d", + tag, gpio_get_level(cs), gpio_get_level(sck), gpio_get_level(miso), gpio_get_level(mosi)); +} + +bool StartupSdManager::initPmuForSdPower() { + if (!tbeam_supreme::initPmuForPeripherals(pmu_, &serial_)) { + logf("ERROR: PMU init failed"); + return false; + } + return true; +} + +void StartupSdManager::cycleSdRail(uint32_t offMs, uint32_t onSettleMs) { + if (!cfg_.enableSdRailCycle) { + return; + } + if (!pmu_) { + logf("SD rail cycle skipped: pmu=null"); + return; + } + + forceSpiDeselected(); + pmu_->disablePowerOutput(XPOWERS_BLDO1); + delay(offMs); + pmu_->setPowerChannelVoltage(XPOWERS_BLDO1, 3300); + pmu_->enablePowerOutput(XPOWERS_BLDO1); + delay(onSettleMs); +} + +bool StartupSdManager::tryMountWithBus(SPIClass& bus, const char* busName, uint32_t hz, bool verbose) { + SD.end(); + bus.end(); + delay(10); + forceSpiDeselected(); + + bus.begin(tbeam_supreme::sdSck(), tbeam_supreme::sdMiso(), tbeam_supreme::sdMosi(), tbeam_supreme::sdCs()); + digitalWrite(tbeam_supreme::sdCs(), HIGH); + delay(2); + for (int i = 0; i < 10; i++) { + bus.transfer(0xFF); + } + delay(2); + + if (verbose) { + logf("SD: trying bus=%s freq=%lu Hz", busName, (unsigned long)hz); + } + + if (!SD.begin(tbeam_supreme::sdCs(), bus, hz)) { + if (verbose) { + logf("SD: mount failed (possible non-FAT format, power, or bus issue)"); + } + return false; + } + + if (SD.cardType() == CARD_NONE) { + SD.end(); + return false; + } + + sdSpi_ = &bus; + sdBusName_ = busName; + sdFreq_ = hz; + return true; +} + +bool StartupSdManager::mountPreferred(bool verbose) { + return tryMountWithBus(sdSpiH_, "HSPI", 400000, verbose); +} + +bool StartupSdManager::mountCardFullScan() { + const uint32_t freqs[] = {400000, 1000000, 4000000, 10000000}; + + for (uint8_t i = 0; i < (sizeof(freqs) / sizeof(freqs[0])); ++i) { + if (tryMountWithBus(sdSpiH_, "HSPI", freqs[i], true)) { + logf("SD: card detected and mounted"); + return true; + } + } + for (uint8_t i = 0; i < (sizeof(freqs) / sizeof(freqs[0])); ++i) { + if (tryMountWithBus(sdSpiF_, "FSPI", freqs[i], true)) { + logf("SD: card detected and mounted"); + return true; + } + } + + logf("SD: begin() failed on all bus/frequency attempts"); + return false; +} + +bool StartupSdManager::verifyMountedCard() { + File root = SD.open("/", FILE_READ); + if (!root) { + return false; + } + root.close(); + return true; +} + +const char* StartupSdManager::cardTypeToString(uint8_t type) { + switch (type) { + case CARD_MMC: + return "MMC"; + case CARD_SD: + return "SDSC"; + case CARD_SDHC: + return "SDHC/SDXC"; + default: + return "UNKNOWN"; + } +} + +void StartupSdManager::printCardInfo() { + uint8_t cardType = SD.cardType(); + uint64_t cardSizeMB = SD.cardSize() / (1024ULL * 1024ULL); + uint64_t totalMB = SD.totalBytes() / (1024ULL * 1024ULL); + uint64_t usedMB = SD.usedBytes() / (1024ULL * 1024ULL); + + logf("SD type: %s", cardTypeToString(cardType)); + logf("SD size: %llu MB", cardSizeMB); + logf("FS total: %llu MB", totalMB); + logf("FS used : %llu MB", usedMB); + logf("SPI bus: %s @ %lu Hz", sdBusName_, (unsigned long)sdFreq_); +} + +bool StartupSdManager::ensureDirRecursive(const char* path) { + String full(path); + if (!full.startsWith("/")) { + full = "/" + full; + } + + int start = 1; + while (start > 0 && start < (int)full.length()) { + int slash = full.indexOf('/', start); + String partial = (slash < 0) ? full : full.substring(0, slash); + if (!SD.exists(partial.c_str()) && !SD.mkdir(partial.c_str())) { + logf("ERROR: mkdir failed for %s", partial.c_str()); + return false; + } + if (slash < 0) { + break; + } + start = slash + 1; + } + + return true; +} + +bool StartupSdManager::rewriteFile(const char* path, const char* payload) { + if (SD.exists(path) && !SD.remove(path)) { + logf("ERROR: failed to erase %s", path); + return false; + } + + File f = SD.open(path, FILE_WRITE); + if (!f) { + logf("ERROR: failed to create %s", path); + return false; + } + + size_t wrote = f.println(payload); + f.close(); + if (wrote == 0) { + logf("ERROR: write failed for %s", path); + return false; + } + return true; +} + +void StartupSdManager::permissionsDemo(const char* path) { + logf("Permissions demo: FAT has no Unix chmod/chown, use open mode only."); + File r = SD.open(path, FILE_READ); + if (!r) { + logf("Could not open %s as FILE_READ", path); + return; + } + size_t writeInReadMode = r.print("attempt write while opened read-only"); + if (writeInReadMode == 0) { + logf("As expected, FILE_READ write was blocked."); + } else { + logf("NOTE: FILE_READ write returned %u (unexpected)", (unsigned)writeInReadMode); + } + r.close(); +} + +void StartupSdManager::setStateMounted() { + if (watchState_ != SdWatchState::MOUNTED) { + logf("EVENT: card inserted/mounted"); + mountedEventPending_ = true; + notify(SdEvent::CARD_MOUNTED, "SD card mounted"); + } + watchState_ = SdWatchState::MOUNTED; +} + +void StartupSdManager::setStateAbsent() { + if (watchState_ == SdWatchState::MOUNTED) { + logf("EVENT: card removed/unavailable"); + removedEventPending_ = true; + notify(SdEvent::CARD_REMOVED, "SD card removed"); + } else if (watchState_ != SdWatchState::ABSENT) { + logf("EVENT: no card detected"); + notify(SdEvent::NO_CARD, "Missing SD card or invalid FAT16/FAT32 format"); + } + SD.end(); + watchState_ = SdWatchState::ABSENT; +} diff --git a/exercises/09_GPS_Time/lib/startup_sd/StartupSdManager.h b/exercises/09_GPS_Time/lib/startup_sd/StartupSdManager.h new file mode 100644 index 0000000..be9ef27 --- /dev/null +++ b/exercises/09_GPS_Time/lib/startup_sd/StartupSdManager.h @@ -0,0 +1,90 @@ +#pragma once + +#include +#include +#include +#include +#include "tbeam_supreme_adapter.h" + +enum class SdWatchState : uint8_t { + UNKNOWN = 0, + ABSENT, + MOUNTED +}; + +enum class SdEvent : uint8_t { + NO_CARD, + CARD_MOUNTED, + CARD_REMOVED +}; + +using SdStatusCallback = void (*)(SdEvent event, const char* message); + +struct SdWatcherConfig { + bool enableSdRailCycle = true; + bool enablePinDumps = true; + bool recoveryRailCycleOnFullScan = true; + uint32_t recoveryRailOffMs = 250; + uint32_t recoveryRailOnSettleMs = 700; + uint32_t startupWarmupMs = 1500; + uint32_t pollIntervalAbsentMs = 1000; + uint32_t pollIntervalMountedMs = 2000; + uint32_t fullScanIntervalMs = 10000; + uint8_t votesToPresent = 2; + uint8_t votesToAbsent = 5; +}; + +class StartupSdManager { + public: + explicit StartupSdManager(Print& serial = Serial); + + bool begin(const SdWatcherConfig& cfg, SdStatusCallback callback = nullptr); + void update(); + + bool isMounted() const { return watchState_ == SdWatchState::MOUNTED; } + SdWatchState state() const { return watchState_; } + + bool consumeMountedEvent(); + bool consumeRemovedEvent(); + + void printCardInfo(); + bool ensureDirRecursive(const char* path); + bool rewriteFile(const char* path, const char* payload); + void permissionsDemo(const char* path); + + private: + void logf(const char* fmt, ...); + void notify(SdEvent event, const char* message); + void forceSpiDeselected(); + void dumpSdPins(const char* tag); + bool initPmuForSdPower(); + void cycleSdRail(uint32_t offMs = 250, uint32_t onSettleMs = 600); + bool tryMountWithBus(SPIClass& bus, const char* busName, uint32_t hz, bool verbose); + bool mountPreferred(bool verbose); + bool mountCardFullScan(); + bool verifyMountedCard(); + const char* cardTypeToString(uint8_t type); + void setStateMounted(); + void setStateAbsent(); + + Print& serial_; + SdWatcherConfig cfg_{}; + SdStatusCallback callback_ = nullptr; + + SPIClass sdSpiH_{HSPI}; + SPIClass sdSpiF_{FSPI}; + SPIClass* sdSpi_ = nullptr; + const char* sdBusName_ = "none"; + uint32_t sdFreq_ = 0; + XPowersLibInterface* pmu_ = nullptr; + + SdWatchState watchState_ = SdWatchState::UNKNOWN; + uint8_t presentVotes_ = 0; + uint8_t absentVotes_ = 0; + uint32_t lastPollMs_ = 0; + uint32_t lastFullScanMs_ = 0; + uint32_t logSeq_ = 0; + + bool mountedEventPending_ = false; + bool removedEventPending_ = false; +}; diff --git a/exercises/09_GPS_Time/lib/startup_sd/library.json b/exercises/09_GPS_Time/lib/startup_sd/library.json new file mode 100644 index 0000000..4978fdd --- /dev/null +++ b/exercises/09_GPS_Time/lib/startup_sd/library.json @@ -0,0 +1,12 @@ +{ + "name": "startup_sd", + "version": "0.1.0", + "dependencies": [ + { + "name": "XPowersLib" + }, + { + "name": "Wire" + } + ] +} diff --git a/exercises/09_GPS_Time/lib/system_startup/SystemStartup.cpp b/exercises/09_GPS_Time/lib/system_startup/SystemStartup.cpp new file mode 100644 index 0000000..3a82e1f --- /dev/null +++ b/exercises/09_GPS_Time/lib/system_startup/SystemStartup.cpp @@ -0,0 +1,96 @@ +#include "SystemStartup.h" + +#include +#include + +#ifndef OLED_SDA +#define OLED_SDA 17 +#endif + +#ifndef OLED_SCL +#define OLED_SCL 18 +#endif + +#ifndef OLED_ADDR +#define OLED_ADDR 0x3C +#endif + +static const bool kEnableOled = true; + +static U8G2_SH1106_128X64_NONAME_F_HW_I2C g_oled(U8G2_R0, /* reset=*/U8X8_PIN_NONE); +static SystemStartup* g_activeSystemStartup = nullptr; + +static void forceSpiDeselectedEarly() { + pinMode(tbeam_supreme::sdCs(), OUTPUT); + digitalWrite(tbeam_supreme::sdCs(), HIGH); + pinMode(tbeam_supreme::imuCs(), OUTPUT); + digitalWrite(tbeam_supreme::imuCs(), HIGH); +} + +SystemStartup::SystemStartup(Print& serial) : serial_(serial), sd_(serial) {} + +bool SystemStartup::begin(const SystemStartupConfig& cfg, SystemEventCallback callback) { + cfg_ = cfg; + callback_ = callback; + g_activeSystemStartup = this; + + // Match Exercise 05 behavior: deselect SPI devices immediately at startup. + forceSpiDeselectedEarly(); + + if (kEnableOled) { + Wire.begin(OLED_SDA, OLED_SCL); + g_oled.setI2CAddress(OLED_ADDR << 1); + g_oled.begin(); + } + + emit(SystemEvent::BOOTING, "System startup booting"); + oledShow3("System Startup", "Booting..."); + + serial_.printf("Sleeping for %lu ms to allow Serial Monitor connection...\r\n", + (unsigned long)cfg_.serialDelayMs); + delay(cfg_.serialDelayMs); + + return sd_.begin(cfg_.sd, &SystemStartup::onSdEventThunk); +} + +void SystemStartup::update() { + sd_.update(); +} + +void SystemStartup::onSdEventThunk(SdEvent event, const char* message) { + if (g_activeSystemStartup != nullptr) { + g_activeSystemStartup->onSdEvent(event, message); + } +} + +void SystemStartup::onSdEvent(SdEvent event, const char* message) { + if (event == SdEvent::NO_CARD) { + oledShow3("SD missing or", "invalid FAT16/32", "Insert/format card"); + emit(SystemEvent::SD_MISSING, message); + } else if (event == SdEvent::CARD_MOUNTED) { + oledShow3("SD card ready", "Mounted OK"); + emit(SystemEvent::SD_READY, message); + } else if (event == SdEvent::CARD_REMOVED) { + oledShow3("SD card removed", "Please re-insert"); + emit(SystemEvent::SD_REMOVED, message); + } +} + +void SystemStartup::emit(SystemEvent event, const char* message) { + serial_.printf("[SYSTEM] %s\r\n", message); + if (callback_ != nullptr) { + callback_(event, message); + } +} + +void SystemStartup::oledShow3(const char* l1, const char* l2, const char* l3) { + if (!kEnableOled) { + return; + } + g_oled.clearBuffer(); + g_oled.setFont(u8g2_font_6x10_tf); + if (l1) g_oled.drawUTF8(0, 16, l1); + if (l2) g_oled.drawUTF8(0, 32, l2); + if (l3) g_oled.drawUTF8(0, 48, l3); + g_oled.sendBuffer(); +} diff --git a/exercises/09_GPS_Time/lib/system_startup/SystemStartup.h b/exercises/09_GPS_Time/lib/system_startup/SystemStartup.h new file mode 100644 index 0000000..cd83857 --- /dev/null +++ b/exercises/09_GPS_Time/lib/system_startup/SystemStartup.h @@ -0,0 +1,46 @@ +#pragma once + +#include +#include "StartupSdManager.h" + +// Convenience alias so sketches can use System.println(...) style logging. +// Arduino exposes Serial, not System, so map System -> Serial. +#ifndef System +#define System Serial +#endif + +enum class SystemEvent : uint8_t { + BOOTING = 0, + SD_MISSING, + SD_READY, + SD_REMOVED +}; + +using SystemEventCallback = void (*)(SystemEvent event, const char* message); + +struct SystemStartupConfig { + uint32_t serialDelayMs = 5000; + SdWatcherConfig sd{}; +}; + +class SystemStartup { + public: + explicit SystemStartup(Print& serial = Serial); + + bool begin(const SystemStartupConfig& cfg = SystemStartupConfig{}, SystemEventCallback callback = nullptr); + void update(); + + bool isSdMounted() const { return sd_.isMounted(); } + StartupSdManager& sdManager() { return sd_; } + + private: + static void onSdEventThunk(SdEvent event, const char* message); + void onSdEvent(SdEvent event, const char* message); + void emit(SystemEvent event, const char* message); + void oledShow3(const char* l1, const char* l2 = nullptr, const char* l3 = nullptr); + + Print& serial_; + SystemStartupConfig cfg_{}; + SystemEventCallback callback_ = nullptr; + StartupSdManager sd_; +}; diff --git a/exercises/09_GPS_Time/lib/system_startup/library.json b/exercises/09_GPS_Time/lib/system_startup/library.json new file mode 100644 index 0000000..0c2c1cf --- /dev/null +++ b/exercises/09_GPS_Time/lib/system_startup/library.json @@ -0,0 +1,15 @@ +{ + "name": "system_startup", + "version": "0.1.0", + "dependencies": [ + { + "name": "startup_sd" + }, + { + "name": "U8g2" + }, + { + "name": "Wire" + } + ] +} diff --git a/exercises/09_GPS_Time/platformio.ini b/exercises/09_GPS_Time/platformio.ini new file mode 100644 index 0000000..96344b6 --- /dev/null +++ b/exercises/09_GPS_Time/platformio.ini @@ -0,0 +1,48 @@ +; 20260213 ChatGPT +; $Id$ +; $HeadURL$ + +[platformio] +default_envs = node_a + +[env] +platform = espressif32 +framework = arduino +board = esp32-s3-devkitc-1 +monitor_speed = 115200 +lib_deps = + lewisxhe/XPowersLib@0.3.3 + Wire + olikraus/U8g2@^2.36.4 + +; SD pins based on T-Beam S3 core pin mapping +build_flags = + -I ../../shared/boards + -I ../../external/microReticulum_Firmware + -D BOARD_MODEL=BOARD_TBEAM_S_V1 + -D OLED_SDA=17 + -D OLED_SCL=18 + -D OLED_ADDR=0x3C + -D GPS_RX_PIN=9 + -D GPS_TX_PIN=8 + -D GPS_WAKEUP_PIN=7 + -D GPS_1PPS_PIN=6 + -D ARDUINO_USB_MODE=1 + -D ARDUINO_USB_CDC_ON_BOOT=1 + -D GPS_L76K + +[env:node_a] +build_flags = + ${env.build_flags} + -D NODE_LABEL=\"A\" + +[env:node_b] +build_flags = + ${env.build_flags} + -D NODE_LABEL=\"B\" + +[env:node_c] +build_flags = + ${env.build_flags} + -D NODE_LABEL=\"C\" + -D GPS_UBLOX \ No newline at end of file diff --git a/exercises/09_GPS_Time/src/main.cpp b/exercises/09_GPS_Time/src/main.cpp new file mode 100644 index 0000000..c8fd2a7 --- /dev/null +++ b/exercises/09_GPS_Time/src/main.cpp @@ -0,0 +1,1263 @@ +// 20260217 ChatGPT +// $Id$ +// $HeadURL$ + +#include +#include +#include +#include + +#include "StartupSdManager.h" +#include "tbeam_supreme_adapter.h" + +#ifndef OLED_SDA +#define OLED_SDA 17 +#endif + +#ifndef OLED_SCL +#define OLED_SCL 18 +#endif + +#ifndef OLED_ADDR +#define OLED_ADDR 0x3C +#endif + +#ifndef RTC_I2C_ADDR +#define RTC_I2C_ADDR 0x51 +#endif + +#ifndef GPS_BAUD +#define GPS_BAUD 9600 +#endif + +static const uint32_t kSerialDelayMs = 5000; +static const uint32_t kMinuteMs = 60000; +static const uint32_t kGpsDiagnosticLogMs = 15000; +static const char* kGpsLogDir = "/gpsdiag"; +static const char* kGpsLogPath = "/gpsdiag/current.log"; +static const char* kBuildDate = __DATE__; +static const char* kBuildTime = __TIME__; + +static XPowersLibInterface* g_pmu = nullptr; +static StartupSdManager g_sd(Serial); +static U8G2_SH1106_128X64_NONAME_F_HW_I2C g_oled(U8G2_R0, /* reset=*/U8X8_PIN_NONE); +static HardwareSerial g_gpsSerial(1); + +static uint32_t g_logSeq = 0; +static uint32_t g_lastMinuteReportMs = 0; +static uint32_t g_lastGpsDiagnosticLogMs = 0; +static uint32_t g_gpsBaud = GPS_BAUD; +static int g_gpsRxPin = GPS_RX_PIN; +static int g_gpsTxPin = GPS_TX_PIN; +static bool g_spiffsReady = false; +static bool g_ubloxConfigAttempted = false; +static bool g_ubloxConfigured = false; +static bool g_ubloxIsM10 = false; + +static bool g_prevHadSatellites = false; +static bool g_prevHadValidUtc = false; +static bool g_satellitesAcquiredAnnounced = false; +static bool g_timeAcquiredAnnounced = false; +static uint8_t g_lastDrawnSatsUsed = 255; +static uint8_t g_lastDrawnSatsView = 255; +static bool g_lastDrawnValidUtc = false; +static bool g_haveLastDrawnState = false; +static uint32_t g_lastDisplayRefreshMs = 0; + +static char g_gpsLine[128]; +static size_t g_gpsLineLen = 0; +static char g_serialLine[128]; +static size_t g_serialLineLen = 0; +static uint8_t g_rawLogGgaCount = 0; +static uint8_t g_rawLogGsaCount = 0; +static uint8_t g_rawLogGsvCount = 0; +static uint8_t g_rawLogRmcCount = 0; +static uint8_t g_rawLogPubxCount = 0; + +enum class GpsModuleKind : uint8_t { + UNKNOWN = 0, + L76K, + UBLOX +}; + +#if defined(GPS_UBLOX) +static const GpsModuleKind kExpectedGpsModule = GpsModuleKind::UBLOX; +#elif defined(GPS_L76K) +static const GpsModuleKind kExpectedGpsModule = GpsModuleKind::L76K; +#else +static const GpsModuleKind kExpectedGpsModule = GpsModuleKind::UNKNOWN; +#endif + +struct RtcDateTime { + uint16_t year; + uint8_t month; + uint8_t day; + uint8_t hour; + uint8_t minute; + uint8_t second; +}; + +struct GpsState { + GpsModuleKind module = kExpectedGpsModule; + bool sawAnySentence = false; + + uint8_t satsUsed = 0; + uint8_t satsInView = 0; + uint8_t satsUsedWindowMax = 0; + uint8_t satsInViewWindowMax = 0; + uint32_t satsUsedWindowMs = 0; + uint32_t satsInViewWindowMs = 0; + + bool hasValidUtc = false; + uint32_t utcFixMs = 0; + uint16_t utcYear = 0; + uint8_t utcMonth = 0; + uint8_t utcDay = 0; + uint8_t utcHour = 0; + uint8_t utcMinute = 0; + uint8_t utcSecond = 0; +}; + +static GpsState g_gps; +static const uint32_t kSatelliteWindowMs = 2000; +static const uint32_t kDisplayRefreshMinMs = 1000; +static const uint32_t kFixFreshMs = 5000; + +static String gpsModuleToString(GpsModuleKind kind); +static GpsModuleKind activeGpsModule(); +static uint8_t bestSatelliteCount(); +static uint8_t displayedSatsUsed(); +static uint8_t displayedSatsInView(); +static bool displayHasFreshUtc(); +static String formatRtcNow(); + +static bool ensureGpsLogDirectory() { + if (!g_spiffsReady) { + return false; + } + if (SPIFFS.exists(kGpsLogDir)) { + return true; + } + return SPIFFS.mkdir(kGpsLogDir); +} + +static bool gpsDiagAppendLine(const char* line) { + if (!g_spiffsReady || !line) { + return false; + } + File file = SPIFFS.open(kGpsLogPath, FILE_APPEND); + if (!file) { + return false; + } + file.print(line); + file.print("\r\n"); + file.close(); + return true; +} + +static void formatGpsSnapshot(char* out, size_t outSize, const char* event) { + if (!out || outSize == 0) { + return; + } + + const uint8_t sats = bestSatelliteCount(); + const char* ev = event ? event : "sample"; + if (g_gps.hasValidUtc) { + snprintf(out, + outSize, + "ms=%lu event=%s module=%s nmea=%s sats_used=%u sats_view=%u sats_best=%u utc=%04u-%02u-%02uT%02u:%02u:%02u rx=%d tx=%d baud=%lu", + (unsigned long)millis(), + ev, + gpsModuleToString(activeGpsModule()).c_str(), + g_gps.sawAnySentence ? "yes" : "no", + (unsigned)g_gps.satsUsed, + (unsigned)g_gps.satsInView, + (unsigned)sats, + (unsigned)g_gps.utcYear, + (unsigned)g_gps.utcMonth, + (unsigned)g_gps.utcDay, + (unsigned)g_gps.utcHour, + (unsigned)g_gps.utcMinute, + (unsigned)g_gps.utcSecond, + g_gpsRxPin, + g_gpsTxPin, + (unsigned long)g_gpsBaud); + } else { + String rtc = formatRtcNow(); + snprintf(out, + outSize, + "ms=%lu event=%s module=%s nmea=%s sats_used=%u sats_view=%u sats_best=%u utc=NO rtc=\"%s\" rx=%d tx=%d baud=%lu", + (unsigned long)millis(), + ev, + gpsModuleToString(activeGpsModule()).c_str(), + g_gps.sawAnySentence ? "yes" : "no", + (unsigned)g_gps.satsUsed, + (unsigned)g_gps.satsInView, + (unsigned)sats, + rtc.c_str(), + g_gpsRxPin, + g_gpsTxPin, + (unsigned long)g_gpsBaud); + } +} + +static void appendGpsSnapshot(const char* event) { + char line[256]; + formatGpsSnapshot(line, sizeof(line), event); + (void)gpsDiagAppendLine(line); +} + +static String buildStampShort() { + char buf[32]; + snprintf(buf, sizeof(buf), "%s %.5s", kBuildDate, kBuildTime); + return String(buf); +} + +static void maybeLogRawSentence(const char* type, const char* sentence) { + if (!type || !sentence || !g_spiffsReady) { + return; + } + + uint8_t* counter = nullptr; + if (strcmp(type, "GGA") == 0) { + counter = &g_rawLogGgaCount; + } else if (strcmp(type, "GSA") == 0) { + counter = &g_rawLogGsaCount; + } else if (strcmp(type, "GSV") == 0) { + counter = &g_rawLogGsvCount; + } else if (strcmp(type, "RMC") == 0) { + counter = &g_rawLogRmcCount; + } else if (strcmp(type, "PUBX") == 0) { + counter = &g_rawLogPubxCount; + } + + if (!counter || *counter >= 12) { + return; + } + (*counter)++; + + char line[220]; + snprintf(line, + sizeof(line), + "ms=%lu event=raw_%s idx=%u sentence=%s", + (unsigned long)millis(), + type, + (unsigned)*counter, + sentence); + (void)gpsDiagAppendLine(line); +} + +static void clearGpsSerialInput() { + g_gpsLineLen = 0; + while (g_gpsSerial.available() > 0) { + (void)g_gpsSerial.read(); + } +} + +static void ubxChecksum(uint8_t* message, size_t length) { + uint8_t ckA = 0; + uint8_t ckB = 0; + for (size_t i = 2; i < length - 2; ++i) { + ckA = (uint8_t)((ckA + message[i]) & 0xFF); + ckB = (uint8_t)((ckB + ckA) & 0xFF); + } + message[length - 2] = ckA; + message[length - 1] = ckB; +} + +static size_t makeUbxPacket(uint8_t* out, + size_t outSize, + uint8_t classId, + uint8_t msgId, + const uint8_t* payload, + uint16_t payloadSize) { + if (!out || outSize < (size_t)payloadSize + 8U) { + return 0; + } + out[0] = 0xB5; + out[1] = 0x62; + out[2] = classId; + out[3] = msgId; + out[4] = (uint8_t)(payloadSize & 0xFF); + out[5] = (uint8_t)((payloadSize >> 8) & 0xFF); + for (uint16_t i = 0; i < payloadSize; ++i) { + out[6 + i] = payload ? payload[i] : 0; + } + out[6 + payloadSize] = 0; + out[7 + payloadSize] = 0; + ubxChecksum(out, payloadSize + 8U); + return (size_t)payloadSize + 8U; +} + +static bool waitForUbxAck(uint8_t classId, uint8_t msgId, uint32_t waitMs) { + uint8_t ack[10] = {0xB5, 0x62, 0x05, 0x01, 0x02, 0x00, classId, msgId, 0x00, 0x00}; + ubxChecksum(ack, sizeof(ack)); + uint8_t ackPos = 0; + uint32_t deadline = millis() + waitMs; + + while ((int32_t)(deadline - millis()) > 0) { + if (g_gpsSerial.available() <= 0) { + delay(2); + continue; + } + uint8_t b = (uint8_t)g_gpsSerial.read(); + if (b == ack[ackPos]) { + ackPos++; + if (ackPos == sizeof(ack)) { + return true; + } + } else { + ackPos = (b == ack[0]) ? 1 : 0; + } + } + return false; +} + +static int waitForUbxPayload(uint8_t* buffer, + uint16_t bufferSize, + uint8_t classId, + uint8_t msgId, + uint32_t waitMs) { + uint16_t framePos = 0; + uint16_t needRead = 0; + uint32_t deadline = millis() + waitMs; + + while ((int32_t)(deadline - millis()) > 0) { + if (g_gpsSerial.available() <= 0) { + delay(2); + continue; + } + + int c = g_gpsSerial.read(); + switch (framePos) { + case 0: + framePos = (c == 0xB5) ? 1 : 0; + break; + case 1: + framePos = (c == 0x62) ? 2 : 0; + break; + case 2: + framePos = (c == classId) ? 3 : 0; + break; + case 3: + framePos = (c == msgId) ? 4 : 0; + break; + case 4: + needRead = (uint16_t)c; + framePos = 5; + break; + case 5: + needRead |= (uint16_t)(c << 8); + if (needRead == 0 || needRead >= bufferSize) { + framePos = 0; + break; + } + if (g_gpsSerial.readBytes(buffer, needRead) != needRead) { + framePos = 0; + break; + } + if (g_gpsSerial.available() >= 2) { + (void)g_gpsSerial.read(); + (void)g_gpsSerial.read(); + } + return (int)needRead; + default: + framePos = 0; + break; + } + } + + return 0; +} + +static bool detectUbloxM10() { + uint8_t packet[8]; + uint8_t payload[256] = {0}; + size_t len = makeUbxPacket(packet, sizeof(packet), 0x0A, 0x04, nullptr, 0); + if (len == 0) { + return false; + } + + clearGpsSerialInput(); + g_gpsSerial.write(packet, len); + int payloadLen = waitForUbxPayload(payload, sizeof(payload), 0x0A, 0x04, 1200); + if (payloadLen < 40) { + appendGpsSnapshot("ubx_monver_timeout"); + return false; + } + + char hwVersion[11] = {0}; + memcpy(hwVersion, payload + 30, 10); + char line[160]; + snprintf(line, sizeof(line), "ms=%lu event=ubx_monver hw=%s", (unsigned long)millis(), hwVersion); + (void)gpsDiagAppendLine(line); + + if (strncmp(hwVersion, "000A0000", 8) == 0) { + return true; + } + + for (int pos = 40; pos + 30 <= payloadLen; pos += 30) { + if (strncmp((const char*)(payload + pos), "PROTVER=", 8) == 0) { + int prot = atoi((const char*)(payload + pos + 8)); + snprintf(line, sizeof(line), "ms=%lu event=ubx_monver prot=%d", (unsigned long)millis(), prot); + (void)gpsDiagAppendLine(line); + if (prot >= 27) { + return true; + } + } + } + + return false; +} + +static bool sendUbxValset(uint8_t classId, + uint8_t msgId, + const uint8_t* payload, + uint16_t payloadLen, + uint32_t ackMs, + const char* eventName) { + uint8_t packet[96]; + size_t len = makeUbxPacket(packet, sizeof(packet), classId, msgId, payload, payloadLen); + if (len == 0) { + return false; + } + clearGpsSerialInput(); + g_gpsSerial.write(packet, len); + bool ok = waitForUbxAck(classId, msgId, ackMs); + char line[160]; + snprintf(line, + sizeof(line), + "ms=%lu event=%s ack=%s", + (unsigned long)millis(), + eventName ? eventName : "ubx_cfg", + ok ? "yes" : "no"); + (void)gpsDiagAppendLine(line); + delay(150); + return ok; +} + +static bool configureUbloxM10() { + static const uint8_t kValsetDisableTxtRam[] = {0x00, 0x01, 0x00, 0x00, 0x07, 0x00, 0x92, 0x20, 0x03}; + static const uint8_t kValsetDisableTxtBbr[] = {0x00, 0x02, 0x00, 0x00, 0x07, 0x00, 0x92, 0x20, 0x03}; + static const uint8_t kValsetEnableNmeaRam[] = {0x00, 0x01, 0x00, 0x00, 0xbb, 0x00, 0x91, 0x20, 0x01, 0xac, 0x00, 0x91, 0x20, 0x01}; + static const uint8_t kValsetEnableNmeaBbr[] = {0x00, 0x02, 0x00, 0x00, 0xbb, 0x00, 0x91, 0x20, 0x01, 0xac, 0x00, 0x91, 0x20, 0x01}; + static const uint8_t kSave10[] = {0x00, 0x00, 0x00, 0x00, 0xFF, 0xFF, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01}; + + bool ok = true; + ok &= sendUbxValset(0x06, 0x8A, kValsetDisableTxtRam, sizeof(kValsetDisableTxtRam), 300, "ubx_m10_disable_txt_ram"); + ok &= sendUbxValset(0x06, 0x8A, kValsetDisableTxtBbr, sizeof(kValsetDisableTxtBbr), 300, "ubx_m10_disable_txt_bbr"); + ok &= sendUbxValset(0x06, 0x8A, kValsetEnableNmeaBbr, sizeof(kValsetEnableNmeaBbr), 400, "ubx_m10_enable_nmea_bbr"); + ok &= sendUbxValset(0x06, 0x8A, kValsetEnableNmeaRam, sizeof(kValsetEnableNmeaRam), 400, "ubx_m10_enable_nmea_ram"); + ok &= sendUbxValset(0x06, 0x09, kSave10, sizeof(kSave10), 800, "ubx_m10_save"); + appendGpsSnapshot(ok ? "ubx_m10_configured" : "ubx_m10_config_failed"); + return ok; +} + +static void maybeConfigureUblox() { + if (g_ubloxConfigAttempted || kExpectedGpsModule != GpsModuleKind::UBLOX) { + return; + } + g_ubloxConfigAttempted = true; + appendGpsSnapshot("ubx_config_attempt"); + + g_ubloxIsM10 = detectUbloxM10(); + if (!g_ubloxIsM10) { + appendGpsSnapshot("ubx_non_m10_or_unknown"); + return; + } + + g_ubloxConfigured = configureUbloxM10(); +} + +static void logf(const char* fmt, ...) { + char msg[220]; + va_list args; + va_start(args, fmt); + vsnprintf(msg, sizeof(msg), fmt, args); + va_end(args); + Serial.printf("[%10lu][%06lu] %s\r\n", (unsigned long)millis(), (unsigned long)g_logSeq++, msg); +} + +static void oledShowLines(const char* l1, + const char* l2 = nullptr, + const char* l3 = nullptr, + const char* l4 = nullptr, + const char* l5 = nullptr) { + g_oled.clearBuffer(); + g_oled.setFont(u8g2_font_5x8_tf); + if (l1) g_oled.drawUTF8(0, 12, l1); + if (l2) g_oled.drawUTF8(0, 24, l2); + if (l3) g_oled.drawUTF8(0, 36, l3); + if (l4) g_oled.drawUTF8(0, 48, l4); + if (l5) g_oled.drawUTF8(0, 60, l5); + g_oled.sendBuffer(); +} + +static uint8_t fromBcd(uint8_t b) { + return ((b >> 4U) * 10U) + (b & 0x0FU); +} + +static bool rtcRead(RtcDateTime& out, bool& lowVoltageFlag) { + Wire1.beginTransmission(RTC_I2C_ADDR); + Wire1.write(0x02); + if (Wire1.endTransmission(false) != 0) { + return false; + } + + const uint8_t need = 7; + uint8_t got = Wire1.requestFrom((int)RTC_I2C_ADDR, (int)need); + if (got != need) { + return false; + } + + uint8_t sec = Wire1.read(); + uint8_t min = Wire1.read(); + uint8_t hour = Wire1.read(); + uint8_t day = Wire1.read(); + (void)Wire1.read(); // weekday + uint8_t month = Wire1.read(); + uint8_t year = Wire1.read(); + + lowVoltageFlag = (sec & 0x80U) != 0; + out.second = fromBcd(sec & 0x7FU); + out.minute = fromBcd(min & 0x7FU); + out.hour = fromBcd(hour & 0x3FU); + out.day = fromBcd(day & 0x3FU); + out.month = fromBcd(month & 0x1FU); + uint8_t yy = fromBcd(year); + bool century = (month & 0x80U) != 0; + out.year = century ? (1900U + yy) : (2000U + yy); + return true; +} + +static String formatRtcNow() { + RtcDateTime now{}; + bool lowV = false; + if (!rtcRead(now, lowV)) { + return "RTC: read failed"; + } + + char buf[48]; + snprintf(buf, + sizeof(buf), + "RTC %04u-%02u-%02u %02u:%02u:%02u%s", + (unsigned)now.year, + (unsigned)now.month, + (unsigned)now.day, + (unsigned)now.hour, + (unsigned)now.minute, + (unsigned)now.second, + lowV ? " !LOWV" : ""); + return String(buf); +} + +static String gpsModuleToString(GpsModuleKind kind) { + if (kind == GpsModuleKind::L76K) { + return "L76K"; + } + if (kind == GpsModuleKind::UBLOX) { + return "UBLOX"; + } + return "Unknown"; +} + +static bool parseUInt2(const char* s, uint8_t& out) { + if (!s || !isdigit((unsigned char)s[0]) || !isdigit((unsigned char)s[1])) { + return false; + } + out = (uint8_t)((s[0] - '0') * 10 + (s[1] - '0')); + return true; +} + +static void detectModuleFromText(const char* text) { + if (!text || text[0] == '\0') { + return; + } + + String t(text); + t.toUpperCase(); + + if (t.indexOf("L76K") >= 0) { + g_gps.module = GpsModuleKind::L76K; + return; + } + + if (t.indexOf("QUECTEL") >= 0 || t.indexOf("2021-10-20") >= 0 || t.indexOf("2021/10/20") >= 0) { + g_gps.module = GpsModuleKind::L76K; + } +} + +static void parseGga(char* fields[], int count) { + if (count <= 7) { + return; + } + int sats = atoi(fields[7]); + if (sats >= 0 && sats <= 255) { + g_gps.satsUsed = (uint8_t)sats; + if ((uint8_t)sats > g_gps.satsUsedWindowMax) { + g_gps.satsUsedWindowMax = (uint8_t)sats; + } + g_gps.satsUsedWindowMs = millis(); + } +} + +static void parseGsv(char* fields[], int count) { + if (count <= 3) { + return; + } + int sats = atoi(fields[3]); + if (sats >= 0 && sats <= 255) { + g_gps.satsInView = (uint8_t)sats; + if ((uint8_t)sats > g_gps.satsInViewWindowMax) { + g_gps.satsInViewWindowMax = (uint8_t)sats; + } + g_gps.satsInViewWindowMs = millis(); + } +} + +static void parseGsa(char* fields[], int count) { + if (count <= 3) { + return; + } +} + +static void parseRmc(char* fields[], int count) { + if (count <= 9) { + return; + } + + const char* utc = fields[1]; + const char* status = fields[2]; + const char* date = fields[9]; + + if (!status || status[0] != 'A') { + return; + } + + if (!utc || strlen(utc) < 6 || !date || strlen(date) < 6) { + return; + } + + uint8_t hh = 0, mm = 0, ss = 0; + uint8_t dd = 0, mo = 0, yy = 0; + if (!parseUInt2(utc + 0, hh) || !parseUInt2(utc + 2, mm) || !parseUInt2(utc + 4, ss)) { + return; + } + if (!parseUInt2(date + 0, dd) || !parseUInt2(date + 2, mo) || !parseUInt2(date + 4, yy)) { + return; + } + + g_gps.utcHour = hh; + g_gps.utcMinute = mm; + g_gps.utcSecond = ss; + g_gps.utcDay = dd; + g_gps.utcMonth = mo; + g_gps.utcYear = (uint16_t)(2000U + yy); + g_gps.hasValidUtc = true; + g_gps.utcFixMs = millis(); +} + +static void parseTxt(char* fields[], int count) { + if (count <= 4) { + return; + } + detectModuleFromText(fields[4]); +} + +static int splitCsvPreserveEmpty(char* line, char* fields[], int maxFields) { + if (!line || !fields || maxFields <= 0) { + return 0; + } + + int count = 0; + char* p = line; + fields[count++] = p; + + while (*p && count < maxFields) { + if (*p == ',') { + *p = '\0'; + fields[count++] = p + 1; + } + ++p; + } + + return count; +} + +static void processNmeaLine(char* line) { + if (!line || line[0] != '$') { + return; + } + g_gps.sawAnySentence = true; + char rawLine[128]; + strncpy(rawLine, line, sizeof(rawLine) - 1); + rawLine[sizeof(rawLine) - 1] = '\0'; + + char* star = strchr(line, '*'); + if (star) { + *star = '\0'; + } + + char* fields[24] = {0}; + int count = splitCsvPreserveEmpty(line, fields, 24); + if (count <= 0 || !fields[0]) { + return; + } + + const char* header = fields[0]; + if (strcmp(header, "$PUBX") == 0) { + g_gps.module = GpsModuleKind::UBLOX; + maybeLogRawSentence("PUBX", rawLine); + return; + } + size_t n = strlen(header); + if (n < 6) { + return; + } + const char* type = header + (n - 3); + maybeLogRawSentence(type, rawLine); + + if (strcmp(type, "GGA") == 0) { + parseGga(fields, count); + } else if (strcmp(type, "GSA") == 0) { + parseGsa(fields, count); + } else if (strcmp(type, "GSV") == 0) { + parseGsv(fields, count); + } else if (strcmp(type, "RMC") == 0) { + parseRmc(fields, count); + } else if (strcmp(type, "TXT") == 0) { + parseTxt(fields, count); + } +} + +static void pollGpsSerial() { + while (g_gpsSerial.available() > 0) { + char c = (char)g_gpsSerial.read(); + if (c == '\r') { + continue; + } + if (c == '\n') { + if (g_gpsLineLen > 0) { + g_gpsLine[g_gpsLineLen] = '\0'; + processNmeaLine(g_gpsLine); + g_gpsLineLen = 0; + } + continue; + } + + if (g_gpsLineLen + 1 < sizeof(g_gpsLine)) { + g_gpsLine[g_gpsLineLen++] = c; + } else { + g_gpsLineLen = 0; + } + } +} + +static void showGpsLogHelp() { + Serial.println("Command list:"); + Serial.println(" help - show command menu"); + Serial.println(" stat - show current GPS log file info"); + Serial.println(" list - list files in /gpsdiag"); + Serial.println(" read - dump current GPS log"); + Serial.println(" clear - erase current GPS log"); +} + +static void gpsLogStat() { + Serial.printf("SPIFFS: %s\r\n", g_spiffsReady ? "ready" : "not ready"); + Serial.printf("Path: %s\r\n", kGpsLogPath); + if (!g_spiffsReady || !SPIFFS.exists(kGpsLogPath)) { + Serial.println("Current GPS log does not exist"); + return; + } + File file = SPIFFS.open(kGpsLogPath, FILE_READ); + if (!file) { + Serial.println("Unable to open current GPS log"); + return; + } + Serial.printf("Size: %u bytes\r\n", (unsigned)file.size()); + file.close(); + Serial.printf("Flash total=%u used=%u free=%u\r\n", + (unsigned)SPIFFS.totalBytes(), + (unsigned)SPIFFS.usedBytes(), + (unsigned)(SPIFFS.totalBytes() - SPIFFS.usedBytes())); +} + +static void gpsLogList() { + if (!g_spiffsReady) { + Serial.println("SPIFFS not ready"); + return; + } + File dir = SPIFFS.open(kGpsLogDir); + if (!dir || !dir.isDirectory()) { + Serial.printf("Unable to open %s\r\n", kGpsLogDir); + return; + } + Serial.printf("Files in %s:\r\n", kGpsLogDir); + File file = dir.openNextFile(); + while (file) { + Serial.printf(" %s (%u bytes)\r\n", file.name(), (unsigned)file.size()); + file = dir.openNextFile(); + } +} + +static void gpsLogRead() { + if (!g_spiffsReady || !SPIFFS.exists(kGpsLogPath)) { + Serial.println("Current GPS log is not available"); + return; + } + File file = SPIFFS.open(kGpsLogPath, FILE_READ); + if (!file) { + Serial.println("Unable to read current GPS log"); + return; + } + Serial.printf("Reading %s:\r\n", kGpsLogPath); + while (file.available()) { + Serial.write(file.read()); + } + if (file.size() > 0) { + Serial.println(); + } + file.close(); +} + +static void gpsLogClear() { + if (!g_spiffsReady) { + Serial.println("SPIFFS not ready"); + return; + } + File file = SPIFFS.open(kGpsLogPath, FILE_WRITE); + if (!file) { + Serial.println("Unable to clear current GPS log"); + return; + } + file.close(); + Serial.printf("Cleared %s\r\n", kGpsLogPath); +} +// Process a command received on the serial console. +static void processSerialCommand(const char* line) { + if (!line || line[0] == '\0') { + return; + } + // Echo the command back to the console for clarity and posterity. + Serial.printf("-->%s\r\n", line); + if (strcasecmp(line, "help") == 0) { + showGpsLogHelp(); + } else if (strcasecmp(line, "stat") == 0) { + gpsLogStat(); + } else if (strcasecmp(line, "list") == 0) { + gpsLogList(); + } else if (strcasecmp(line, "read") == 0) { + gpsLogRead(); + } else if (strcasecmp(line, "clear") == 0) { + gpsLogClear(); + } else { + Serial.println("Unknown command (help for list)"); + } +} + +static void pollSerialConsole() { + while (Serial.available() > 0) { + int c = Serial.read(); + if (c < 0) { + continue; + } + if (c == '\r' || c == '\n') { + if (g_serialLineLen > 0) { + g_serialLine[g_serialLineLen] = '\0'; + processSerialCommand(g_serialLine); + g_serialLineLen = 0; + } + continue; + } + if (g_serialLineLen + 1 < sizeof(g_serialLine)) { + g_serialLine[g_serialLineLen++] = (char)c; + } else { + g_serialLineLen = 0; + } + } +} + +static void startGpsUart(uint32_t baud, int rxPin, int txPin) { + g_gpsSerial.end(); + delay(20); + g_gpsSerial.setRxBufferSize(1024); + g_gpsSerial.begin(baud, SERIAL_8N1, rxPin, txPin); + g_gpsBaud = baud; + g_gpsRxPin = rxPin; + g_gpsTxPin = txPin; +} + +static bool collectGpsTraffic(uint32_t windowMs, bool updateSd) { + uint32_t start = millis(); + bool sawBytes = false; + while ((uint32_t)(millis() - start) < windowMs) { + if (g_gpsSerial.available() > 0) { + sawBytes = true; + } + pollGpsSerial(); + if (updateSd) { + g_sd.update(); + } + delay(2); + } + return sawBytes || g_gps.sawAnySentence; +} + +static bool probeGpsAtBaud(uint32_t baud, int rxPin, int txPin) { + startGpsUart(baud, rxPin, txPin); + logf("Probing GPS at %lu baud on RX=%d TX=%d...", (unsigned long)baud, rxPin, txPin); + if (collectGpsTraffic(700, true)) { + return true; + } + + // Common commands for MTK/L76K and related chipsets. + g_gpsSerial.write("$PCAS06,0*1B\r\n"); + g_gpsSerial.write("$PMTK605*31\r\n"); + g_gpsSerial.write("$PQTMVERNO*58\r\n"); + g_gpsSerial.write("$PUBX,00*33\r\n"); + g_gpsSerial.write("$PMTK353,1,1,1,1,1*2A\r\n"); + g_gpsSerial.write("$PMTK314,0,1,0,1,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0*29\r\n"); + + return collectGpsTraffic(1200, true); +} + +static void initialGpsProbe() { + const uint32_t bauds[] = {GPS_BAUD, 115200, 38400, 57600, 19200}; + int pinCandidates[2][2] = { + {GPS_RX_PIN, GPS_TX_PIN}, + {34, 12}, // Legacy T-Beam UBLOX mapping. + }; + size_t pinCount = 1; + if (kExpectedGpsModule == GpsModuleKind::UBLOX && + !(GPS_RX_PIN == 34 && GPS_TX_PIN == 12)) { + pinCount = 2; + } + + for (size_t p = 0; p < pinCount; ++p) { + int rxPin = pinCandidates[p][0]; + int txPin = pinCandidates[p][1]; + for (size_t i = 0; i < sizeof(bauds) / sizeof(bauds[0]); ++i) { + if (probeGpsAtBaud(bauds[i], rxPin, txPin)) { + logf("GPS traffic detected at %lu baud on RX=%d TX=%d", + (unsigned long)g_gpsBaud, g_gpsRxPin, g_gpsTxPin); + return; + } + } + } + logf("No GPS traffic detected during startup probe"); +} + +static uint32_t startupProbeWindowMs() { + return (kExpectedGpsModule == GpsModuleKind::UBLOX) ? 45000U : 20000U; +} + +static GpsModuleKind activeGpsModule() { + if (g_gps.module != GpsModuleKind::UNKNOWN) { + return g_gps.module; + } + return kExpectedGpsModule; +} + +static uint8_t bestSatelliteCount() { + uint32_t now = millis(); + + if ((uint32_t)(now - g_gps.satsUsedWindowMs) > kSatelliteWindowMs) { + g_gps.satsUsedWindowMax = g_gps.satsUsed; + } + if ((uint32_t)(now - g_gps.satsInViewWindowMs) > kSatelliteWindowMs) { + g_gps.satsInViewWindowMax = g_gps.satsInView; + } + + uint8_t used = (g_gps.satsUsedWindowMax > g_gps.satsUsed) ? g_gps.satsUsedWindowMax : g_gps.satsUsed; + uint8_t inView = (g_gps.satsInViewWindowMax > g_gps.satsInView) ? g_gps.satsInViewWindowMax : g_gps.satsInView; + return (used > inView) ? used : inView; +} + +static uint8_t displayedSatsUsed() { + if ((uint32_t)(millis() - g_gps.satsUsedWindowMs) > kFixFreshMs) { + return 0; + } + return g_gps.satsUsed; +} + +static uint8_t displayedSatsInView() { + if ((uint32_t)(millis() - g_gps.satsInViewWindowMs) > kFixFreshMs) { + return 0; + } + return g_gps.satsInView; +} + +static bool displayHasFreshUtc() { + return g_gps.hasValidUtc && (uint32_t)(millis() - g_gps.utcFixMs) <= kFixFreshMs; +} + +static bool isUnsupportedGpsMode() { + GpsModuleKind active = activeGpsModule(); + if (kExpectedGpsModule == GpsModuleKind::UNKNOWN || active == GpsModuleKind::UNKNOWN) { + return false; + } + return active != kExpectedGpsModule; +} + +static void reportStatusToSerial() { + uint8_t satsUsed = displayedSatsUsed(); + uint8_t satsView = displayedSatsInView(); + logf("GPS module active: %s", gpsModuleToString(activeGpsModule()).c_str()); + logf("GPS module expected: %s", gpsModuleToString(kExpectedGpsModule).c_str()); + logf("GPS sentences seen: %s", g_gps.sawAnySentence ? "yes" : "no"); + logf("GPS satellites: used=%u in-view=%u recent-best=%u", + (unsigned)satsUsed, + (unsigned)satsView, + (unsigned)bestSatelliteCount()); + logf("GPS can provide time from satellites: %s", displayHasFreshUtc() ? "YES" : "NO"); + appendGpsSnapshot("status"); +} + +static void maybeAnnounceGpsTransitions() { + if (isUnsupportedGpsMode()) { + return; + } + + uint8_t satsUsed = displayedSatsUsed(); + uint8_t satsView = displayedSatsInView(); + uint8_t sats = satsUsed > 0 ? satsUsed : satsView; + bool hasSats = sats > 0; + bool hasUtc = displayHasFreshUtc(); + + if (!g_satellitesAcquiredAnnounced && !g_prevHadSatellites && hasSats) { + String rtc = formatRtcNow(); + char l2[28]; + char l3[28]; + snprintf(l2, sizeof(l2), "Used: %u", (unsigned)satsUsed); + snprintf(l3, sizeof(l3), "View: %u", (unsigned)satsView); + oledShowLines("GPS acquired", l2, l3, "Waiting for UTC...", rtc.c_str()); + logf("Transition: satellites acquired (%u)", (unsigned)sats); + appendGpsSnapshot("satellites_acquired"); + g_satellitesAcquiredAnnounced = true; + } + + if (!g_timeAcquiredAnnounced && !g_prevHadValidUtc && hasUtc) { + char line2[40]; + char line3[28]; + char line4[28]; + snprintf(line2, + sizeof(line2), + "%04u-%02u-%02u %02u:%02u:%02u", + (unsigned)g_gps.utcYear, + (unsigned)g_gps.utcMonth, + (unsigned)g_gps.utcDay, + (unsigned)g_gps.utcHour, + (unsigned)g_gps.utcMinute, + (unsigned)g_gps.utcSecond); + snprintf(line3, sizeof(line3), "Used:%u View:%u", (unsigned)satsUsed, (unsigned)satsView); + snprintf(line4, sizeof(line4), "Source: %s", gpsModuleToString(activeGpsModule()).c_str()); + oledShowLines("GPS UTC acquired", line2, line3, line4); + logf("Transition: GPS UTC acquired: %s", line2); + appendGpsSnapshot("utc_acquired"); + g_timeAcquiredAnnounced = true; + } + + g_prevHadSatellites = hasSats; + g_prevHadValidUtc = hasUtc; +} + +static void drawMinuteStatus() { + if (isUnsupportedGpsMode()) { + oledShowLines("GPS module mismatch", + ("Expected: " + gpsModuleToString(kExpectedGpsModule)).c_str(), + ("Detected: " + gpsModuleToString(activeGpsModule())).c_str(), + "Check node profile"); + logf("GPS module mismatch: expected=%s detected=%s", + gpsModuleToString(kExpectedGpsModule).c_str(), + gpsModuleToString(activeGpsModule()).c_str()); + return; + } + + uint8_t satsUsed = displayedSatsUsed(); + uint8_t satsView = displayedSatsInView(); + if (displayHasFreshUtc()) { + char line2[40]; + char line3[28]; + char line4[28]; + snprintf(line2, + sizeof(line2), + "%04u-%02u-%02u %02u:%02u:%02u", + (unsigned)g_gps.utcYear, + (unsigned)g_gps.utcMonth, + (unsigned)g_gps.utcDay, + (unsigned)g_gps.utcHour, + (unsigned)g_gps.utcMinute, + (unsigned)g_gps.utcSecond); + snprintf(line3, sizeof(line3), "Used:%u View:%u", (unsigned)satsUsed, (unsigned)satsView); + snprintf(line4, sizeof(line4), "Source: %s", gpsModuleToString(activeGpsModule()).c_str()); + oledShowLines("GPS time (UTC)", line2, line3, line4); + logf("GPS time (UTC): %s used=%u view=%u", line2, (unsigned)satsUsed, (unsigned)satsView); + return; + } + + String rtc = formatRtcNow(); + if (satsUsed > 0 || satsView > 0) { + char line2[28]; + char line3[28]; + snprintf(line2, sizeof(line2), "Used: %u", (unsigned)satsUsed); + snprintf(line3, sizeof(line3), "View: %u", (unsigned)satsView); + oledShowLines("GPS signal detected", line2, line3, "GPS UTC not ready", rtc.c_str()); + logf("Satellites detected (used=%u view=%u) but GPS UTC not ready. %s", + (unsigned)satsUsed, + (unsigned)satsView, + rtc.c_str()); + } else { + oledShowLines("Unable to acquire", "satellites", "Take me outside so I", "can see satellites", rtc.c_str()); + logf("Unable to acquire satellites. %s", rtc.c_str()); + } +} + +static bool shouldRefreshDisplay() { + uint32_t now = millis(); + if (g_lastDisplayRefreshMs != 0 && (uint32_t)(now - g_lastDisplayRefreshMs) < kDisplayRefreshMinMs) { + return false; + } + uint8_t satsUsed = displayedSatsUsed(); + uint8_t satsView = displayedSatsInView(); + bool hasUtc = displayHasFreshUtc(); + if (!g_haveLastDrawnState) { + return true; + } + return satsUsed != g_lastDrawnSatsUsed || + satsView != g_lastDrawnSatsView || + hasUtc != g_lastDrawnValidUtc; +} + +static void markDisplayStateDrawn() { + g_lastDrawnSatsUsed = displayedSatsUsed(); + g_lastDrawnSatsView = displayedSatsInView(); + g_lastDrawnValidUtc = displayHasFreshUtc(); + g_haveLastDrawnState = true; + g_lastDisplayRefreshMs = millis(); +} + +void setup() { + Serial.begin(115200); + delay(kSerialDelayMs); + + Serial.println("\r\n=================================================="); + Serial.println("Exercise 09: GPS Time"); + Serial.println("=================================================="); + Serial.printf("Build: %s %s\r\n", kBuildDate, kBuildTime); + + if (!tbeam_supreme::initPmuForPeripherals(g_pmu, &Serial)) { + logf("PMU init failed"); + } + + // SPI Flash File System ("SPIFFS") is used for logging GPS diagnostics, + // which may be helpful for analyzing GPS behavior in different + //environments and over time. + g_spiffsReady = SPIFFS.begin(true); + if (!g_spiffsReady) { + logf("SPIFFS mount failed"); + } else if (!ensureGpsLogDirectory()) { + logf("GPS log directory create/open failed"); + } else { + File file = SPIFFS.open(kGpsLogPath, FILE_WRITE); + if (file) { + file.println("Exercise 09 GPS diagnostics"); + file.printf("Build: %s %s\r\n", kBuildDate, kBuildTime); + file.close(); + } else { + logf("GPS log file open failed: %s", kGpsLogPath); + } + } + + // Initialize the OLED display and show a boot message with build info. + Wire.begin(OLED_SDA, OLED_SCL); + g_oled.setI2CAddress(OLED_ADDR << 1); + g_oled.begin(); + String buildStamp = buildStampShort(); + oledShowLines("09_GPS_Time", buildStamp.c_str(), "Booting..."); + // The GPS startup probe may take a while, + //especially for a cold start. Log some + SdWatcherConfig sdCfg{}; + if (!g_sd.begin(sdCfg, nullptr)) { + logf("SD startup manager begin() failed"); + } + +#ifdef GPS_WAKEUP_PIN + // Keep wake pin neutral; avoid forcing an unknown standby state. + pinMode(GPS_WAKEUP_PIN, INPUT); +#endif + +#ifdef GPS_1PPS_PIN + pinMode(GPS_1PPS_PIN, INPUT); +#endif + + startGpsUart(GPS_BAUD, GPS_RX_PIN, GPS_TX_PIN); + logf("GPS UART started: RX=%d TX=%d baud=%lu", g_gpsRxPin, g_gpsTxPin, (unsigned long)g_gpsBaud); + appendGpsSnapshot("uart_started"); + initialGpsProbe(); + appendGpsSnapshot("startup_probe_complete"); + maybeConfigureUblox(); + + oledShowLines("GPS startup probe", "Checking satellites", "and GPS time..."); + + uint32_t probeWindowMs = startupProbeWindowMs(); + if (kExpectedGpsModule == GpsModuleKind::UBLOX) { + logf("UBLOX startup window: %lu ms (allowing cold start acquisition)", + (unsigned long)probeWindowMs); + } + + uint32_t probeStart = millis(); + uint32_t lastProbeUiMs = 0; + while ((uint32_t)(millis() - probeStart) < probeWindowMs) { + pollSerialConsole(); + pollGpsSerial(); + g_sd.update(); + + uint32_t now = millis(); + if ((uint32_t)(now - g_lastGpsDiagnosticLogMs) >= kGpsDiagnosticLogMs) { + g_lastGpsDiagnosticLogMs = now; + appendGpsSnapshot("startup_wait"); + } + if ((uint32_t)(now - lastProbeUiMs) >= 1000) { + lastProbeUiMs = now; + char l3[28]; + char l4[30]; + char l5[24]; + snprintf(l3, sizeof(l3), "Used:%u View:%u", (unsigned)displayedSatsUsed(), (unsigned)displayedSatsInView()); + snprintf(l4, sizeof(l4), "Module: %s", gpsModuleToString(activeGpsModule()).c_str()); + snprintf(l5, sizeof(l5), "NMEA:%s %d/%d", g_gps.sawAnySentence ? "yes" : "no", g_gpsRxPin, g_gpsTxPin); + oledShowLines("GPS startup probe", "Checking satellites", l3, l4, l5); + } + delay(10); + } + + reportStatusToSerial(); + g_prevHadSatellites = (displayedSatsUsed() > 0 || displayedSatsInView() > 0); + g_prevHadValidUtc = displayHasFreshUtc(); + drawMinuteStatus(); + markDisplayStateDrawn(); + g_lastMinuteReportMs = millis(); + g_lastGpsDiagnosticLogMs = millis(); +} + +void loop() { + pollSerialConsole(); + pollGpsSerial(); + g_sd.update(); + maybeAnnounceGpsTransitions(); + + uint32_t now = millis(); + if (shouldRefreshDisplay()) { + drawMinuteStatus(); + markDisplayStateDrawn(); + } + if ((uint32_t)(now - g_lastGpsDiagnosticLogMs) >= kGpsDiagnosticLogMs) { + g_lastGpsDiagnosticLogMs = now; + appendGpsSnapshot("periodic"); + } + if ((uint32_t)(now - g_lastMinuteReportMs) >= kMinuteMs) { + g_lastMinuteReportMs = now; + drawMinuteStatus(); + markDisplayStateDrawn(); + appendGpsSnapshot("minute_status"); + } +} diff --git a/exercises/10_Simple_GPS/README.md b/exercises/10_Simple_GPS/README.md new file mode 100644 index 0000000..6fc5c73 --- /dev/null +++ b/exercises/10_Simple_GPS/README.md @@ -0,0 +1,98 @@ +## Exercise 10: Simple GPS (No SD) + +Goal: verify GPS satellite and UTC time acquisition on T-Beam Supreme using OLED-only status updates. + +## Current behavior + +1. Boots PMU, OLED, RTC, and GPS UART. +2. Runs an active startup GPS probe (multi-baud + query commands) to detect GPS serial traffic. +3. Every 30 seconds: + - Shows `Trying to locate satellites` + `NMEA seen: yes/no` + current RTC time. + - Continues parsing GPS NMEA data. + - If GPS UTC is valid, shows GPS UTC + satellite count + `NMEA seen: yes/no`. + - Otherwise shows `Take me outside` + `NMEA seen: yes/no` + RTC. +4. No SD card logic is used in this exercise. + +## Walk-through: original approach and why + +Initial implementation used a minimal/simple GPS strategy: + +1. Power up PMU rails using the existing T-Beam adapter. +2. Start `Serial1` at 9600 baud. +3. Parse incoming NMEA (`GGA/GSV/RMC`) passively. +4. Show periodic OLED status every 30 seconds. + +Why this was chosen: + +- It is the smallest path to validate basic GPS lock/time behavior. +- It avoids introducing SD complexity while isolating GPS. +- It is easy for field testing (OLED-first, battery-powered). + +## What was discovered by comparing with Meshtastic + +Meshtastic GPS handling is more defensive and hardware-aware in principle: + +1. It uses a board variant that provides explicit GPS pin mapping for the T-Beam Supreme path. +2. It initializes GPS serial with explicit RX/TX pins and larger receive buffers. +3. It performs active startup probing (commands + response checks), not only passive listening. +4. It attempts detection across known module families and may try multiple serial settings. +5. It manages GNSS-related power/standby states deliberately (rather than assuming default UART traffic immediately appears). + +## What differed in this exercise and likely caused the issue + +The first Exercise 10 version was built on `esp32-s3-devkitc-1` with conditional pin usage. + +- If GPS pin macros are not present, `Serial1` can start on default pins. +- That can produce `NMEA seen: no` forever even outdoors, because firmware is listening on the wrong UART pins. + +## Corrections applied after Meshtastic review + +1. Added explicit GPS pin defines in `platformio.ini`: + - `GPS_RX_PIN=9` + - `GPS_TX_PIN=8` + - `GPS_WAKEUP_PIN=7` + - `GPS_1PPS_PIN=6` +2. Forced UART startup using explicit RX/TX pins. +3. Added startup multi-baud active probe and common GPS query commands. +4. Added OLED `NMEA seen: yes/no` so field tests distinguish: + - `no sky fix yet` vs + - `no GPS serial traffic at all`. + +## Field Test Checklist + +1. Flash and reboot outdoors with clear sky view. +2. Confirm the OLED updates every 30 seconds. +3. Watch for this expected progression: + - `Trying to locate satellites` + `NMEA seen: no` + - then `Trying to locate satellites` + `NMEA seen: yes` + - then either: + - `GPS lock acquired` with UTC and satellite count, or + - `Take me outside` if no fix yet. +4. Keep unit stationary for 2-5 minutes for first lock after cold start. + +Interpretation guide: + +- `NMEA seen: no`: likely UART/pin/baud/module-power communication issue. +- `NMEA seen: yes` + no lock: GPS is talking, but no valid fix yet (sky view/time-to-first-fix issue). +- `GPS lock acquired`: fix is valid; UTC and satellites are available from GPS. +- RTC line updates every 30 seconds: loop is alive and retry cycle is running. + +If still failing: + +1. Capture serial log from boot through at least 2 full 30-second cycles. +2. Note whether `NMEA seen` ever changes from `no` to `yes`. +3. Record whether GPS startup probe reports traffic at any baud rate. + +## Build + +```bash +source /home/jlpoole/rnsenv/bin/activate +pio run -e node_a +``` + +## Upload + +```bash +source /home/jlpoole/rnsenv/bin/activate +pio run -e node_a -t upload --upload-port /dev/ttyACM0 +``` diff --git a/exercises/10_Simple_GPS/platformio.ini b/exercises/10_Simple_GPS/platformio.ini new file mode 100644 index 0000000..3422f89 --- /dev/null +++ b/exercises/10_Simple_GPS/platformio.ini @@ -0,0 +1,47 @@ +; 20260217 ChatGPT +; $Id$ +; $HeadURL$ + +[platformio] +default_envs = node_a + +[env] +platform = espressif32 +framework = arduino +board = esp32-s3-devkitc-1 +monitor_speed = 115200 +lib_deps = + lewisxhe/XPowersLib@0.3.3 + Wire + olikraus/U8g2@^2.36.4 + +build_flags = + -I ../../shared/boards + -I ../../external/microReticulum_Firmware + -D BOARD_MODEL=BOARD_TBEAM_S_V1 + -D OLED_SDA=17 + -D OLED_SCL=18 + -D OLED_ADDR=0x3C + -D GPS_RX_PIN=9 + -D GPS_TX_PIN=8 + -D GPS_WAKEUP_PIN=7 + -D GPS_1PPS_PIN=6 + -D ARDUINO_USB_MODE=1 + -D ARDUINO_USB_CDC_ON_BOOT=1 + -D GPS_L76K + +[env:node_a] +build_flags = + ${env.build_flags} + -D NODE_LABEL=\"A\" + +[env:node_b] +build_flags = + ${env.build_flags} + -D NODE_LABEL=\"B\" + +[env:node_c] +build_flags = + ${env.build_flags} + -D NODE_LABEL=\"C\" + -D GPS_UBLOX \ No newline at end of file diff --git a/exercises/10_Simple_GPS/src/main.cpp b/exercises/10_Simple_GPS/src/main.cpp new file mode 100644 index 0000000..7456125 --- /dev/null +++ b/exercises/10_Simple_GPS/src/main.cpp @@ -0,0 +1,431 @@ +// 20260217 ChatGPT +// $Id$ +// $HeadURL$ + +#include +#include +#include + +#include "tbeam_supreme_adapter.h" + +#ifndef OLED_SDA +#define OLED_SDA 17 +#endif + +#ifndef OLED_SCL +#define OLED_SCL 18 +#endif + +#ifndef OLED_ADDR +#define OLED_ADDR 0x3C +#endif + +#ifndef RTC_I2C_ADDR +#define RTC_I2C_ADDR 0x51 +#endif + +#ifndef GPS_BAUD +#define GPS_BAUD 9600 +#endif + +static const uint32_t kSerialDelayMs = 5000; +static const uint32_t kReportIntervalMs = 30000; + +static XPowersLibInterface* g_pmu = nullptr; +static U8G2_SH1106_128X64_NONAME_F_HW_I2C g_oled(U8G2_R0, /* reset=*/U8X8_PIN_NONE); +static HardwareSerial g_gpsSerial(1); + +static uint32_t g_logSeq = 0; +static uint32_t g_lastReportMs = 0; +static uint32_t g_gpsBaud = GPS_BAUD; + +static char g_gpsLine[128]; +static size_t g_gpsLineLen = 0; + +struct RtcDateTime { + uint16_t year; + uint8_t month; + uint8_t day; + uint8_t hour; + uint8_t minute; + uint8_t second; +}; + +struct GpsState { + bool sawAnySentence = false; + uint8_t satsUsed = 0; + uint8_t satsInView = 0; + + bool hasValidUtc = false; + uint16_t utcYear = 0; + uint8_t utcMonth = 0; + uint8_t utcDay = 0; + uint8_t utcHour = 0; + uint8_t utcMinute = 0; + uint8_t utcSecond = 0; +}; + +static GpsState g_gps; + +static void logf(const char* fmt, ...) { + char msg[220]; + va_list args; + va_start(args, fmt); + vsnprintf(msg, sizeof(msg), fmt, args); + va_end(args); + Serial.printf("[%10lu][%06lu] %s\r\n", (unsigned long)millis(), (unsigned long)g_logSeq++, msg); +} + +static void oledShowLines(const char* l1, + const char* l2 = nullptr, + const char* l3 = nullptr, + const char* l4 = nullptr, + const char* l5 = nullptr) { + g_oled.clearBuffer(); + g_oled.setFont(u8g2_font_5x8_tf); + if (l1) g_oled.drawUTF8(0, 12, l1); + if (l2) g_oled.drawUTF8(0, 24, l2); + if (l3) g_oled.drawUTF8(0, 36, l3); + if (l4) g_oled.drawUTF8(0, 48, l4); + if (l5) g_oled.drawUTF8(0, 60, l5); + g_oled.sendBuffer(); +} + +static uint8_t fromBcd(uint8_t b) { + return ((b >> 4U) * 10U) + (b & 0x0FU); +} + +static bool rtcRead(RtcDateTime& out, bool& lowVoltageFlag) { + Wire1.beginTransmission(RTC_I2C_ADDR); + Wire1.write(0x02); + if (Wire1.endTransmission(false) != 0) { + return false; + } + + const uint8_t need = 7; + uint8_t got = Wire1.requestFrom((int)RTC_I2C_ADDR, (int)need); + if (got != need) { + return false; + } + + uint8_t sec = Wire1.read(); + uint8_t min = Wire1.read(); + uint8_t hour = Wire1.read(); + uint8_t day = Wire1.read(); + (void)Wire1.read(); + uint8_t month = Wire1.read(); + uint8_t year = Wire1.read(); + + lowVoltageFlag = (sec & 0x80U) != 0; + out.second = fromBcd(sec & 0x7FU); + out.minute = fromBcd(min & 0x7FU); + out.hour = fromBcd(hour & 0x3FU); + out.day = fromBcd(day & 0x3FU); + out.month = fromBcd(month & 0x1FU); + uint8_t yy = fromBcd(year); + bool century = (month & 0x80U) != 0; + out.year = century ? (1900U + yy) : (2000U + yy); + return true; +} + +static String formatRtcNow() { + RtcDateTime now{}; + bool lowV = false; + if (!rtcRead(now, lowV)) { + return "RTC read failed"; + } + + char buf[48]; + snprintf(buf, + sizeof(buf), + "RTC %04u-%02u-%02u %02u:%02u:%02u%s", + (unsigned)now.year, + (unsigned)now.month, + (unsigned)now.day, + (unsigned)now.hour, + (unsigned)now.minute, + (unsigned)now.second, + lowV ? " !LOWV" : ""); + return String(buf); +} + +static bool parseUInt2(const char* s, uint8_t& out) { + if (!s || !isdigit((unsigned char)s[0]) || !isdigit((unsigned char)s[1])) { + return false; + } + out = (uint8_t)((s[0] - '0') * 10 + (s[1] - '0')); + return true; +} + +static void parseGga(char* fields[], int count) { + if (count <= 7) { + return; + } + int sats = atoi(fields[7]); + if (sats >= 0 && sats <= 255) { + g_gps.satsUsed = (uint8_t)sats; + } +} + +static void parseGsv(char* fields[], int count) { + if (count <= 3) { + return; + } + int sats = atoi(fields[3]); + if (sats >= 0 && sats <= 255) { + g_gps.satsInView = (uint8_t)sats; + } +} + +static void parseRmc(char* fields[], int count) { + if (count <= 9) { + return; + } + + const char* utc = fields[1]; + const char* status = fields[2]; + const char* date = fields[9]; + + if (!status || status[0] != 'A') { + return; + } + + if (!utc || strlen(utc) < 6 || !date || strlen(date) < 6) { + return; + } + + uint8_t hh = 0, mm = 0, ss = 0; + uint8_t dd = 0, mo = 0, yy = 0; + if (!parseUInt2(utc + 0, hh) || !parseUInt2(utc + 2, mm) || !parseUInt2(utc + 4, ss)) { + return; + } + if (!parseUInt2(date + 0, dd) || !parseUInt2(date + 2, mo) || !parseUInt2(date + 4, yy)) { + return; + } + + g_gps.utcHour = hh; + g_gps.utcMinute = mm; + g_gps.utcSecond = ss; + g_gps.utcDay = dd; + g_gps.utcMonth = mo; + g_gps.utcYear = (uint16_t)(2000U + yy); + g_gps.hasValidUtc = true; +} + +static void processNmeaLine(char* line) { + if (!line || line[0] != '$') { + return; + } + + g_gps.sawAnySentence = true; + + char* star = strchr(line, '*'); + if (star) { + *star = '\0'; + } + + char* fields[24] = {0}; + int count = 0; + char* saveptr = nullptr; + char* tok = strtok_r(line, ",", &saveptr); + while (tok && count < 24) { + fields[count++] = tok; + tok = strtok_r(nullptr, ",", &saveptr); + } + + if (count <= 0 || !fields[0]) { + return; + } + + const char* header = fields[0]; + size_t n = strlen(header); + if (n < 6) { + return; + } + + const char* type = header + (n - 3); + if (strcmp(type, "GGA") == 0) { + parseGga(fields, count); + } else if (strcmp(type, "GSV") == 0) { + parseGsv(fields, count); + } else if (strcmp(type, "RMC") == 0) { + parseRmc(fields, count); + } +} + +static void pollGpsSerial() { + while (g_gpsSerial.available() > 0) { + char c = (char)g_gpsSerial.read(); + if (c == '\r') { + continue; + } + if (c == '\n') { + if (g_gpsLineLen > 0) { + g_gpsLine[g_gpsLineLen] = '\0'; + processNmeaLine(g_gpsLine); + g_gpsLineLen = 0; + } + continue; + } + + if (g_gpsLineLen + 1 < sizeof(g_gpsLine)) { + g_gpsLine[g_gpsLineLen++] = c; + } else { + g_gpsLineLen = 0; + } + } +} + +static void startGpsUart(uint32_t baud) { + g_gpsSerial.end(); + delay(20); + g_gpsSerial.setRxBufferSize(1024); + g_gpsSerial.begin(baud, SERIAL_8N1, GPS_RX_PIN, GPS_TX_PIN); + g_gpsBaud = baud; +} + +static bool collectGpsTraffic(uint32_t windowMs) { + uint32_t start = millis(); + size_t bytesSeen = 0; + while ((uint32_t)(millis() - start) < windowMs) { + while (g_gpsSerial.available() > 0) { + (void)g_gpsSerial.read(); + bytesSeen++; + } + pollGpsSerial(); + delay(2); + } + return bytesSeen > 0 || g_gps.sawAnySentence; +} + +static bool probeGpsAtBaud(uint32_t baud) { + startGpsUart(baud); + logf("Probing GPS at %lu baud...", (unsigned long)baud); + if (collectGpsTraffic(700)) { + return true; + } + + // Try common query/wake commands used by MTK/L76K and related chipsets. + g_gpsSerial.write("$PCAS06,0*1B\r\n"); // Request module SW text + g_gpsSerial.write("$PMTK605*31\r\n"); // MTK firmware query + g_gpsSerial.write("$PQTMVERNO*58\r\n"); // Quectel LC86 query + g_gpsSerial.write("$PMTK353,1,1,1,1,1*2A\r\n"); + g_gpsSerial.write("$PMTK314,0,1,0,1,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0*29\r\n"); + + return collectGpsTraffic(1200); +} + +static void initialGpsProbe() { + const uint32_t bauds[] = {GPS_BAUD, 115200, 38400, 57600, 19200}; + for (size_t i = 0; i < sizeof(bauds) / sizeof(bauds[0]); ++i) { + if (probeGpsAtBaud(bauds[i])) { + logf("GPS traffic detected at %lu baud", (unsigned long)g_gpsBaud); + return; + } + } + logf("No GPS traffic detected during startup probe"); +} + +static uint8_t bestSatelliteCount() { + return (g_gps.satsUsed > g_gps.satsInView) ? g_gps.satsUsed : g_gps.satsInView; +} + +static void showTryingMessage() { + String rtc = formatRtcNow(); + oledShowLines("Trying to locate", + "satellites", + g_gps.sawAnySentence ? "NMEA seen: yes" : "NMEA seen: no", + rtc.c_str()); + logf("Trying to locate satellites. %s", rtc.c_str()); +} + +static void showStatusMessage() { + uint8_t sats = bestSatelliteCount(); + + if (g_gps.hasValidUtc) { + char line2[40]; + char line3[28]; + snprintf(line2, + sizeof(line2), + "GPS UTC %04u-%02u-%02u", + (unsigned)g_gps.utcYear, + (unsigned)g_gps.utcMonth, + (unsigned)g_gps.utcDay); + snprintf(line3, + sizeof(line3), + "%02u:%02u:%02u sats:%u", + (unsigned)g_gps.utcHour, + (unsigned)g_gps.utcMinute, + (unsigned)g_gps.utcSecond, + (unsigned)sats); + oledShowLines("GPS lock acquired", + line2, + line3, + g_gps.sawAnySentence ? "NMEA seen: yes" : "NMEA seen: no"); + logf("GPS lock acquired. %s sats=%u", line3, (unsigned)sats); + return; + } + + String rtc = formatRtcNow(); + oledShowLines("Take me outside", + "No GPS time/sats yet", + g_gps.sawAnySentence ? "NMEA seen: yes" : "NMEA seen: no", + rtc.c_str()); + logf("Take me outside. sats=%u, has_utc=%s, nmea_seen=%s. %s", + (unsigned)sats, + g_gps.hasValidUtc ? "yes" : "no", + g_gps.sawAnySentence ? "yes" : "no", + rtc.c_str()); +} + +void setup() { + Serial.begin(115200); + delay(kSerialDelayMs); + + Serial.println("\r\n=================================================="); + Serial.println("Exercise 10: Simple GPS (No SD)"); + Serial.println("=================================================="); + + if (!tbeam_supreme::initPmuForPeripherals(g_pmu, &Serial)) { + logf("PMU init failed"); + } + + Wire.begin(OLED_SDA, OLED_SCL); + g_oled.setI2CAddress(OLED_ADDR << 1); + g_oled.begin(); + oledShowLines("Simple GPS", "Booting..."); + +#ifdef GPS_1PPS_PIN + pinMode(GPS_1PPS_PIN, INPUT); +#endif + +#ifdef GPS_WAKEUP_PIN + // Keep wake pin in a neutral state similar to Meshtastic behavior. + pinMode(GPS_WAKEUP_PIN, INPUT); +#endif + + startGpsUart(GPS_BAUD); + logf("GPS UART started: RX=%d TX=%d baud=%lu", GPS_RX_PIN, GPS_TX_PIN, (unsigned long)g_gpsBaud); + initialGpsProbe(); + + showTryingMessage(); + g_lastReportMs = millis(); +} + +void loop() { + pollGpsSerial(); + + uint32_t now = millis(); + if ((uint32_t)(now - g_lastReportMs) >= kReportIntervalMs) { + g_lastReportMs = now; + + showTryingMessage(); + uint32_t start = millis(); + while ((uint32_t)(millis() - start) < 2000) { + pollGpsSerial(); + delay(5); + } + + showStatusMessage(); + } +} diff --git a/exercises/11_Set_RTC2GPS/README.md b/exercises/11_Set_RTC2GPS/README.md new file mode 100644 index 0000000..462c2d2 --- /dev/null +++ b/exercises/11_Set_RTC2GPS/README.md @@ -0,0 +1,33 @@ +## Exercise 11: Set RTC to GPS (1PPS Discipline) + +This exercise extends Exercise 9 behavior (GPS + SD + OLED) and disciplines the onboard RTC from GPS UTC using the GPS `1PPS` (pulse-per-second) timing signal. + +Implemented behavior: + +1. Boots PMU, OLED, SD watcher, and GPS UART using the same T-Beam Supreme pin mapping from prior exercises. +2. Parses NMEA (`RMC`, `GGA`, `GSV`) to track UTC validity and satellite counts. +3. Every 1 minute, attempts to set RTC from GPS: + - Uses latest valid GPS UTC. + - Waits for next `1PPS` rising edge. + - Sets RTC to GPS time aligned to that edge (UTC + 1 second). +4. Appends event records to SD file: + - Path: `/gps/discipline_rtc.log` + - Append-only writes (`FILE_APPEND`) + - Format: + - `YYYYMMDD_HH24MISS_z\t set RTC to GPS using 1PPS pulse-per-second discipline\trtc-gps drift=+/-Ns` +5. OLED success message shows RTC disciplined confirmation and timestamp. +6. If GPS time cannot be determined (or 1PPS edge is not seen in timeout), OLED shows failure status and the loop delays 30 seconds before retry. + +## Build + +```bash +source /home/jlpoole/rnsenv/bin/activate +pio run -e node_a +``` + +## Upload + +```bash +source /home/jlpoole/rnsenv/bin/activate +pio run -e node_a -t upload --upload-port /dev/ttyACM0 +``` diff --git a/exercises/11_Set_RTC2GPS/lib/startup_sd/StartupSdManager.cpp b/exercises/11_Set_RTC2GPS/lib/startup_sd/StartupSdManager.cpp new file mode 100644 index 0000000..1e8791c --- /dev/null +++ b/exercises/11_Set_RTC2GPS/lib/startup_sd/StartupSdManager.cpp @@ -0,0 +1,360 @@ +#include "StartupSdManager.h" + +#include +#include "driver/gpio.h" + +StartupSdManager::StartupSdManager(Print& serial) : serial_(serial) {} + +bool StartupSdManager::begin(const SdWatcherConfig& cfg, SdStatusCallback callback) { + cfg_ = cfg; + callback_ = callback; + + forceSpiDeselected(); + dumpSdPins("very-early"); + + if (!initPmuForSdPower()) { + return false; + } + + cycleSdRail(); + delay(cfg_.startupWarmupMs); + + bool warmMounted = false; + for (uint8_t i = 0; i < 3; ++i) { + if (mountPreferred(false)) { + warmMounted = true; + break; + } + delay(200); + } + + // Some cards need a longer power/settle window after cold boot. + // Before declaring ABSENT, retry with extended settle and a full scan. + if (!warmMounted) { + logf("Watcher: startup preferred mount failed, retrying with extended settle"); + cycleSdRail(400, 1200); + delay(cfg_.startupWarmupMs + 1500); + warmMounted = mountCardFullScan(); + } + + if (warmMounted) { + setStateMounted(); + } else { + setStateAbsent(); + } + return true; +} + +void StartupSdManager::update() { + const uint32_t now = millis(); + const uint32_t pollInterval = + (watchState_ == SdWatchState::MOUNTED) ? cfg_.pollIntervalMountedMs : cfg_.pollIntervalAbsentMs; + + if ((uint32_t)(now - lastPollMs_) < pollInterval) { + return; + } + lastPollMs_ = now; + + if (watchState_ == SdWatchState::MOUNTED) { + if (verifyMountedCard()) { + presentVotes_ = 0; + absentVotes_ = 0; + return; + } + + if (mountPreferred(false) && verifyMountedCard()) { + presentVotes_ = 0; + absentVotes_ = 0; + return; + } + + absentVotes_++; + presentVotes_ = 0; + if (absentVotes_ >= cfg_.votesToAbsent) { + setStateAbsent(); + absentVotes_ = 0; + } + return; + } + + bool mounted = mountPreferred(false); + if (!mounted && (uint32_t)(now - lastFullScanMs_) >= cfg_.fullScanIntervalMs) { + lastFullScanMs_ = now; + if (cfg_.recoveryRailCycleOnFullScan) { + logf("Watcher: recovery rail cycle before full scan"); + cycleSdRail(cfg_.recoveryRailOffMs, cfg_.recoveryRailOnSettleMs); + delay(150); + } + logf("Watcher: preferred probe failed, running full scan"); + mounted = mountCardFullScan(); + } + + if (mounted) { + presentVotes_++; + absentVotes_ = 0; + if (presentVotes_ >= cfg_.votesToPresent) { + setStateMounted(); + presentVotes_ = 0; + } + } else { + absentVotes_++; + presentVotes_ = 0; + if (absentVotes_ >= cfg_.votesToAbsent) { + setStateAbsent(); + absentVotes_ = 0; + } + } +} + +bool StartupSdManager::consumeMountedEvent() { + bool out = mountedEventPending_; + mountedEventPending_ = false; + return out; +} + +bool StartupSdManager::consumeRemovedEvent() { + bool out = removedEventPending_; + removedEventPending_ = false; + return out; +} + +void StartupSdManager::logf(const char* fmt, ...) { + char msg[196]; + va_list args; + va_start(args, fmt); + vsnprintf(msg, sizeof(msg), fmt, args); + va_end(args); + serial_.printf("[%10lu][%06lu] %s\r\n", + (unsigned long)millis(), + (unsigned long)logSeq_++, + msg); +} + +void StartupSdManager::notify(SdEvent event, const char* message) { + if (callback_ != nullptr) { + callback_(event, message); + } +} + +void StartupSdManager::forceSpiDeselected() { + pinMode(tbeam_supreme::sdCs(), OUTPUT); + digitalWrite(tbeam_supreme::sdCs(), HIGH); + pinMode(tbeam_supreme::imuCs(), OUTPUT); + digitalWrite(tbeam_supreme::imuCs(), HIGH); +} + +void StartupSdManager::dumpSdPins(const char* tag) { + if (!cfg_.enablePinDumps) { + (void)tag; + return; + } + + const gpio_num_t cs = (gpio_num_t)tbeam_supreme::sdCs(); + const gpio_num_t sck = (gpio_num_t)tbeam_supreme::sdSck(); + const gpio_num_t miso = (gpio_num_t)tbeam_supreme::sdMiso(); + const gpio_num_t mosi = (gpio_num_t)tbeam_supreme::sdMosi(); + logf("PINS(%s): CS=%d SCK=%d MISO=%d MOSI=%d", + tag, gpio_get_level(cs), gpio_get_level(sck), gpio_get_level(miso), gpio_get_level(mosi)); +} + +bool StartupSdManager::initPmuForSdPower() { + if (!tbeam_supreme::initPmuForPeripherals(pmu_, &serial_)) { + logf("ERROR: PMU init failed"); + return false; + } + return true; +} + +void StartupSdManager::cycleSdRail(uint32_t offMs, uint32_t onSettleMs) { + if (!cfg_.enableSdRailCycle) { + return; + } + if (!pmu_) { + logf("SD rail cycle skipped: pmu=null"); + return; + } + + forceSpiDeselected(); + pmu_->disablePowerOutput(XPOWERS_BLDO1); + delay(offMs); + pmu_->setPowerChannelVoltage(XPOWERS_BLDO1, 3300); + pmu_->enablePowerOutput(XPOWERS_BLDO1); + delay(onSettleMs); +} + +bool StartupSdManager::tryMountWithBus(SPIClass& bus, const char* busName, uint32_t hz, bool verbose) { + SD.end(); + bus.end(); + delay(10); + forceSpiDeselected(); + + bus.begin(tbeam_supreme::sdSck(), tbeam_supreme::sdMiso(), tbeam_supreme::sdMosi(), tbeam_supreme::sdCs()); + digitalWrite(tbeam_supreme::sdCs(), HIGH); + delay(2); + for (int i = 0; i < 10; i++) { + bus.transfer(0xFF); + } + delay(2); + + if (verbose) { + logf("SD: trying bus=%s freq=%lu Hz", busName, (unsigned long)hz); + } + + if (!SD.begin(tbeam_supreme::sdCs(), bus, hz)) { + if (verbose) { + logf("SD: mount failed (possible non-FAT format, power, or bus issue)"); + } + return false; + } + + if (SD.cardType() == CARD_NONE) { + SD.end(); + return false; + } + + sdSpi_ = &bus; + sdBusName_ = busName; + sdFreq_ = hz; + return true; +} + +bool StartupSdManager::mountPreferred(bool verbose) { + return tryMountWithBus(sdSpiH_, "HSPI", 400000, verbose); +} + +bool StartupSdManager::mountCardFullScan() { + const uint32_t freqs[] = {400000, 1000000, 4000000, 10000000}; + + for (uint8_t i = 0; i < (sizeof(freqs) / sizeof(freqs[0])); ++i) { + if (tryMountWithBus(sdSpiH_, "HSPI", freqs[i], true)) { + logf("SD: card detected and mounted"); + return true; + } + } + for (uint8_t i = 0; i < (sizeof(freqs) / sizeof(freqs[0])); ++i) { + if (tryMountWithBus(sdSpiF_, "FSPI", freqs[i], true)) { + logf("SD: card detected and mounted"); + return true; + } + } + + logf("SD: begin() failed on all bus/frequency attempts"); + return false; +} + +bool StartupSdManager::verifyMountedCard() { + File root = SD.open("/", FILE_READ); + if (!root) { + return false; + } + root.close(); + return true; +} + +const char* StartupSdManager::cardTypeToString(uint8_t type) { + switch (type) { + case CARD_MMC: + return "MMC"; + case CARD_SD: + return "SDSC"; + case CARD_SDHC: + return "SDHC/SDXC"; + default: + return "UNKNOWN"; + } +} + +void StartupSdManager::printCardInfo() { + uint8_t cardType = SD.cardType(); + uint64_t cardSizeMB = SD.cardSize() / (1024ULL * 1024ULL); + uint64_t totalMB = SD.totalBytes() / (1024ULL * 1024ULL); + uint64_t usedMB = SD.usedBytes() / (1024ULL * 1024ULL); + + logf("SD type: %s", cardTypeToString(cardType)); + logf("SD size: %llu MB", cardSizeMB); + logf("FS total: %llu MB", totalMB); + logf("FS used : %llu MB", usedMB); + logf("SPI bus: %s @ %lu Hz", sdBusName_, (unsigned long)sdFreq_); +} + +bool StartupSdManager::ensureDirRecursive(const char* path) { + String full(path); + if (!full.startsWith("/")) { + full = "/" + full; + } + + int start = 1; + while (start > 0 && start < (int)full.length()) { + int slash = full.indexOf('/', start); + String partial = (slash < 0) ? full : full.substring(0, slash); + if (!SD.exists(partial.c_str()) && !SD.mkdir(partial.c_str())) { + logf("ERROR: mkdir failed for %s", partial.c_str()); + return false; + } + if (slash < 0) { + break; + } + start = slash + 1; + } + + return true; +} + +bool StartupSdManager::rewriteFile(const char* path, const char* payload) { + if (SD.exists(path) && !SD.remove(path)) { + logf("ERROR: failed to erase %s", path); + return false; + } + + File f = SD.open(path, FILE_WRITE); + if (!f) { + logf("ERROR: failed to create %s", path); + return false; + } + + size_t wrote = f.println(payload); + f.close(); + if (wrote == 0) { + logf("ERROR: write failed for %s", path); + return false; + } + return true; +} + +void StartupSdManager::permissionsDemo(const char* path) { + logf("Permissions demo: FAT has no Unix chmod/chown, use open mode only."); + File r = SD.open(path, FILE_READ); + if (!r) { + logf("Could not open %s as FILE_READ", path); + return; + } + size_t writeInReadMode = r.print("attempt write while opened read-only"); + if (writeInReadMode == 0) { + logf("As expected, FILE_READ write was blocked."); + } else { + logf("NOTE: FILE_READ write returned %u (unexpected)", (unsigned)writeInReadMode); + } + r.close(); +} + +void StartupSdManager::setStateMounted() { + if (watchState_ != SdWatchState::MOUNTED) { + logf("EVENT: card inserted/mounted"); + mountedEventPending_ = true; + notify(SdEvent::CARD_MOUNTED, "SD card mounted"); + } + watchState_ = SdWatchState::MOUNTED; +} + +void StartupSdManager::setStateAbsent() { + if (watchState_ == SdWatchState::MOUNTED) { + logf("EVENT: card removed/unavailable"); + removedEventPending_ = true; + notify(SdEvent::CARD_REMOVED, "SD card removed"); + } else if (watchState_ != SdWatchState::ABSENT) { + logf("EVENT: no card detected"); + notify(SdEvent::NO_CARD, "Missing SD card or invalid FAT16/FAT32 format"); + } + SD.end(); + watchState_ = SdWatchState::ABSENT; +} diff --git a/exercises/11_Set_RTC2GPS/lib/startup_sd/StartupSdManager.h b/exercises/11_Set_RTC2GPS/lib/startup_sd/StartupSdManager.h new file mode 100644 index 0000000..be9ef27 --- /dev/null +++ b/exercises/11_Set_RTC2GPS/lib/startup_sd/StartupSdManager.h @@ -0,0 +1,90 @@ +#pragma once + +#include +#include +#include +#include +#include "tbeam_supreme_adapter.h" + +enum class SdWatchState : uint8_t { + UNKNOWN = 0, + ABSENT, + MOUNTED +}; + +enum class SdEvent : uint8_t { + NO_CARD, + CARD_MOUNTED, + CARD_REMOVED +}; + +using SdStatusCallback = void (*)(SdEvent event, const char* message); + +struct SdWatcherConfig { + bool enableSdRailCycle = true; + bool enablePinDumps = true; + bool recoveryRailCycleOnFullScan = true; + uint32_t recoveryRailOffMs = 250; + uint32_t recoveryRailOnSettleMs = 700; + uint32_t startupWarmupMs = 1500; + uint32_t pollIntervalAbsentMs = 1000; + uint32_t pollIntervalMountedMs = 2000; + uint32_t fullScanIntervalMs = 10000; + uint8_t votesToPresent = 2; + uint8_t votesToAbsent = 5; +}; + +class StartupSdManager { + public: + explicit StartupSdManager(Print& serial = Serial); + + bool begin(const SdWatcherConfig& cfg, SdStatusCallback callback = nullptr); + void update(); + + bool isMounted() const { return watchState_ == SdWatchState::MOUNTED; } + SdWatchState state() const { return watchState_; } + + bool consumeMountedEvent(); + bool consumeRemovedEvent(); + + void printCardInfo(); + bool ensureDirRecursive(const char* path); + bool rewriteFile(const char* path, const char* payload); + void permissionsDemo(const char* path); + + private: + void logf(const char* fmt, ...); + void notify(SdEvent event, const char* message); + void forceSpiDeselected(); + void dumpSdPins(const char* tag); + bool initPmuForSdPower(); + void cycleSdRail(uint32_t offMs = 250, uint32_t onSettleMs = 600); + bool tryMountWithBus(SPIClass& bus, const char* busName, uint32_t hz, bool verbose); + bool mountPreferred(bool verbose); + bool mountCardFullScan(); + bool verifyMountedCard(); + const char* cardTypeToString(uint8_t type); + void setStateMounted(); + void setStateAbsent(); + + Print& serial_; + SdWatcherConfig cfg_{}; + SdStatusCallback callback_ = nullptr; + + SPIClass sdSpiH_{HSPI}; + SPIClass sdSpiF_{FSPI}; + SPIClass* sdSpi_ = nullptr; + const char* sdBusName_ = "none"; + uint32_t sdFreq_ = 0; + XPowersLibInterface* pmu_ = nullptr; + + SdWatchState watchState_ = SdWatchState::UNKNOWN; + uint8_t presentVotes_ = 0; + uint8_t absentVotes_ = 0; + uint32_t lastPollMs_ = 0; + uint32_t lastFullScanMs_ = 0; + uint32_t logSeq_ = 0; + + bool mountedEventPending_ = false; + bool removedEventPending_ = false; +}; diff --git a/exercises/11_Set_RTC2GPS/lib/startup_sd/library.json b/exercises/11_Set_RTC2GPS/lib/startup_sd/library.json new file mode 100644 index 0000000..4978fdd --- /dev/null +++ b/exercises/11_Set_RTC2GPS/lib/startup_sd/library.json @@ -0,0 +1,12 @@ +{ + "name": "startup_sd", + "version": "0.1.0", + "dependencies": [ + { + "name": "XPowersLib" + }, + { + "name": "Wire" + } + ] +} diff --git a/exercises/11_Set_RTC2GPS/platformio.ini b/exercises/11_Set_RTC2GPS/platformio.ini new file mode 100644 index 0000000..8c7c7f3 --- /dev/null +++ b/exercises/11_Set_RTC2GPS/platformio.ini @@ -0,0 +1,43 @@ +; 20260213 ChatGPT +; $Id$ +; $HeadURL$ + +[platformio] +default_envs = node_a + +[env] +platform = espressif32 +framework = arduino +board = esp32-s3-devkitc-1 +monitor_speed = 115200 +extra_scripts = pre:scripts/set_build_epoch.py +lib_deps = + lewisxhe/XPowersLib@0.3.3 + Wire + olikraus/U8g2@^2.36.4 + +; SD pins based on T-Beam S3 core pin mapping +build_flags = + -I ../../shared/boards + -I ../../external/microReticulum_Firmware + -D BOARD_MODEL=BOARD_TBEAM_S_V1 + -D OLED_SDA=17 + -D OLED_SCL=18 + -D OLED_ADDR=0x3C + -D GPS_RX_PIN=9 + -D GPS_TX_PIN=8 + -D GPS_WAKEUP_PIN=7 + -D GPS_1PPS_PIN=6 + -D GPS_L76K + -D ARDUINO_USB_MODE=1 + -D ARDUINO_USB_CDC_ON_BOOT=1 + +[env:node_a] +build_flags = + ${env.build_flags} + -D NODE_LABEL=\"A\" + +[env:node_b] +build_flags = + ${env.build_flags} + -D NODE_LABEL=\"B\" diff --git a/exercises/11_Set_RTC2GPS/scripts/set_build_epoch.py b/exercises/11_Set_RTC2GPS/scripts/set_build_epoch.py new file mode 100644 index 0000000..3011129 --- /dev/null +++ b/exercises/11_Set_RTC2GPS/scripts/set_build_epoch.py @@ -0,0 +1,12 @@ +import time +Import("env") + +epoch = int(time.time()) +utc_tag = time.strftime("%Y%m%d_%H%M%S_z", time.gmtime(epoch)) + +env.Append( + CPPDEFINES=[ + ("FW_BUILD_EPOCH", str(epoch)), + ("FW_BUILD_UTC", '\\"%s\\"' % utc_tag), + ] +) diff --git a/exercises/11_Set_RTC2GPS/src/main.cpp b/exercises/11_Set_RTC2GPS/src/main.cpp new file mode 100644 index 0000000..9e54ea7 --- /dev/null +++ b/exercises/11_Set_RTC2GPS/src/main.cpp @@ -0,0 +1,786 @@ +// 20260217 ChatGPT +// $Id$ +// $HeadURL$ + +#include +#include +#include +#include + +#include "StartupSdManager.h" +#include "tbeam_supreme_adapter.h" + +#ifndef OLED_SDA +#define OLED_SDA 17 +#endif + +#ifndef OLED_SCL +#define OLED_SCL 18 +#endif + +#ifndef OLED_ADDR +#define OLED_ADDR 0x3C +#endif + +#ifndef RTC_I2C_ADDR +#define RTC_I2C_ADDR 0x51 +#endif + +#ifndef GPS_BAUD +#define GPS_BAUD 9600 +#endif + +#ifndef FILE_APPEND +#define FILE_APPEND FILE_WRITE +#endif + +#ifndef FW_BUILD_EPOCH +#define FW_BUILD_EPOCH 0 +#endif + +#ifndef FW_BUILD_UTC +#define FW_BUILD_UTC "unknown" +#endif + +static const uint32_t kSerialDelayMs = 5000; +static const uint32_t kLoopMsDiscipline = 60000; +static const uint32_t kNoTimeDelayMs = 30000; +static const uint32_t kGpsStartupProbeMs = 20000; +static const uint32_t kPpsWaitTimeoutMs = 1500; + +static XPowersLibInterface* g_pmu = nullptr; +static StartupSdManager g_sd(Serial); +static U8G2_SH1106_128X64_NONAME_F_HW_I2C g_oled(U8G2_R0, /* reset=*/U8X8_PIN_NONE); +static HardwareSerial g_gpsSerial(1); + +static uint32_t g_logSeq = 0; +static uint32_t g_nextDisciplineMs = 0; +static bool g_gpsPathReady = false; + +static char g_gpsLine[128]; +static size_t g_gpsLineLen = 0; + +static volatile uint32_t g_ppsEdgeCount = 0; + +struct DateTime { + uint16_t year; + uint8_t month; + uint8_t day; + uint8_t hour; + uint8_t minute; + uint8_t second; +}; + +struct GpsState { + bool sawAnySentence = false; + bool hasValidUtc = false; + bool hasValidPosition = false; + bool hasValidAltitude = false; + uint8_t satsUsed = 0; + uint8_t satsInView = 0; + float hdop = -1.0f; + float altitudeM = 0.0f; + double latitudeDeg = 0.0; + double longitudeDeg = 0.0; + DateTime utc{}; + uint32_t lastUtcMs = 0; +}; + +static GpsState g_gps; + +static void logf(const char* fmt, ...) { + char msg[240]; + va_list args; + va_start(args, fmt); + vsnprintf(msg, sizeof(msg), fmt, args); + va_end(args); + Serial.printf("[%10lu][%06lu] %s\r\n", (unsigned long)millis(), (unsigned long)g_logSeq++, msg); +} + +static void oledShowLines(const char* l1, + const char* l2 = nullptr, + const char* l3 = nullptr, + const char* l4 = nullptr, + const char* l5 = nullptr) { + g_oled.clearBuffer(); + g_oled.setFont(u8g2_font_5x8_tf); + if (l1) g_oled.drawUTF8(0, 12, l1); + if (l2) g_oled.drawUTF8(0, 24, l2); + if (l3) g_oled.drawUTF8(0, 36, l3); + if (l4) g_oled.drawUTF8(0, 48, l4); + if (l5) g_oled.drawUTF8(0, 60, l5); + g_oled.sendBuffer(); +} + +static uint8_t toBcd(uint8_t v) { + return (uint8_t)(((v / 10U) << 4U) | (v % 10U)); +} + +static uint8_t fromBcd(uint8_t b) { + return (uint8_t)(((b >> 4U) * 10U) + (b & 0x0FU)); +} + +static bool rtcRead(DateTime& out, bool& lowVoltageFlag) { + Wire1.beginTransmission(RTC_I2C_ADDR); + Wire1.write(0x02); + if (Wire1.endTransmission(false) != 0) { + return false; + } + + const uint8_t need = 7; + uint8_t got = Wire1.requestFrom((int)RTC_I2C_ADDR, (int)need); + if (got != need) { + return false; + } + + uint8_t sec = Wire1.read(); + uint8_t min = Wire1.read(); + uint8_t hour = Wire1.read(); + uint8_t day = Wire1.read(); + (void)Wire1.read(); + uint8_t month = Wire1.read(); + uint8_t year = Wire1.read(); + + lowVoltageFlag = (sec & 0x80U) != 0; + out.second = fromBcd(sec & 0x7FU); + out.minute = fromBcd(min & 0x7FU); + out.hour = fromBcd(hour & 0x3FU); + out.day = fromBcd(day & 0x3FU); + out.month = fromBcd(month & 0x1FU); + uint8_t yy = fromBcd(year); + bool century = (month & 0x80U) != 0; + out.year = century ? (1900U + yy) : (2000U + yy); + return true; +} + +static bool rtcWrite(const DateTime& dt) { + Wire1.beginTransmission(RTC_I2C_ADDR); + Wire1.write(0x02); + Wire1.write(toBcd(dt.second & 0x7FU)); + Wire1.write(toBcd(dt.minute)); + Wire1.write(toBcd(dt.hour)); + Wire1.write(toBcd(dt.day)); + Wire1.write(0x00); + + uint8_t monthReg = toBcd(dt.month); + if (dt.year < 2000U) { + monthReg |= 0x80U; + } + Wire1.write(monthReg); + Wire1.write(toBcd((uint8_t)(dt.year % 100U))); + + return Wire1.endTransmission() == 0; +} + +static bool isLeapYear(uint16_t y) { + return ((y % 4U) == 0U && (y % 100U) != 0U) || ((y % 400U) == 0U); +} + +static uint8_t daysInMonth(uint16_t year, uint8_t month) { + static const uint8_t kDays[12] = {31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31}; + if (month == 2) { + return (uint8_t)(isLeapYear(year) ? 29 : 28); + } + if (month >= 1 && month <= 12) { + return kDays[month - 1]; + } + return 30; +} + +static bool isValidDateTime(const DateTime& dt) { + if (dt.year < 2000U || dt.year > 2099U) return false; + if (dt.month < 1 || dt.month > 12) return false; + if (dt.day < 1 || dt.day > daysInMonth(dt.year, dt.month)) return false; + if (dt.hour > 23 || dt.minute > 59 || dt.second > 59) return false; + return true; +} + +static int64_t daysFromCivil(int y, unsigned m, unsigned d) { + y -= (m <= 2); + const int era = (y >= 0 ? y : y - 399) / 400; + const unsigned yoe = (unsigned)(y - era * 400); + const unsigned doy = (153 * (m + (m > 2 ? (unsigned)-3 : 9)) + 2) / 5 + d - 1; + const unsigned doe = yoe * 365 + yoe / 4 - yoe / 100 + doy; + return era * 146097 + (int)doe - 719468; +} + +static int64_t toEpochSeconds(const DateTime& dt) { + int64_t days = daysFromCivil((int)dt.year, dt.month, dt.day); + return days * 86400LL + (int64_t)dt.hour * 3600LL + (int64_t)dt.minute * 60LL + (int64_t)dt.second; +} + +static bool fromEpochSeconds(int64_t sec, DateTime& out) { + if (sec < 0) { + return false; + } + + int64_t days = sec / 86400LL; + int64_t rem = sec % 86400LL; + if (rem < 0) { + rem += 86400LL; + days -= 1; + } + + out.hour = (uint8_t)(rem / 3600LL); + rem %= 3600LL; + out.minute = (uint8_t)(rem / 60LL); + out.second = (uint8_t)(rem % 60LL); + + days += 719468; + const int era = (days >= 0 ? days : days - 146096) / 146097; + const unsigned doe = (unsigned)(days - era * 146097); + const unsigned yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365; + int y = (int)yoe + era * 400; + const unsigned doy = doe - (365 * yoe + yoe / 4 - yoe / 100); + const unsigned mp = (5 * doy + 2) / 153; + const unsigned d = doy - (153 * mp + 2) / 5 + 1; + const unsigned m = mp + (mp < 10 ? 3 : (unsigned)-9); + y += (m <= 2); + + out.year = (uint16_t)y; + out.month = (uint8_t)m; + out.day = (uint8_t)d; + return isValidDateTime(out); +} + +static void addOneSecond(DateTime& dt) { + int64_t t = toEpochSeconds(dt); + DateTime out{}; + if (fromEpochSeconds(t + 1, out)) { + dt = out; + } +} + +static void formatUtcCompact(const DateTime& dt, char* out, size_t outLen) { + snprintf(out, + outLen, + "%04u%02u%02u_%02u%02u%02u_z", + (unsigned)dt.year, + (unsigned)dt.month, + (unsigned)dt.day, + (unsigned)dt.hour, + (unsigned)dt.minute, + (unsigned)dt.second); +} + +static void formatUtcHuman(const DateTime& dt, char* out, size_t outLen) { + snprintf(out, + outLen, + "%04u-%02u-%02u %02u:%02u:%02u UTC", + (unsigned)dt.year, + (unsigned)dt.month, + (unsigned)dt.day, + (unsigned)dt.hour, + (unsigned)dt.minute, + (unsigned)dt.second); +} + +static bool parseUInt2(const char* s, uint8_t& out) { + if (!s || !isdigit((unsigned char)s[0]) || !isdigit((unsigned char)s[1])) { + return false; + } + out = (uint8_t)((s[0] - '0') * 10 + (s[1] - '0')); + return true; +} + +static bool parseNmeaCoordToDecimal(const char* raw, const char* hemi, bool isLat, double& outDeg) { + if (!raw || !hemi || raw[0] == '\0' || hemi[0] == '\0') { + return false; + } + + // NMEA uses ddmm.mmmm (lat) and dddmm.mmmm (lon), with leading zeros preserved. + // Parse from string slices so longitudes like 071xx.xxxx do not collapse to 7xx.xxxx. + int degDigits = isLat ? 2 : 3; + size_t n = strlen(raw); + if (n <= (size_t)degDigits + 2) { + return false; + } + + for (int i = 0; i < degDigits; ++i) { + if (!isdigit((unsigned char)raw[i])) { + return false; + } + } + + char degBuf[4] = {0}; + memcpy(degBuf, raw, degDigits); + int deg = atoi(degBuf); + + const char* minPtr = raw + degDigits; + double minutes = atof(minPtr); + if (minutes < 0.0 || minutes >= 60.0) { + return false; + } + + double dec = (double)deg + (minutes / 60.0); + char h = (char)toupper((unsigned char)hemi[0]); + if (h == 'S' || h == 'W') { + dec = -dec; + } else if (h != 'N' && h != 'E') { + return false; + } + + outDeg = dec; + return true; +} + +static void parseGga(char* fields[], int count) { + if (count <= 7) { + return; + } + const char* latRaw = (count > 2) ? fields[2] : nullptr; + const char* latHem = (count > 3) ? fields[3] : nullptr; + const char* lonRaw = (count > 4) ? fields[4] : nullptr; + const char* lonHem = (count > 5) ? fields[5] : nullptr; + int sats = atoi(fields[7]); + if (sats >= 0 && sats <= 255) { + g_gps.satsUsed = (uint8_t)sats; + } + if (count > 8 && fields[8] && fields[8][0] != '\0') { + g_gps.hdop = (float)atof(fields[8]); + } + if (count > 9 && fields[9] && fields[9][0] != '\0') { + g_gps.altitudeM = (float)atof(fields[9]); + g_gps.hasValidAltitude = true; + } + + // Position fallback from GGA so we still log coordinates if RMC position is missing. + double lat = 0.0; + double lon = 0.0; + if (parseNmeaCoordToDecimal(latRaw, latHem, true, lat) && + parseNmeaCoordToDecimal(lonRaw, lonHem, false, lon)) { + g_gps.latitudeDeg = lat; + g_gps.longitudeDeg = lon; + g_gps.hasValidPosition = true; + } +} + +static void parseGsv(char* fields[], int count) { + if (count <= 3) { + return; + } + int sats = atoi(fields[3]); + if (sats >= 0 && sats <= 255) { + g_gps.satsInView = (uint8_t)sats; + } +} + +static void parseRmc(char* fields[], int count) { + if (count <= 9) { + return; + } + + const char* utc = fields[1]; + const char* status = fields[2]; + const char* latRaw = fields[3]; + const char* latHem = fields[4]; + const char* lonRaw = fields[5]; + const char* lonHem = fields[6]; + const char* date = fields[9]; + + if (!status || status[0] != 'A') { + return; + } + + if (!utc || strlen(utc) < 6 || !date || strlen(date) < 6) { + return; + } + + uint8_t hh = 0, mm = 0, ss = 0; + uint8_t dd = 0, mo = 0, yy = 0; + if (!parseUInt2(utc + 0, hh) || !parseUInt2(utc + 2, mm) || !parseUInt2(utc + 4, ss)) { + return; + } + if (!parseUInt2(date + 0, dd) || !parseUInt2(date + 2, mo) || !parseUInt2(date + 4, yy)) { + return; + } + + g_gps.utc.hour = hh; + g_gps.utc.minute = mm; + g_gps.utc.second = ss; + g_gps.utc.day = dd; + g_gps.utc.month = mo; + g_gps.utc.year = (uint16_t)(2000U + yy); + g_gps.hasValidUtc = true; + g_gps.lastUtcMs = millis(); + + double lat = 0.0; + double lon = 0.0; + if (parseNmeaCoordToDecimal(latRaw, latHem, true, lat) && + parseNmeaCoordToDecimal(lonRaw, lonHem, false, lon)) { + g_gps.latitudeDeg = lat; + g_gps.longitudeDeg = lon; + g_gps.hasValidPosition = true; + } +} + +static void processNmeaLine(char* line) { + if (!line || line[0] != '$') { + return; + } + + g_gps.sawAnySentence = true; + + char* star = strchr(line, '*'); + if (star) { + *star = '\0'; + } + + char* fields[24] = {0}; + int count = 0; + char* saveptr = nullptr; + char* tok = strtok_r(line, ",", &saveptr); + while (tok && count < 24) { + fields[count++] = tok; + tok = strtok_r(nullptr, ",", &saveptr); + } + + if (count <= 0 || !fields[0]) { + return; + } + + const char* header = fields[0]; + size_t n = strlen(header); + if (n < 6) { + return; + } + + const char* type = header + (n - 3); + if (strcmp(type, "GGA") == 0) { + parseGga(fields, count); + } else if (strcmp(type, "GSV") == 0) { + parseGsv(fields, count); + } else if (strcmp(type, "RMC") == 0) { + parseRmc(fields, count); + } +} + +static void pollGpsSerial() { + while (g_gpsSerial.available() > 0) { + char c = (char)g_gpsSerial.read(); + if (c == '\r') { + continue; + } + if (c == '\n') { + if (g_gpsLineLen > 0) { + g_gpsLine[g_gpsLineLen] = '\0'; + processNmeaLine(g_gpsLine); + g_gpsLineLen = 0; + } + continue; + } + + if (g_gpsLineLen + 1 < sizeof(g_gpsLine)) { + g_gpsLine[g_gpsLineLen++] = c; + } else { + g_gpsLineLen = 0; + } + } +} + +static bool collectGpsTraffic(uint32_t windowMs) { + uint32_t start = millis(); + bool sawBytes = false; + while ((uint32_t)(millis() - start) < windowMs) { + if (g_gpsSerial.available() > 0) { + sawBytes = true; + } + pollGpsSerial(); + g_sd.update(); + delay(2); + } + return sawBytes || g_gps.sawAnySentence; +} + +static void initialGpsProbe() { + logf("GPS startup probe at %u baud", (unsigned)GPS_BAUD); + (void)collectGpsTraffic(kGpsStartupProbeMs); + logf("GPS probe complete: nmea=%s sats_used=%u sats_view=%u utc=%s", + g_gps.sawAnySentence ? "yes" : "no", + (unsigned)g_gps.satsUsed, + (unsigned)g_gps.satsInView, + g_gps.hasValidUtc ? "yes" : "no"); +} + +static IRAM_ATTR void onPpsEdge() { + g_ppsEdgeCount++; +} + +static uint8_t bestSatelliteCount() { + return (g_gps.satsUsed > g_gps.satsInView) ? g_gps.satsUsed : g_gps.satsInView; +} + +static bool ensureGpsLogPathReady() { + if (!g_sd.isMounted()) { + g_gpsPathReady = false; + return false; + } + + if (g_gpsPathReady) { + return true; + } + + if (!g_sd.ensureDirRecursive("/gps")) { + logf("Could not create /gps directory"); + return false; + } + + // Touch the log file so a clean SD card is prepared before first discipline event. + File f = SD.open("/gps/discipline_rtc.log", FILE_APPEND); + if (!f) { + logf("Could not open /gps/discipline_rtc.log for append"); + return false; + } + f.close(); + + g_gpsPathReady = true; + return true; +} + +static bool appendDisciplineLog(const DateTime& gpsUtc, + bool havePriorRtc, + int64_t rtcMinusGpsSeconds, + uint8_t sats, + uint32_t utcAgeMs, + uint32_t ppsEdges, + char* outTs, + size_t outTsLen) { + if (!ensureGpsLogPathReady()) { + logf("SD not mounted, skipping append to gps/discipline_rtc.log"); + return false; + } + + File f = SD.open("/gps/discipline_rtc.log", FILE_APPEND); + if (!f) { + logf("Could not open /gps/discipline_rtc.log for append"); + return false; + } + + char ts[32]; + formatUtcCompact(gpsUtc, ts, sizeof(ts)); + if (outTs && outTsLen > 0) { + snprintf(outTs, outTsLen, "%s", ts); + } + + char drift[40]; + if (havePriorRtc) { + snprintf(drift, sizeof(drift), "%+lld s", (long long)rtcMinusGpsSeconds); + } else { + snprintf(drift, sizeof(drift), "RTC_unset"); + } + + char pos[64]; + if (g_gps.hasValidPosition) { + snprintf(pos, sizeof(pos), "lat=%.6f lon=%.6f", g_gps.latitudeDeg, g_gps.longitudeDeg); + } else { + snprintf(pos, sizeof(pos), "lat=NA lon=NA"); + } + + char hdop[16]; + if (g_gps.hdop > 0.0f) { + snprintf(hdop, sizeof(hdop), "%.1f", g_gps.hdop); + } else { + snprintf(hdop, sizeof(hdop), "NA"); + } + + char alt[16]; + if (g_gps.hasValidAltitude) { + snprintf(alt, sizeof(alt), "%.1f", g_gps.altitudeM); + } else { + snprintf(alt, sizeof(alt), "NA"); + } + + char line[320]; + snprintf(line, + sizeof(line), + "%s\t set RTC to GPS using 1PPS pulse-per-second discipline\t" + "rtc-gps drift=%s; sats=%u; %s; alt_m=%s; hdop=%s; utc_age_ms=%lu; pps_edges=%lu; " + "fw_epoch=%lu; fw_build_utc=%s", + ts, + drift, + (unsigned)sats, + pos, + alt, + hdop, + (unsigned long)utcAgeMs, + (unsigned long)ppsEdges, + (unsigned long)FW_BUILD_EPOCH, + FW_BUILD_UTC); + + size_t wrote = f.println(line); + f.close(); + if (wrote == 0) { + logf("Append write failed: /gps/discipline_rtc.log"); + return false; + } + return true; +} + +static bool gpsUtcIsFresh() { + if (!g_gps.hasValidUtc) { + return false; + } + return (uint32_t)(millis() - g_gps.lastUtcMs) <= 2000; +} + +static bool waitForNextPps(uint32_t timeoutMs) { + uint32_t startCount = g_ppsEdgeCount; + uint32_t startMs = millis(); + while ((uint32_t)(millis() - startMs) < timeoutMs) { + pollGpsSerial(); + g_sd.update(); + if (g_ppsEdgeCount != startCount) { + return true; + } + delay(2); + } + return false; +} + +static void waitWithUpdates(uint32_t delayMs) { + uint32_t start = millis(); + while ((uint32_t)(millis() - start) < delayMs) { + pollGpsSerial(); + g_sd.update(); + delay(10); + } +} + +static void showNoTimeAndDelay() { + uint8_t sats = bestSatelliteCount(); + char l3[24]; + snprintf(l3, sizeof(l3), "Satellites: %u", (unsigned)sats); + oledShowLines("GPS time unavailable", "RTC NOT disciplined", l3, "Retry in 30 seconds"); + logf("GPS UTC unavailable. satellites=%u. Waiting 30 seconds.", (unsigned)sats); + waitWithUpdates(kNoTimeDelayMs); +} + +static bool disciplineRtcToGps() { + if (!gpsUtcIsFresh()) { + showNoTimeAndDelay(); + return false; + } + + DateTime priorRtc{}; + bool lowV = false; + bool havePriorRtc = rtcRead(priorRtc, lowV); + if (havePriorRtc && (lowV || !isValidDateTime(priorRtc))) { + havePriorRtc = false; + } + + DateTime gpsSnap = g_gps.utc; + if (!waitForNextPps(kPpsWaitTimeoutMs)) { + oledShowLines("GPS 1PPS missing", "RTC NOT disciplined", "Retry in 30 seconds"); + logf("No 1PPS edge observed within timeout. Waiting 30 seconds."); + waitWithUpdates(kNoTimeDelayMs); + return false; + } + + DateTime target = gpsSnap; + addOneSecond(target); + + if (!rtcWrite(target)) { + oledShowLines("RTC write failed", "Could not set from GPS"); + logf("RTC write failed"); + return false; + } + + int64_t driftSec = 0; + if (havePriorRtc) { + driftSec = toEpochSeconds(priorRtc) - toEpochSeconds(target); + } + + uint8_t sats = bestSatelliteCount(); + uint32_t utcAgeMs = (uint32_t)(millis() - g_gps.lastUtcMs); + uint32_t ppsEdges = g_ppsEdgeCount; + char tsCompact[32]; + bool logOk = appendDisciplineLog(target, + havePriorRtc, + driftSec, + sats, + utcAgeMs, + ppsEdges, + tsCompact, + sizeof(tsCompact)); + + char utcLine[36]; + char driftLine[36]; + char logLine[36]; + formatUtcHuman(target, utcLine, sizeof(utcLine)); + if (havePriorRtc) { + snprintf(driftLine, sizeof(driftLine), "rtc-gps drift: %+lld s", (long long)driftSec); + } else { + snprintf(driftLine, sizeof(driftLine), "rtc-gps drift: RTC_unset"); + } + snprintf(logLine, sizeof(logLine), "Log:%s sats:%u", logOk ? "ok" : "fail", (unsigned)sats); + + oledShowLines("RTC disciplined to GPS", utcLine, driftLine, logLine, tsCompact); + + logf("RTC disciplined to GPS with 1PPS. %s drift=%+llds lowV=%s", + utcLine, + (long long)driftSec, + lowV ? "yes" : "no"); + return true; +} + +void setup() { + Serial.begin(115200); + delay(kSerialDelayMs); + + Serial.println("\r\n=================================================="); + Serial.println("Exercise 11: Set RTC to GPS with 1PPS discipline"); + Serial.println("=================================================="); + + if (!tbeam_supreme::initPmuForPeripherals(g_pmu, &Serial)) { + logf("PMU init failed"); + } + + Wire.begin(OLED_SDA, OLED_SCL); + g_oled.setI2CAddress(OLED_ADDR << 1); + g_oled.begin(); + oledShowLines("Exercise 11", "RTC <- GPS (1PPS)", "Booting..."); + + SdWatcherConfig sdCfg{}; + if (!g_sd.begin(sdCfg, nullptr)) { + logf("SD startup manager begin() failed"); + } + (void)ensureGpsLogPathReady(); + +#ifdef GPS_WAKEUP_PIN + pinMode(GPS_WAKEUP_PIN, INPUT); +#endif +#ifdef GPS_1PPS_PIN + pinMode(GPS_1PPS_PIN, INPUT); + attachInterrupt(digitalPinToInterrupt(GPS_1PPS_PIN), onPpsEdge, RISING); +#endif + + g_gpsSerial.setRxBufferSize(1024); + g_gpsSerial.begin(GPS_BAUD, SERIAL_8N1, GPS_RX_PIN, GPS_TX_PIN); + logf("GPS UART started: RX=%d TX=%d baud=%u", GPS_RX_PIN, GPS_TX_PIN, (unsigned)GPS_BAUD); + + oledShowLines("GPS startup probe", "Checking UTC + 1PPS"); + initialGpsProbe(); + + g_nextDisciplineMs = millis(); +} + +void loop() { + pollGpsSerial(); + g_sd.update(); + + if (g_sd.consumeMountedEvent()) { + g_gpsPathReady = false; + (void)ensureGpsLogPathReady(); + } + if (g_sd.consumeRemovedEvent()) { + g_gpsPathReady = false; + } + + uint32_t now = millis(); + if ((int32_t)(now - g_nextDisciplineMs) >= 0) { + bool ok = disciplineRtcToGps(); + g_nextDisciplineMs = now + (ok ? kLoopMsDiscipline : kNoTimeDelayMs); + } + + delay(5); +} diff --git a/exercises/12_FiveTalk/READEME.md b/exercises/12_FiveTalk/READEME.md new file mode 100644 index 0000000..44d86d5 --- /dev/null +++ b/exercises/12_FiveTalk/READEME.md @@ -0,0 +1,20 @@ + + +main.cpp needs to be modified to reflect the number of units. It is a zero-based array, so for 7 possible unite, the value of 6 is used in both lines below: + + #if (NODE_SLOT_INDEX < 0) || (NODE_SLOT_INDEX > 6) + #error "NODE_SLOT_INDEX must be 0..6" + #endif + + + +INSERT SCREENSHOT HERE. + +To compile and load: + + time pio run -e flo -t upload --upload-port /dev/ttytFLO + +To monitor (replace with appropriate unit name) for Exercise 12: + + pio device monitor -d /usr/local/src/microreticulum/microReticulumTbeam/exercises/12_FiveTalk -e flo --port /dev/ttytFLO + diff --git a/exercises/12_FiveTalk/lib/startup_sd/StartupSdManager.cpp b/exercises/12_FiveTalk/lib/startup_sd/StartupSdManager.cpp new file mode 100644 index 0000000..1e8791c --- /dev/null +++ b/exercises/12_FiveTalk/lib/startup_sd/StartupSdManager.cpp @@ -0,0 +1,360 @@ +#include "StartupSdManager.h" + +#include +#include "driver/gpio.h" + +StartupSdManager::StartupSdManager(Print& serial) : serial_(serial) {} + +bool StartupSdManager::begin(const SdWatcherConfig& cfg, SdStatusCallback callback) { + cfg_ = cfg; + callback_ = callback; + + forceSpiDeselected(); + dumpSdPins("very-early"); + + if (!initPmuForSdPower()) { + return false; + } + + cycleSdRail(); + delay(cfg_.startupWarmupMs); + + bool warmMounted = false; + for (uint8_t i = 0; i < 3; ++i) { + if (mountPreferred(false)) { + warmMounted = true; + break; + } + delay(200); + } + + // Some cards need a longer power/settle window after cold boot. + // Before declaring ABSENT, retry with extended settle and a full scan. + if (!warmMounted) { + logf("Watcher: startup preferred mount failed, retrying with extended settle"); + cycleSdRail(400, 1200); + delay(cfg_.startupWarmupMs + 1500); + warmMounted = mountCardFullScan(); + } + + if (warmMounted) { + setStateMounted(); + } else { + setStateAbsent(); + } + return true; +} + +void StartupSdManager::update() { + const uint32_t now = millis(); + const uint32_t pollInterval = + (watchState_ == SdWatchState::MOUNTED) ? cfg_.pollIntervalMountedMs : cfg_.pollIntervalAbsentMs; + + if ((uint32_t)(now - lastPollMs_) < pollInterval) { + return; + } + lastPollMs_ = now; + + if (watchState_ == SdWatchState::MOUNTED) { + if (verifyMountedCard()) { + presentVotes_ = 0; + absentVotes_ = 0; + return; + } + + if (mountPreferred(false) && verifyMountedCard()) { + presentVotes_ = 0; + absentVotes_ = 0; + return; + } + + absentVotes_++; + presentVotes_ = 0; + if (absentVotes_ >= cfg_.votesToAbsent) { + setStateAbsent(); + absentVotes_ = 0; + } + return; + } + + bool mounted = mountPreferred(false); + if (!mounted && (uint32_t)(now - lastFullScanMs_) >= cfg_.fullScanIntervalMs) { + lastFullScanMs_ = now; + if (cfg_.recoveryRailCycleOnFullScan) { + logf("Watcher: recovery rail cycle before full scan"); + cycleSdRail(cfg_.recoveryRailOffMs, cfg_.recoveryRailOnSettleMs); + delay(150); + } + logf("Watcher: preferred probe failed, running full scan"); + mounted = mountCardFullScan(); + } + + if (mounted) { + presentVotes_++; + absentVotes_ = 0; + if (presentVotes_ >= cfg_.votesToPresent) { + setStateMounted(); + presentVotes_ = 0; + } + } else { + absentVotes_++; + presentVotes_ = 0; + if (absentVotes_ >= cfg_.votesToAbsent) { + setStateAbsent(); + absentVotes_ = 0; + } + } +} + +bool StartupSdManager::consumeMountedEvent() { + bool out = mountedEventPending_; + mountedEventPending_ = false; + return out; +} + +bool StartupSdManager::consumeRemovedEvent() { + bool out = removedEventPending_; + removedEventPending_ = false; + return out; +} + +void StartupSdManager::logf(const char* fmt, ...) { + char msg[196]; + va_list args; + va_start(args, fmt); + vsnprintf(msg, sizeof(msg), fmt, args); + va_end(args); + serial_.printf("[%10lu][%06lu] %s\r\n", + (unsigned long)millis(), + (unsigned long)logSeq_++, + msg); +} + +void StartupSdManager::notify(SdEvent event, const char* message) { + if (callback_ != nullptr) { + callback_(event, message); + } +} + +void StartupSdManager::forceSpiDeselected() { + pinMode(tbeam_supreme::sdCs(), OUTPUT); + digitalWrite(tbeam_supreme::sdCs(), HIGH); + pinMode(tbeam_supreme::imuCs(), OUTPUT); + digitalWrite(tbeam_supreme::imuCs(), HIGH); +} + +void StartupSdManager::dumpSdPins(const char* tag) { + if (!cfg_.enablePinDumps) { + (void)tag; + return; + } + + const gpio_num_t cs = (gpio_num_t)tbeam_supreme::sdCs(); + const gpio_num_t sck = (gpio_num_t)tbeam_supreme::sdSck(); + const gpio_num_t miso = (gpio_num_t)tbeam_supreme::sdMiso(); + const gpio_num_t mosi = (gpio_num_t)tbeam_supreme::sdMosi(); + logf("PINS(%s): CS=%d SCK=%d MISO=%d MOSI=%d", + tag, gpio_get_level(cs), gpio_get_level(sck), gpio_get_level(miso), gpio_get_level(mosi)); +} + +bool StartupSdManager::initPmuForSdPower() { + if (!tbeam_supreme::initPmuForPeripherals(pmu_, &serial_)) { + logf("ERROR: PMU init failed"); + return false; + } + return true; +} + +void StartupSdManager::cycleSdRail(uint32_t offMs, uint32_t onSettleMs) { + if (!cfg_.enableSdRailCycle) { + return; + } + if (!pmu_) { + logf("SD rail cycle skipped: pmu=null"); + return; + } + + forceSpiDeselected(); + pmu_->disablePowerOutput(XPOWERS_BLDO1); + delay(offMs); + pmu_->setPowerChannelVoltage(XPOWERS_BLDO1, 3300); + pmu_->enablePowerOutput(XPOWERS_BLDO1); + delay(onSettleMs); +} + +bool StartupSdManager::tryMountWithBus(SPIClass& bus, const char* busName, uint32_t hz, bool verbose) { + SD.end(); + bus.end(); + delay(10); + forceSpiDeselected(); + + bus.begin(tbeam_supreme::sdSck(), tbeam_supreme::sdMiso(), tbeam_supreme::sdMosi(), tbeam_supreme::sdCs()); + digitalWrite(tbeam_supreme::sdCs(), HIGH); + delay(2); + for (int i = 0; i < 10; i++) { + bus.transfer(0xFF); + } + delay(2); + + if (verbose) { + logf("SD: trying bus=%s freq=%lu Hz", busName, (unsigned long)hz); + } + + if (!SD.begin(tbeam_supreme::sdCs(), bus, hz)) { + if (verbose) { + logf("SD: mount failed (possible non-FAT format, power, or bus issue)"); + } + return false; + } + + if (SD.cardType() == CARD_NONE) { + SD.end(); + return false; + } + + sdSpi_ = &bus; + sdBusName_ = busName; + sdFreq_ = hz; + return true; +} + +bool StartupSdManager::mountPreferred(bool verbose) { + return tryMountWithBus(sdSpiH_, "HSPI", 400000, verbose); +} + +bool StartupSdManager::mountCardFullScan() { + const uint32_t freqs[] = {400000, 1000000, 4000000, 10000000}; + + for (uint8_t i = 0; i < (sizeof(freqs) / sizeof(freqs[0])); ++i) { + if (tryMountWithBus(sdSpiH_, "HSPI", freqs[i], true)) { + logf("SD: card detected and mounted"); + return true; + } + } + for (uint8_t i = 0; i < (sizeof(freqs) / sizeof(freqs[0])); ++i) { + if (tryMountWithBus(sdSpiF_, "FSPI", freqs[i], true)) { + logf("SD: card detected and mounted"); + return true; + } + } + + logf("SD: begin() failed on all bus/frequency attempts"); + return false; +} + +bool StartupSdManager::verifyMountedCard() { + File root = SD.open("/", FILE_READ); + if (!root) { + return false; + } + root.close(); + return true; +} + +const char* StartupSdManager::cardTypeToString(uint8_t type) { + switch (type) { + case CARD_MMC: + return "MMC"; + case CARD_SD: + return "SDSC"; + case CARD_SDHC: + return "SDHC/SDXC"; + default: + return "UNKNOWN"; + } +} + +void StartupSdManager::printCardInfo() { + uint8_t cardType = SD.cardType(); + uint64_t cardSizeMB = SD.cardSize() / (1024ULL * 1024ULL); + uint64_t totalMB = SD.totalBytes() / (1024ULL * 1024ULL); + uint64_t usedMB = SD.usedBytes() / (1024ULL * 1024ULL); + + logf("SD type: %s", cardTypeToString(cardType)); + logf("SD size: %llu MB", cardSizeMB); + logf("FS total: %llu MB", totalMB); + logf("FS used : %llu MB", usedMB); + logf("SPI bus: %s @ %lu Hz", sdBusName_, (unsigned long)sdFreq_); +} + +bool StartupSdManager::ensureDirRecursive(const char* path) { + String full(path); + if (!full.startsWith("/")) { + full = "/" + full; + } + + int start = 1; + while (start > 0 && start < (int)full.length()) { + int slash = full.indexOf('/', start); + String partial = (slash < 0) ? full : full.substring(0, slash); + if (!SD.exists(partial.c_str()) && !SD.mkdir(partial.c_str())) { + logf("ERROR: mkdir failed for %s", partial.c_str()); + return false; + } + if (slash < 0) { + break; + } + start = slash + 1; + } + + return true; +} + +bool StartupSdManager::rewriteFile(const char* path, const char* payload) { + if (SD.exists(path) && !SD.remove(path)) { + logf("ERROR: failed to erase %s", path); + return false; + } + + File f = SD.open(path, FILE_WRITE); + if (!f) { + logf("ERROR: failed to create %s", path); + return false; + } + + size_t wrote = f.println(payload); + f.close(); + if (wrote == 0) { + logf("ERROR: write failed for %s", path); + return false; + } + return true; +} + +void StartupSdManager::permissionsDemo(const char* path) { + logf("Permissions demo: FAT has no Unix chmod/chown, use open mode only."); + File r = SD.open(path, FILE_READ); + if (!r) { + logf("Could not open %s as FILE_READ", path); + return; + } + size_t writeInReadMode = r.print("attempt write while opened read-only"); + if (writeInReadMode == 0) { + logf("As expected, FILE_READ write was blocked."); + } else { + logf("NOTE: FILE_READ write returned %u (unexpected)", (unsigned)writeInReadMode); + } + r.close(); +} + +void StartupSdManager::setStateMounted() { + if (watchState_ != SdWatchState::MOUNTED) { + logf("EVENT: card inserted/mounted"); + mountedEventPending_ = true; + notify(SdEvent::CARD_MOUNTED, "SD card mounted"); + } + watchState_ = SdWatchState::MOUNTED; +} + +void StartupSdManager::setStateAbsent() { + if (watchState_ == SdWatchState::MOUNTED) { + logf("EVENT: card removed/unavailable"); + removedEventPending_ = true; + notify(SdEvent::CARD_REMOVED, "SD card removed"); + } else if (watchState_ != SdWatchState::ABSENT) { + logf("EVENT: no card detected"); + notify(SdEvent::NO_CARD, "Missing SD card or invalid FAT16/FAT32 format"); + } + SD.end(); + watchState_ = SdWatchState::ABSENT; +} diff --git a/exercises/12_FiveTalk/lib/startup_sd/StartupSdManager.h b/exercises/12_FiveTalk/lib/startup_sd/StartupSdManager.h new file mode 100644 index 0000000..be9ef27 --- /dev/null +++ b/exercises/12_FiveTalk/lib/startup_sd/StartupSdManager.h @@ -0,0 +1,90 @@ +#pragma once + +#include +#include +#include +#include +#include "tbeam_supreme_adapter.h" + +enum class SdWatchState : uint8_t { + UNKNOWN = 0, + ABSENT, + MOUNTED +}; + +enum class SdEvent : uint8_t { + NO_CARD, + CARD_MOUNTED, + CARD_REMOVED +}; + +using SdStatusCallback = void (*)(SdEvent event, const char* message); + +struct SdWatcherConfig { + bool enableSdRailCycle = true; + bool enablePinDumps = true; + bool recoveryRailCycleOnFullScan = true; + uint32_t recoveryRailOffMs = 250; + uint32_t recoveryRailOnSettleMs = 700; + uint32_t startupWarmupMs = 1500; + uint32_t pollIntervalAbsentMs = 1000; + uint32_t pollIntervalMountedMs = 2000; + uint32_t fullScanIntervalMs = 10000; + uint8_t votesToPresent = 2; + uint8_t votesToAbsent = 5; +}; + +class StartupSdManager { + public: + explicit StartupSdManager(Print& serial = Serial); + + bool begin(const SdWatcherConfig& cfg, SdStatusCallback callback = nullptr); + void update(); + + bool isMounted() const { return watchState_ == SdWatchState::MOUNTED; } + SdWatchState state() const { return watchState_; } + + bool consumeMountedEvent(); + bool consumeRemovedEvent(); + + void printCardInfo(); + bool ensureDirRecursive(const char* path); + bool rewriteFile(const char* path, const char* payload); + void permissionsDemo(const char* path); + + private: + void logf(const char* fmt, ...); + void notify(SdEvent event, const char* message); + void forceSpiDeselected(); + void dumpSdPins(const char* tag); + bool initPmuForSdPower(); + void cycleSdRail(uint32_t offMs = 250, uint32_t onSettleMs = 600); + bool tryMountWithBus(SPIClass& bus, const char* busName, uint32_t hz, bool verbose); + bool mountPreferred(bool verbose); + bool mountCardFullScan(); + bool verifyMountedCard(); + const char* cardTypeToString(uint8_t type); + void setStateMounted(); + void setStateAbsent(); + + Print& serial_; + SdWatcherConfig cfg_{}; + SdStatusCallback callback_ = nullptr; + + SPIClass sdSpiH_{HSPI}; + SPIClass sdSpiF_{FSPI}; + SPIClass* sdSpi_ = nullptr; + const char* sdBusName_ = "none"; + uint32_t sdFreq_ = 0; + XPowersLibInterface* pmu_ = nullptr; + + SdWatchState watchState_ = SdWatchState::UNKNOWN; + uint8_t presentVotes_ = 0; + uint8_t absentVotes_ = 0; + uint32_t lastPollMs_ = 0; + uint32_t lastFullScanMs_ = 0; + uint32_t logSeq_ = 0; + + bool mountedEventPending_ = false; + bool removedEventPending_ = false; +}; diff --git a/exercises/12_FiveTalk/lib/startup_sd/library.json b/exercises/12_FiveTalk/lib/startup_sd/library.json new file mode 100644 index 0000000..4978fdd --- /dev/null +++ b/exercises/12_FiveTalk/lib/startup_sd/library.json @@ -0,0 +1,12 @@ +{ + "name": "startup_sd", + "version": "0.1.0", + "dependencies": [ + { + "name": "XPowersLib" + }, + { + "name": "Wire" + } + ] +} diff --git a/exercises/12_FiveTalk/platformio.ini b/exercises/12_FiveTalk/platformio.ini new file mode 100644 index 0000000..84b0b70 --- /dev/null +++ b/exercises/12_FiveTalk/platformio.ini @@ -0,0 +1,100 @@ +; 20260219 ChatGPT +; Exercise 12_FiveTalk + +[platformio] +default_envs = amy + +[env] +platform = espressif32 +framework = arduino +board = esp32-s3-devkitc-1 +monitor_speed = 115200 +extra_scripts = pre:scripts/set_build_epoch.py +lib_deps = + jgromes/RadioLib@^6.6.0 + lewisxhe/XPowersLib@0.3.3 + Wire + olikraus/U8g2@^2.36.4 + +build_flags = + -I ../../shared/boards + -I ../../external/microReticulum_Firmware + -D BOARD_MODEL=BOARD_TBEAM_S_V1 + -D OLED_SDA=17 + -D OLED_SCL=18 + -D OLED_ADDR=0x3C + -D GPS_RX_PIN=9 + -D GPS_TX_PIN=8 + -D GPS_WAKEUP_PIN=7 + -D GPS_1PPS_PIN=6 + -D GPS_L76K + -D NODE_SLOT_COUNT=7 + -D LORA_CS=10 + -D LORA_MOSI=11 + -D LORA_SCK=12 + -D LORA_MISO=13 + -D LORA_RESET=5 + -D LORA_DIO1=1 + -D LORA_BUSY=4 + -D LORA_TCXO_VOLTAGE=1.8 + -D ARDUINO_USB_MODE=1 + -D ARDUINO_USB_CDC_ON_BOOT=1 + +[env:amy] +extends = env +build_flags = + ${env.build_flags} + -D NODE_LABEL=\"Amy\" + -D NODE_SHORT=\"A\" + -D NODE_SLOT_INDEX=0 + +[env:bob] +extends = env +build_flags = + ${env.build_flags} + -D NODE_LABEL=\"Bob\" + -D NODE_SHORT=\"B\" + -D NODE_SLOT_INDEX=1 + +[env:cy] +extends = env +build_flags = + ${env.build_flags} + -D NODE_LABEL=\"Cy\" + -D NODE_SHORT=\"C\" + -D NODE_SLOT_INDEX=2 + +[env:dan] +extends = env +build_flags = + ${env.build_flags} + -D NODE_LABEL=\"Dan\" + -D NODE_SHORT=\"D\" + -D NODE_SLOT_INDEX=3 + +[env:ed] +extends = env +build_flags = + ${env.build_flags} + -D NODE_LABEL=\"Ed\" + -D NODE_SHORT=\"E\" + -D NODE_SLOT_INDEX=4 + +[env:flo] +extends = env +build_flags = + ${env.build_flags} + -D NODE_LABEL=\"Flo\" + -D NODE_SHORT=\"F\" + -D NODE_SLOT_INDEX=5 + +[env:guy] +extends = env +build_flags = + ${env.build_flags} + -D NODE_LABEL=\"Guy\" + -D NODE_SHORT=\"G\" + -D NODE_SLOT_INDEX=6 + -D GPS_UBLOX + + diff --git a/exercises/12_FiveTalk/scripts/set_build_epoch.py b/exercises/12_FiveTalk/scripts/set_build_epoch.py new file mode 100644 index 0000000..3011129 --- /dev/null +++ b/exercises/12_FiveTalk/scripts/set_build_epoch.py @@ -0,0 +1,12 @@ +import time +Import("env") + +epoch = int(time.time()) +utc_tag = time.strftime("%Y%m%d_%H%M%S_z", time.gmtime(epoch)) + +env.Append( + CPPDEFINES=[ + ("FW_BUILD_EPOCH", str(epoch)), + ("FW_BUILD_UTC", '\\"%s\\"' % utc_tag), + ] +) diff --git a/exercises/12_FiveTalk/src/main.cpp b/exercises/12_FiveTalk/src/main.cpp new file mode 100644 index 0000000..b292aab --- /dev/null +++ b/exercises/12_FiveTalk/src/main.cpp @@ -0,0 +1,1348 @@ +// 20260219 ChatGPT +// Exercise 12_FiveTalk + +#include +#include +#include +#include +#include +#include + +#include +#include +#include + +#include "StartupSdManager.h" +#include "tbeam_supreme_adapter.h" + +#ifdef SX1262 +#undef SX1262 +#endif + +#ifndef NODE_LABEL +#define NODE_LABEL "UNNAMED" +#endif + +#ifndef NODE_SHORT +#define NODE_SHORT "?" +#endif + +#ifndef NODE_SLOT_COUNT +#define NODE_SLOT_COUNT 7 +#endif + +#ifndef NODE_SLOT_INDEX +#define NODE_SLOT_INDEX 0 +#endif + +#ifndef OLED_SDA +#define OLED_SDA 17 +#endif + +#ifndef OLED_SCL +#define OLED_SCL 18 +#endif + +#ifndef OLED_ADDR +#define OLED_ADDR 0x3C +#endif + +#ifndef RTC_I2C_ADDR +#define RTC_I2C_ADDR 0x51 +#endif + +#ifndef GPS_BAUD +#define GPS_BAUD 9600 +#endif + +#ifndef FILE_APPEND +#define FILE_APPEND FILE_WRITE +#endif + +#ifndef FW_BUILD_EPOCH +#define FW_BUILD_EPOCH 0 +#endif + +#ifndef FW_BUILD_UTC +#define FW_BUILD_UTC "unknown" +#endif + +#if (NODE_SLOT_INDEX < 0) || (NODE_SLOT_INDEX >= NODE_SLOT_COUNT) +#error "NODE_SLOT_INDEX must be 0..NODE_SLOT_COUNT-1" +#endif + +static const uint32_t kSerialDelayMs = 1000; +static const uint32_t kDisciplineMaxAgeSec = 24UL * 60UL * 60UL; +static const uint32_t kDisciplineRetryMs = 5000; +static const uint32_t kPpsWaitTimeoutMs = 1500; +static const uint32_t kSdMessagePeriodMs = 1200; +static const uint32_t kNoGpsMessagePeriodMs = 1500; +static const uint32_t kHealthCheckPeriodMs = 60000; +static const uint32_t kSlotSeconds = 2; + +static XPowersLibInterface *g_pmu = nullptr; +static StartupSdManager g_sd(Serial); +static U8G2_SH1106_128X64_NONAME_F_HW_I2C g_oled(U8G2_R0, U8X8_PIN_NONE); +static HardwareSerial g_gpsSerial(1); +static SX1262 g_radio = new Module(LORA_CS, LORA_DIO1, LORA_RESET, LORA_BUSY); + +static volatile bool g_rxFlag = false; +static volatile uint32_t g_ppsEdgeCount = 0; + +static uint32_t g_logSeq = 0; +static uint32_t g_lastWarnMs = 0; +static uint32_t g_lastDisciplineTryMs = 0; +static uint32_t g_lastHealthCheckMs = 0; + +static int64_t g_lastDisciplineEpoch = -1; +static int64_t g_lastTxEpochSecond = -1; +static uint32_t g_txCount = 0; +static uint32_t g_txAttemptId = 0; + +static bool g_radioReady = false; +static bool g_sessionReady = false; +static bool g_gpsPathReady = false; + +static char g_sessionStamp[20] = {0}; +static char g_sentPath[64] = {0}; +static char g_recvPath[64] = {0}; + +static File g_sentFile; +static File g_recvFile; + +static char g_gpsLine[128]; +static size_t g_gpsLineLen = 0; + +struct DateTime +{ + uint16_t year; + uint8_t month; + uint8_t day; + uint8_t hour; + uint8_t minute; + uint8_t second; +}; + +struct GpsState +{ + bool sawAnySentence = false; + bool hasValidUtc = false; + bool hasValidPosition = false; + bool hasValidAltitude = false; + uint8_t satsUsed = 0; + uint8_t satsInView = 0; + uint32_t lastUtcMs = 0; + DateTime utc{}; + double latitudeDeg = 0.0; + double longitudeDeg = 0.0; + float altitudeM = 0.0f; +}; + +static GpsState g_gps; + +static void parsePayloadFields(const char *msg, + char *txIdOut, size_t txIdLen, + char *latOut, size_t latLen, + char *lonOut, size_t lonLen, + char *altOut, size_t altLen); + +enum class AppPhase : uint8_t +{ + WAIT_SD = 0, + WAIT_DISCIPLINE, + RUN +}; + +static AppPhase g_phase = AppPhase::WAIT_SD; + +static uint8_t bestSatelliteCount() +{ + return (g_gps.satsUsed > g_gps.satsInView) ? g_gps.satsUsed : g_gps.satsInView; +} + +static uint32_t computeFrameSeconds(uint32_t requiredSeconds) +{ + uint32_t frame = ((requiredSeconds + 4U) / 5U) * 5U; // round up to 5s + while (frame <= 60U && (60U % frame) != 0U) + { + frame += 5U; + } + if (frame == 0U || frame > 60U) + { + frame = 60U; // fallback + } + return frame; +} + +static void logf(const char *fmt, ...) +{ + char msg[256]; + va_list args; + va_start(args, fmt); + vsnprintf(msg, sizeof(msg), fmt, args); + va_end(args); + Serial.printf("[%10lu][%06lu] %s\r\n", (unsigned long)millis(), (unsigned long)g_logSeq++, msg); +} + +static void oledShowLines(const char *l1, + const char *l2 = nullptr, + const char *l3 = nullptr, + const char *l4 = nullptr, + const char *l5 = nullptr) +{ + g_oled.clearBuffer(); + g_oled.setFont(u8g2_font_5x8_tf); + if (l1) + g_oled.drawUTF8(0, 12, l1); + if (l2) + g_oled.drawUTF8(0, 24, l2); + if (l3) + g_oled.drawUTF8(0, 36, l3); + if (l4) + g_oled.drawUTF8(0, 48, l4); + if (l5) + g_oled.drawUTF8(0, 60, l5); + g_oled.sendBuffer(); +} + +static uint8_t toBcd(uint8_t v) +{ + return (uint8_t)(((v / 10U) << 4U) | (v % 10U)); +} + +static uint8_t fromBcd(uint8_t b) +{ + return (uint8_t)(((b >> 4U) * 10U) + (b & 0x0FU)); +} + +static bool isLeapYear(uint16_t y) +{ + return ((y % 4U) == 0U && (y % 100U) != 0U) || ((y % 400U) == 0U); +} + +static uint8_t daysInMonth(uint16_t year, uint8_t month) +{ + static const uint8_t kDays[12] = {31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31}; + if (month == 2) + return (uint8_t)(isLeapYear(year) ? 29 : 28); + if (month >= 1 && month <= 12) + return kDays[month - 1]; + return 30; +} + +static bool isValidDateTime(const DateTime &dt) +{ + if (dt.year < 2000U || dt.year > 2099U) + return false; + if (dt.month < 1 || dt.month > 12) + return false; + if (dt.day < 1 || dt.day > daysInMonth(dt.year, dt.month)) + return false; + if (dt.hour > 23 || dt.minute > 59 || dt.second > 59) + return false; + return true; +} + +static int64_t daysFromCivil(int y, unsigned m, unsigned d) +{ + y -= (m <= 2); + const int era = (y >= 0 ? y : y - 399) / 400; + const unsigned yoe = (unsigned)(y - era * 400); + const unsigned doy = (153 * (m + (m > 2 ? (unsigned)-3 : 9)) + 2) / 5 + d - 1; + const unsigned doe = yoe * 365 + yoe / 4 - yoe / 100 + doy; + return era * 146097 + (int)doe - 719468; +} + +static int64_t toEpochSeconds(const DateTime &dt) +{ + int64_t days = daysFromCivil((int)dt.year, dt.month, dt.day); + return days * 86400LL + (int64_t)dt.hour * 3600LL + (int64_t)dt.minute * 60LL + (int64_t)dt.second; +} + +static bool fromEpochSeconds(int64_t sec, DateTime &out) +{ + if (sec < 0) + return false; + + int64_t days = sec / 86400LL; + int64_t rem = sec % 86400LL; + if (rem < 0) + { + rem += 86400LL; + days -= 1; + } + + out.hour = (uint8_t)(rem / 3600LL); + rem %= 3600LL; + out.minute = (uint8_t)(rem / 60LL); + out.second = (uint8_t)(rem % 60LL); + + days += 719468; + const int era = (days >= 0 ? days : days - 146096) / 146097; + const unsigned doe = (unsigned)(days - era * 146097); + const unsigned yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365; + int y = (int)yoe + era * 400; + const unsigned doy = doe - (365 * yoe + yoe / 4 - yoe / 100); + const unsigned mp = (5 * doy + 2) / 153; + const unsigned d = doy - (153 * mp + 2) / 5 + 1; + const unsigned m = mp + (mp < 10 ? 3 : (unsigned)-9); + y += (m <= 2); + + out.year = (uint16_t)y; + out.month = (uint8_t)m; + out.day = (uint8_t)d; + return isValidDateTime(out); +} + +static bool rtcRead(DateTime &out, bool &lowVoltageFlag) +{ + Wire1.beginTransmission(RTC_I2C_ADDR); + Wire1.write(0x02); + if (Wire1.endTransmission(false) != 0) + return false; + + const uint8_t need = 7; + uint8_t got = Wire1.requestFrom((int)RTC_I2C_ADDR, (int)need); + if (got != need) + return false; + + uint8_t sec = Wire1.read(); + uint8_t min = Wire1.read(); + uint8_t hour = Wire1.read(); + uint8_t day = Wire1.read(); + (void)Wire1.read(); + uint8_t month = Wire1.read(); + uint8_t year = Wire1.read(); + + lowVoltageFlag = (sec & 0x80U) != 0; + out.second = fromBcd(sec & 0x7FU); + out.minute = fromBcd(min & 0x7FU); + out.hour = fromBcd(hour & 0x3FU); + out.day = fromBcd(day & 0x3FU); + out.month = fromBcd(month & 0x1FU); + + uint8_t yy = fromBcd(year); + bool century = (month & 0x80U) != 0; + out.year = century ? (1900U + yy) : (2000U + yy); + return true; +} + +static bool rtcWrite(const DateTime &dt) +{ + Wire1.beginTransmission(RTC_I2C_ADDR); + Wire1.write(0x02); + Wire1.write(toBcd(dt.second & 0x7FU)); + Wire1.write(toBcd(dt.minute)); + Wire1.write(toBcd(dt.hour)); + Wire1.write(toBcd(dt.day)); + Wire1.write(0x00); + + uint8_t monthReg = toBcd(dt.month); + if (dt.year < 2000U) + monthReg |= 0x80U; + Wire1.write(monthReg); + Wire1.write(toBcd((uint8_t)(dt.year % 100U))); + + return Wire1.endTransmission() == 0; +} + +static bool getCurrentUtc(DateTime &dt, int64_t &epoch) +{ + bool lowV = false; + if (!rtcRead(dt, lowV)) + return false; + if (lowV || !isValidDateTime(dt)) + return false; + epoch = toEpochSeconds(dt); + return true; +} + +static void formatUtcHuman(const DateTime &dt, char *out, size_t outLen) +{ + snprintf(out, outLen, "%04u-%02u-%02u %02u:%02u:%02u UTC", + (unsigned)dt.year, + (unsigned)dt.month, + (unsigned)dt.day, + (unsigned)dt.hour, + (unsigned)dt.minute, + (unsigned)dt.second); +} + +static void formatUtcCompact(const DateTime &dt, char *out, size_t outLen) +{ + snprintf(out, + outLen, + "%04u%02u%02u_%02u%02u%02u", + (unsigned)dt.year, + (unsigned)dt.month, + (unsigned)dt.day, + (unsigned)dt.hour, + (unsigned)dt.minute, + (unsigned)dt.second); +} + +static bool parseUInt2(const char *s, uint8_t &out) +{ + if (!s || !isdigit((unsigned char)s[0]) || !isdigit((unsigned char)s[1])) + return false; + out = (uint8_t)((s[0] - '0') * 10 + (s[1] - '0')); + return true; +} + +static bool parseNmeaCoordToDecimal(const char *raw, const char *hemi, bool isLat, double &outDeg) +{ + if (!raw || !hemi || raw[0] == '\0' || hemi[0] == '\0') + return false; + + int degDigits = isLat ? 2 : 3; + size_t n = strlen(raw); + if (n <= (size_t)degDigits + 2) + return false; + + for (int i = 0; i < degDigits; ++i) + { + if (!isdigit((unsigned char)raw[i])) + return false; + } + + char degBuf[4] = {0}; + memcpy(degBuf, raw, degDigits); + int deg = atoi(degBuf); + + const char *minPtr = raw + degDigits; + double minutes = atof(minPtr); + if (minutes < 0.0 || minutes >= 60.0) + return false; + + double dec = (double)deg + (minutes / 60.0); + char h = (char)toupper((unsigned char)hemi[0]); + if (h == 'S' || h == 'W') + { + dec = -dec; + } + else if (h != 'N' && h != 'E') + { + return false; + } + + outDeg = dec; + return true; +} + +static void parseRmc(char *fields[], int count) +{ + if (count <= 9) + return; + + const char *utc = fields[1]; + const char *status = fields[2]; + const char *latRaw = (count > 3) ? fields[3] : nullptr; + const char *latHem = (count > 4) ? fields[4] : nullptr; + const char *lonRaw = (count > 5) ? fields[5] : nullptr; + const char *lonHem = (count > 6) ? fields[6] : nullptr; + const char *date = fields[9]; + + if (!status || status[0] != 'A') + return; + if (!utc || !date || strlen(utc) < 6 || strlen(date) < 6) + return; + + uint8_t hh = 0, mm = 0, ss = 0; + uint8_t dd = 0, mo = 0, yy = 0; + if (!parseUInt2(utc + 0, hh) || !parseUInt2(utc + 2, mm) || !parseUInt2(utc + 4, ss)) + return; + if (!parseUInt2(date + 0, dd) || !parseUInt2(date + 2, mo) || !parseUInt2(date + 4, yy)) + return; + + g_gps.utc.hour = hh; + g_gps.utc.minute = mm; + g_gps.utc.second = ss; + g_gps.utc.day = dd; + g_gps.utc.month = mo; + g_gps.utc.year = (uint16_t)(2000U + yy); + g_gps.hasValidUtc = true; + g_gps.lastUtcMs = millis(); + + double lat = 0.0; + double lon = 0.0; + if (parseNmeaCoordToDecimal(latRaw, latHem, true, lat) && + parseNmeaCoordToDecimal(lonRaw, lonHem, false, lon)) + { + g_gps.latitudeDeg = lat; + g_gps.longitudeDeg = lon; + g_gps.hasValidPosition = true; + } +} + +static void parseGga(char *fields[], int count) +{ + if (count <= 7) + return; + const char *latRaw = (count > 2) ? fields[2] : nullptr; + const char *latHem = (count > 3) ? fields[3] : nullptr; + const char *lonRaw = (count > 4) ? fields[4] : nullptr; + const char *lonHem = (count > 5) ? fields[5] : nullptr; + int sats = atoi(fields[7]); + if (sats >= 0 && sats <= 255) + g_gps.satsUsed = (uint8_t)sats; + + if (count > 9 && fields[9] && fields[9][0] != '\0') + { + g_gps.altitudeM = (float)atof(fields[9]); + g_gps.hasValidAltitude = true; + } + + double lat = 0.0; + double lon = 0.0; + if (parseNmeaCoordToDecimal(latRaw, latHem, true, lat) && + parseNmeaCoordToDecimal(lonRaw, lonHem, false, lon)) + { + g_gps.latitudeDeg = lat; + g_gps.longitudeDeg = lon; + g_gps.hasValidPosition = true; + } +} + +static void parseGsv(char *fields[], int count) +{ + if (count <= 3) + return; + int sats = atoi(fields[3]); + if (sats >= 0 && sats <= 255) + g_gps.satsInView = (uint8_t)sats; +} + +static void processNmeaLine(char *line) +{ + if (!line || line[0] != '$') + return; + + g_gps.sawAnySentence = true; + char *star = strchr(line, '*'); + if (star) + *star = '\0'; + + char *fields[24] = {0}; + int count = 0; + char *saveptr = nullptr; + char *tok = strtok_r(line, ",", &saveptr); + while (tok && count < 24) + { + fields[count++] = tok; + tok = strtok_r(nullptr, ",", &saveptr); + } + if (count == 0 || !fields[0]) + return; + + const char *header = fields[0]; + size_t n = strlen(header); + if (n < 6) + return; + + const char *type = header + (n - 3); + if (strcmp(type, "RMC") == 0) + parseRmc(fields, count); + else if (strcmp(type, "GGA") == 0) + parseGga(fields, count); + else if (strcmp(type, "GSV") == 0) + parseGsv(fields, count); +} + +static void pollGpsSerial() +{ + while (g_gpsSerial.available() > 0) + { + char c = (char)g_gpsSerial.read(); + if (c == '\r') + continue; + if (c == '\n') + { + if (g_gpsLineLen > 0) + { + g_gpsLine[g_gpsLineLen] = '\0'; + processNmeaLine(g_gpsLine); + g_gpsLineLen = 0; + } + continue; + } + + if (g_gpsLineLen + 1 < sizeof(g_gpsLine)) + g_gpsLine[g_gpsLineLen++] = c; + else + g_gpsLineLen = 0; + } +} + +static bool gpsUtcIsFresh() +{ + return g_gps.hasValidUtc && ((uint32_t)(millis() - g_gps.lastUtcMs) <= 2000U); +} + +static IRAM_ATTR void onPpsEdge() +{ + g_ppsEdgeCount++; +} + +static bool waitForNextPps(uint32_t timeoutMs) +{ + uint32_t startEdges = g_ppsEdgeCount; + uint32_t startMs = millis(); + while ((uint32_t)(millis() - startMs) < timeoutMs) + { + pollGpsSerial(); + g_sd.update(); + if (g_ppsEdgeCount != startEdges) + return true; + delay(2); + } + return false; +} + +static bool ensureGpsLogPathReady() +{ + if (!g_sd.isMounted()) + { + g_gpsPathReady = false; + return false; + } + if (g_gpsPathReady) + return true; + + if (!g_sd.ensureDirRecursive("/gps")) + return false; + File f = SD.open("/gps/discipline_rtc.log", FILE_APPEND); + if (!f) + return false; + f.close(); + g_gpsPathReady = true; + return true; +} + +static bool appendDisciplineLog(const DateTime &gpsUtc, int64_t rtcMinusGpsSeconds, bool hadPriorRtc) +{ + if (!ensureGpsLogPathReady()) + return false; + + File f = SD.open("/gps/discipline_rtc.log", FILE_APPEND); + if (!f) + return false; + + char ts[24]; + snprintf(ts, + sizeof(ts), + "%04u%02u%02u_%02u%02u%02u_z", + (unsigned)gpsUtc.year, + (unsigned)gpsUtc.month, + (unsigned)gpsUtc.day, + (unsigned)gpsUtc.hour, + (unsigned)gpsUtc.minute, + (unsigned)gpsUtc.second); + + char line[256]; + if (hadPriorRtc) + { + snprintf(line, + sizeof(line), + "%s\t set RTC to GPS for FiveTalk\trtc-gps drift=%+lld s; sats=%u; fw_build_utc=%s", + ts, + (long long)rtcMinusGpsSeconds, + (unsigned)bestSatelliteCount(), + FW_BUILD_UTC); + } + else + { + snprintf(line, + sizeof(line), + "%s\t set RTC to GPS for FiveTalk\trtc-gps drift=RTC_unset; sats=%u; fw_build_utc=%s", + ts, + (unsigned)bestSatelliteCount(), + FW_BUILD_UTC); + } + + size_t wrote = f.println(line); + f.close(); + return wrote > 0; +} + +static bool disciplineRtcToGps() +{ + if (!gpsUtcIsFresh()) + return false; + + DateTime prior{}; + bool lowV = false; + bool havePriorRtc = rtcRead(prior, lowV) && !lowV && isValidDateTime(prior); + + DateTime gpsSnap = g_gps.utc; + if (!waitForNextPps(kPpsWaitTimeoutMs)) + return false; + + int64_t snapEpoch = toEpochSeconds(gpsSnap); + DateTime target{}; + if (!fromEpochSeconds(snapEpoch + 1, target)) + return false; + if (!rtcWrite(target)) + return false; + + int64_t driftSec = 0; + if (havePriorRtc) + driftSec = toEpochSeconds(prior) - toEpochSeconds(target); + + if (!appendDisciplineLog(target, driftSec, havePriorRtc)) + { + logf("WARN: Failed to append /gps/discipline_rtc.log"); + } + + g_lastDisciplineEpoch = toEpochSeconds(target); + char human[32]; + formatUtcHuman(target, human, sizeof(human)); + logf("RTC disciplined to GPS (%s), sats=%u", human, (unsigned)bestSatelliteCount()); + return true; +} + +static bool parseLogTimestampToken(const char *token, int64_t &epochOut) +{ + if (!token) + return false; + + unsigned y = 0, m = 0, d = 0, hh = 0, mm = 0, ss = 0; + if (sscanf(token, "%4u%2u%2u_%2u%2u%2u", &y, &m, &d, &hh, &mm, &ss) != 6) + return false; + + DateTime dt{}; + dt.year = (uint16_t)y; + dt.month = (uint8_t)m; + dt.day = (uint8_t)d; + dt.hour = (uint8_t)hh; + dt.minute = (uint8_t)mm; + dt.second = (uint8_t)ss; + if (!isValidDateTime(dt)) + return false; + + epochOut = toEpochSeconds(dt); + return true; +} + +static bool loadLastDisciplineEpoch(int64_t &epochOut) +{ + epochOut = -1; + if (!g_sd.isMounted()) + return false; + if (!SD.exists("/gps/discipline_rtc.log")) + return false; + + File f = SD.open("/gps/discipline_rtc.log", FILE_READ); + if (!f) + return false; + + while (f.available()) + { + String line = f.readStringUntil('\n'); + line.trim(); + if (line.length() == 0) + continue; + + int sep = line.indexOf('\t'); + String token = (sep >= 0) ? line.substring(0, sep) : line; + + char buf[32]; + size_t n = token.length(); + if (n >= sizeof(buf)) + n = sizeof(buf) - 1; + memcpy(buf, token.c_str(), n); + buf[n] = '\0'; + + int64_t parsed = -1; + if (parseLogTimestampToken(buf, parsed)) + epochOut = parsed; + } + + f.close(); + return epochOut >= 0; +} + +static bool isDisciplineStale() +{ + DateTime now{}; + int64_t nowEpoch = 0; + if (!getCurrentUtc(now, nowEpoch)) + return true; + + int64_t lastEpoch = -1; + if (!loadLastDisciplineEpoch(lastEpoch)) + { + if (g_lastDisciplineEpoch < 0) + return true; + lastEpoch = g_lastDisciplineEpoch; + } + + g_lastDisciplineEpoch = lastEpoch; + if (lastEpoch < 0) + return true; + + int64_t age = nowEpoch - lastEpoch; + return age < 0 || age > (int64_t)kDisciplineMaxAgeSec; +} + +static void readBattery(float &voltageV, bool &present) +{ + voltageV = -1.0f; + present = false; + if (!g_pmu) + return; + + present = g_pmu->isBatteryConnect(); + voltageV = g_pmu->getBattVoltage() / 1000.0f; +} + +static void closeSessionLogs() +{ + if (g_sentFile) + g_sentFile.close(); + if (g_recvFile) + g_recvFile.close(); + g_sessionReady = false; +} + +static bool openSessionLogs() +{ + closeSessionLogs(); + + DateTime now{}; + int64_t nowEpoch = 0; + if (!getCurrentUtc(now, nowEpoch)) + { + logf("Cannot open session logs: RTC unavailable"); + return false; + } + + formatUtcCompact(now, g_sessionStamp, sizeof(g_sessionStamp)); + snprintf(g_sentPath, sizeof(g_sentPath), "/%s_sent_%s.log", NODE_SHORT, g_sessionStamp); + snprintf(g_recvPath, sizeof(g_recvPath), "/%s_received_%s.log", NODE_SHORT, g_sessionStamp); + + g_sentFile = SD.open(g_sentPath, FILE_APPEND); + g_recvFile = SD.open(g_recvPath, FILE_APPEND); + if (!g_sentFile || !g_recvFile) + { + logf("Failed to open session logs: %s | %s", g_sentPath, g_recvPath); + closeSessionLogs(); + return false; + } + + char human[32]; + formatUtcHuman(now, human, sizeof(human)); + g_sentFile.printf("# session_start_epoch=%lld utc=%s node=%s (%s) fw_build=%s\n", + (long long)nowEpoch, + human, + NODE_SHORT, + NODE_LABEL, + FW_BUILD_UTC); + g_recvFile.printf("# session_start_epoch=%lld utc=%s node=%s (%s) fw_build=%s\n", + (long long)nowEpoch, + human, + NODE_SHORT, + NODE_LABEL, + FW_BUILD_UTC); + g_sentFile.flush(); + g_recvFile.flush(); + + logf("Session logs ready: %s | %s", g_sentPath, g_recvPath); + g_sessionReady = true; + return true; +} + +static void gpsFieldStrings(char *latOut, size_t latLen, char *lonOut, size_t lonLen, char *altOut, size_t altLen) +{ + if (latOut && latLen > 0) + latOut[0] = '\0'; + if (lonOut && lonLen > 0) + lonOut[0] = '\0'; + if (altOut && altLen > 0) + altOut[0] = '\0'; + + if (g_gps.hasValidPosition) + { + if (latOut && latLen > 0) + snprintf(latOut, latLen, "%.6f", g_gps.latitudeDeg); + if (lonOut && lonLen > 0) + snprintf(lonOut, lonLen, "%.6f", g_gps.longitudeDeg); + } + if (g_gps.hasValidAltitude) + { + if (altOut && altLen > 0) + snprintf(altOut, altLen, "%.2f", g_gps.altitudeM); + } +} + +static void writeSentLog(int64_t epoch, const DateTime &dt, uint32_t txId, const char *payload, bool txOk) +{ + if (!g_sessionReady || !g_sentFile) + return; + + float battV = -1.0f; + bool battPresent = false; + readBattery(battV, battPresent); + + char human[32]; + formatUtcHuman(dt, human, sizeof(human)); + + char lat[24], lon[24], alt[24]; + gpsFieldStrings(lat, sizeof(lat), lon, sizeof(lon), alt, sizeof(alt)); + + g_sentFile.printf("epoch=%lld\tutc=%s\tunit=%s\tmsg=%s\ttx_id=%lu\ttx_ok=%u\tlat=%s\tlon=%s\talt_m=%s\ttx_count=%lu\tbatt_present=%u\tbatt_v=%.3f\n", + (long long)epoch, + human, + NODE_SHORT, + payload ? payload : "", + (unsigned long)txId, + txOk ? 1U : 0U, + lat, + lon, + alt, + (unsigned long)g_txCount, + battPresent ? 1U : 0U, + battV); + g_sentFile.flush(); +} + +static void writeRecvLog(int64_t epoch, const DateTime &dt, const char *msg, float rssi, float snr) +{ + if (!g_sessionReady || !g_recvFile) + return; + + float battV = -1.0f; + bool battPresent = false; + readBattery(battV, battPresent); + + char human[32]; + formatUtcHuman(dt, human, sizeof(human)); + + char txId[24], lat[24], lon[24], alt[24]; + parsePayloadFields(msg, txId, sizeof(txId), lat, sizeof(lat), lon, sizeof(lon), alt, sizeof(alt)); + + g_recvFile.printf("epoch=%lld\tutc=%s\tunit=%s\trx_msg=%s\trx_tx_id=%s\trx_lat=%s\trx_lon=%s\trx_alt_m=%s\trssi=%.1f\tsnr=%.1f\tbatt_present=%u\tbatt_v=%.3f\n", + (long long)epoch, + human, + NODE_SHORT, + msg ? msg : "", + txId, + lat, + lon, + alt, + rssi, + snr, + battPresent ? 1U : 0U, + battV); + g_recvFile.flush(); +} + +static void buildTxPayload(char *out, size_t outLen, uint32_t txId) +{ + if (!out || outLen == 0) + return; + out[0] = '\0'; + + char lat[24], lon[24], alt[24]; + gpsFieldStrings(lat, sizeof(lat), lon, sizeof(lon), alt, sizeof(alt)); + snprintf(out, outLen, "%s,%lu,%s,%s,%s", NODE_SHORT, (unsigned long)txId, lat, lon, alt); +} + +static bool isAllDigits(const char *s) +{ + if (!s || s[0] == '\0') + return false; + for (size_t i = 0; s[i] != '\0'; ++i) + { + if (!isdigit((unsigned char)s[i])) + return false; + } + return true; +} + +static void parsePayloadFields(const char *msg, + char *txIdOut, size_t txIdLen, + char *latOut, size_t latLen, + char *lonOut, size_t lonLen, + char *altOut, size_t altLen) +{ + if (txIdOut && txIdLen > 0) + txIdOut[0] = '\0'; + if (latOut && latLen > 0) + latOut[0] = '\0'; + if (lonOut && lonLen > 0) + lonOut[0] = '\0'; + if (altOut && altLen > 0) + altOut[0] = '\0'; + if (!msg || msg[0] == '\0') + return; + + char buf[128]; + size_t n = strlen(msg); + if (n >= sizeof(buf)) + n = sizeof(buf) - 1; + memcpy(buf, msg, n); + buf[n] = '\0'; + + char *saveptr = nullptr; + char *token = strtok_r(buf, ",", &saveptr); // unit label + (void)token; + + token = strtok_r(nullptr, ",", &saveptr); // tx_id or lat (legacy) + if (!token) + return; + + if (isAllDigits(token)) + { + if (txIdOut && txIdLen > 0) + snprintf(txIdOut, txIdLen, "%s", token); + + token = strtok_r(nullptr, ",", &saveptr); // lat + if (token && latOut && latLen > 0) + snprintf(latOut, latLen, "%s", token); + + token = strtok_r(nullptr, ",", &saveptr); // lon + if (token && lonOut && lonLen > 0) + snprintf(lonOut, lonLen, "%s", token); + + token = strtok_r(nullptr, ",", &saveptr); // alt + if (token && altOut && altLen > 0) + snprintf(altOut, altLen, "%s", token); + } + else + { + // Backward compatibility: older payloads were "UNIT,lat,lon,alt". + if (latOut && latLen > 0) + snprintf(latOut, latLen, "%s", token); + + token = strtok_r(nullptr, ",", &saveptr); // lon + if (token && lonOut && lonLen > 0) + snprintf(lonOut, lonLen, "%s", token); + + token = strtok_r(nullptr, ",", &saveptr); // alt + if (token && altOut && altLen > 0) + snprintf(altOut, altLen, "%s", token); + } +} + +static void onLoRaDio1Rise() +{ + g_rxFlag = true; +} + +static bool initRadio() +{ + SPI.begin(LORA_SCK, LORA_MISO, LORA_MOSI, LORA_CS); + + int state = g_radio.begin(915.0, 125.0, 7, 5, 0x12, 14); + if (state != RADIOLIB_ERR_NONE) + { + logf("radio.begin failed code=%d", state); + return false; + } + + g_radio.setDio1Action(onLoRaDio1Rise); + state = g_radio.startReceive(); + if (state != RADIOLIB_ERR_NONE) + { + logf("radio.startReceive failed code=%d", state); + return false; + } + + logf("Radio ready for %s (%s), slot=%d/%d (2s each)", NODE_LABEL, NODE_SHORT, NODE_SLOT_INDEX, NODE_SLOT_COUNT); + return true; +} + +static void showRxOnOled(const DateTime &dt, const char *msg) +{ + char hhmmss[16]; + snprintf(hhmmss, sizeof(hhmmss), "%02u:%02u:%02u", (unsigned)dt.hour, (unsigned)dt.minute, (unsigned)dt.second); + + char line[32]; + snprintf(line, sizeof(line), "[%s, %s]", msg ? msg : "", NODE_SHORT); + oledShowLines(hhmmss, line); +} + +static void runTxScheduler() +{ + DateTime now{}; + int64_t epoch = 0; + if (!getCurrentUtc(now, epoch)) + return; + + uint32_t requiredTxSeconds = (uint32_t)NODE_SLOT_COUNT * kSlotSeconds; + uint32_t frameSeconds = computeFrameSeconds(requiredTxSeconds); + uint32_t slotSecond = (uint32_t)NODE_SLOT_INDEX * kSlotSeconds; + if (slotSecond >= frameSeconds) + return; + + uint32_t secInFrame = (uint32_t)now.second % frameSeconds; + if (secInFrame >= requiredTxSeconds) + return; // idle guard interval + if (secInFrame != slotSecond) + return; + + int64_t epochSecond = epoch; + if (epochSecond == g_lastTxEpochSecond) + return; + + g_lastTxEpochSecond = epochSecond; + uint32_t txId = ++g_txAttemptId; + + g_rxFlag = false; + g_radio.clearDio1Action(); + + char payload[96]; + buildTxPayload(payload, sizeof(payload), txId); + int tx = g_radio.transmit(payload); + if (tx == RADIOLIB_ERR_NONE) + { + g_txCount++; + writeSentLog(epoch, now, txId, payload, true); + logf("TX %s tx_id=%lu success_count=%lu payload=%s", NODE_SHORT, (unsigned long)txId, (unsigned long)g_txCount, payload); + } + else + { + writeSentLog(epoch, now, txId, payload, false); + logf("TX failed code=%d", tx); + } + + g_rxFlag = false; + g_radio.setDio1Action(onLoRaDio1Rise); + g_radio.startReceive(); +} + +static void runRxHandler() +{ + if (!g_rxFlag) + return; + g_rxFlag = false; + + String rx; + int rc = g_radio.readData(rx); + if (rc != RADIOLIB_ERR_NONE) + { + g_radio.startReceive(); + return; + } + + DateTime now{}; + int64_t epoch = 0; + if (getCurrentUtc(now, epoch)) + { + writeRecvLog(epoch, now, rx.c_str(), g_radio.getRSSI(), g_radio.getSNR()); + showRxOnOled(now, rx.c_str()); + } + + g_radio.startReceive(); +} + +static void enterWaitSdState() +{ + if (g_phase == AppPhase::WAIT_SD) + return; + g_phase = AppPhase::WAIT_SD; + closeSessionLogs(); + logf("State -> WAIT_SD"); +} + +static void enterWaitDisciplineState() +{ + if (g_phase == AppPhase::WAIT_DISCIPLINE) + return; + g_phase = AppPhase::WAIT_DISCIPLINE; + closeSessionLogs(); + logf("State -> WAIT_DISCIPLINE"); +} + +static void enterRunState() +{ + if (g_phase == AppPhase::RUN) + return; + if (!openSessionLogs()) + return; + g_lastTxEpochSecond = -1; + g_lastHealthCheckMs = millis(); + g_phase = AppPhase::RUN; + logf("State -> RUN"); +} + +static void updateWaitSd() +{ + if (g_sd.isMounted()) + { + g_lastWarnMs = 0; + g_gpsPathReady = false; + enterWaitDisciplineState(); + return; + } + + uint32_t now = millis(); + if ((uint32_t)(now - g_lastWarnMs) >= kSdMessagePeriodMs) + { + g_lastWarnMs = now; + oledShowLines("Reinsert SD Card", NODE_SHORT, NODE_LABEL); + } +} + +static void updateWaitDiscipline() +{ + if (!g_sd.isMounted()) + { + enterWaitSdState(); + return; + } + + if (!isDisciplineStale()) + { + enterRunState(); + return; + } + + uint32_t now = millis(); + if ((uint32_t)(now - g_lastWarnMs) >= kNoGpsMessagePeriodMs) + { + g_lastWarnMs = now; + char satsLine[24]; + snprintf(satsLine, sizeof(satsLine), "Satellites: %u", (unsigned)bestSatelliteCount()); + oledShowLines("Take me outside", "Need GPS time sync", satsLine); + } + + if ((uint32_t)(now - g_lastDisciplineTryMs) < kDisciplineRetryMs) + return; + g_lastDisciplineTryMs = now; + + if (disciplineRtcToGps()) + { + g_lastWarnMs = 0; + enterRunState(); + } +} + +static void updateRun() +{ + uint32_t now = millis(); + + if (!g_sd.isMounted()) + { + if ((uint32_t)(now - g_lastWarnMs) >= kSdMessagePeriodMs) + { + g_lastWarnMs = now; + oledShowLines("SD removed", "Logging paused", "LoRa continues"); + } + } + else if (!g_sessionReady) + { + // Card came back while running. Resume append logging without pausing radio work. + (void)openSessionLogs(); + } + + if ((uint32_t)(now - g_lastHealthCheckMs) >= kHealthCheckPeriodMs) + { + g_lastHealthCheckMs = now; + if (isDisciplineStale()) + { + enterWaitDisciplineState(); + return; + } + } + + runTxScheduler(); + runRxHandler(); +} + +void setup() +{ + Serial.begin(115200); + delay(kSerialDelayMs); + + Serial.println("\r\n=================================================="); + Serial.println("Exercise 12: FiveTalk"); + Serial.println("=================================================="); + + if (!tbeam_supreme::initPmuForPeripherals(g_pmu, &Serial)) + { + logf("WARN: PMU init failed"); + } + + Wire.begin(OLED_SDA, OLED_SCL); + g_oled.setI2CAddress(OLED_ADDR << 1); + g_oled.begin(); + oledShowLines("Exercise 12", "FiveTalk startup", NODE_SHORT, NODE_LABEL); + + SdWatcherConfig sdCfg{}; + if (!g_sd.begin(sdCfg, nullptr)) + { + logf("WARN: SD watcher begin failed"); + } + +#ifdef GPS_1PPS_PIN + pinMode(GPS_1PPS_PIN, INPUT); + attachInterrupt(digitalPinToInterrupt(GPS_1PPS_PIN), onPpsEdge, RISING); +#endif +#ifdef GPS_WAKEUP_PIN + pinMode(GPS_WAKEUP_PIN, INPUT); +#endif + + g_gpsSerial.setRxBufferSize(1024); + g_gpsSerial.begin(GPS_BAUD, SERIAL_8N1, GPS_RX_PIN, GPS_TX_PIN); + + g_radioReady = initRadio(); + if (!g_radioReady) + { + oledShowLines("LoRa init failed", "Check radio pins"); + } + + g_phase = g_sd.isMounted() ? AppPhase::WAIT_DISCIPLINE : AppPhase::WAIT_SD; +} + +void loop() +{ + pollGpsSerial(); + g_sd.update(); + + if (g_sd.consumeMountedEvent()) + { + logf("SD mounted"); + g_gpsPathReady = false; + if (g_phase == AppPhase::RUN) + { + g_lastWarnMs = 0; + if (!g_sessionReady) + { + (void)openSessionLogs(); + } + } + } + if (g_sd.consumeRemovedEvent()) + { + logf("SD removed"); + g_gpsPathReady = false; + if (g_phase == AppPhase::RUN) + { + closeSessionLogs(); + g_lastWarnMs = 0; + oledShowLines("SD removed", "Logging paused", "LoRa continues"); + } + } + + if (!g_radioReady) + { + delay(50); + return; + } + + switch (g_phase) + { + case AppPhase::WAIT_SD: + updateWaitSd(); + break; + case AppPhase::WAIT_DISCIPLINE: + updateWaitDiscipline(); + break; + case AppPhase::RUN: + updateRun(); + break; + } + + delay(5); +} diff --git a/exercises/13_SD_Card_Diagnostics/README.md b/exercises/13_SD_Card_Diagnostics/README.md new file mode 100644 index 0000000..8a21aed --- /dev/null +++ b/exercises/13_SD_Card_Diagnostics/README.md @@ -0,0 +1,71 @@ +## Exercise 13: SD Card Diagnostics + +Dedicated SD hardware + software diagnostics for T-Beam Supreme. + +This exercise is meant to isolate SD failures like: +- card only works after reinsertion, +- intermittent mount loss, +- one unit never mounts while others do, +- possible interconnect / socket / power rail issues. + +### What it does + +1. Uses the `startup_sd` watcher library from Exercise 12 for continuous card presence monitoring. +2. Logs PMU telemetry repeatedly: + - BLDO1 (SD rail enable state) + - VBUS voltage + - battery voltage and battery-present flag +3. Samples SD SPI GPIO logic levels (`CS`, `SCK`, `MISO`, `MOSI`) at runtime. +4. Runs SPI idle-byte probes on both `HSPI` and `FSPI`. +5. Runs full mount matrix scans: + - buses: `HSPI`, then `FSPI` + - frequencies: `400k`, `1M`, `4M`, `10M` +6. Performs SD file I/O validation when mounted: + - append to `/diag/sd_diag_probe.log` + - flush + - reopen and read back verification token +7. Every few cycles, power-cycles SD rail (BLDO1) and re-tests mount. +8. Shows live status on OLED and detailed logs on Serial. + +### Build + +```bash +source /home/jlpoole/rnsenv/bin/activate +pio run -e amy +``` + +### Upload (using your udev aliases) + +```bash +source /home/jlpoole/rnsenv/bin/activate +pio run -e amy -t upload --upload-port /dev/ttytAMY +``` + +### Monitor + +```bash +pio device monitor --port /dev/ttytAMY --baud 115200 +``` + +### Interpreting key log lines + +- `Mount OK bus=... hz=...` + - SD stack works at that bus/speed. +- `Mount FAIL ...` on all combos + - usually hardware path, socket contact, power rail, interconnect, or card format issue. +- `SPI probe ... ff=8` + - typical idle/pull-up style response. +- `SPI probe ... zero=8` + - suspicious: line stuck low/short or bus contention. +- `BLDO1=0` while testing + - SD rail is off; card cannot function. +- `I/O FAIL` after mount success + - media/filesystem instability or write path issue. + +### Practical A/B troubleshooting workflow + +1. Use one known-good SD card and test it in a known-good unit and Amy. +2. Compare whether `Mount OK` appears in both units. +3. If Amy never gets `Mount OK` but good unit does, suspect Amy hardware path. +4. Gently flex/reseat board stack while monitoring logs for mount transitions. +5. If behavior changes with pressure/reseat, interconnect/socket contact is likely root cause. diff --git a/exercises/13_SD_Card_Diagnostics/lib/startup_sd/StartupSdManager.cpp b/exercises/13_SD_Card_Diagnostics/lib/startup_sd/StartupSdManager.cpp new file mode 100644 index 0000000..1e8791c --- /dev/null +++ b/exercises/13_SD_Card_Diagnostics/lib/startup_sd/StartupSdManager.cpp @@ -0,0 +1,360 @@ +#include "StartupSdManager.h" + +#include +#include "driver/gpio.h" + +StartupSdManager::StartupSdManager(Print& serial) : serial_(serial) {} + +bool StartupSdManager::begin(const SdWatcherConfig& cfg, SdStatusCallback callback) { + cfg_ = cfg; + callback_ = callback; + + forceSpiDeselected(); + dumpSdPins("very-early"); + + if (!initPmuForSdPower()) { + return false; + } + + cycleSdRail(); + delay(cfg_.startupWarmupMs); + + bool warmMounted = false; + for (uint8_t i = 0; i < 3; ++i) { + if (mountPreferred(false)) { + warmMounted = true; + break; + } + delay(200); + } + + // Some cards need a longer power/settle window after cold boot. + // Before declaring ABSENT, retry with extended settle and a full scan. + if (!warmMounted) { + logf("Watcher: startup preferred mount failed, retrying with extended settle"); + cycleSdRail(400, 1200); + delay(cfg_.startupWarmupMs + 1500); + warmMounted = mountCardFullScan(); + } + + if (warmMounted) { + setStateMounted(); + } else { + setStateAbsent(); + } + return true; +} + +void StartupSdManager::update() { + const uint32_t now = millis(); + const uint32_t pollInterval = + (watchState_ == SdWatchState::MOUNTED) ? cfg_.pollIntervalMountedMs : cfg_.pollIntervalAbsentMs; + + if ((uint32_t)(now - lastPollMs_) < pollInterval) { + return; + } + lastPollMs_ = now; + + if (watchState_ == SdWatchState::MOUNTED) { + if (verifyMountedCard()) { + presentVotes_ = 0; + absentVotes_ = 0; + return; + } + + if (mountPreferred(false) && verifyMountedCard()) { + presentVotes_ = 0; + absentVotes_ = 0; + return; + } + + absentVotes_++; + presentVotes_ = 0; + if (absentVotes_ >= cfg_.votesToAbsent) { + setStateAbsent(); + absentVotes_ = 0; + } + return; + } + + bool mounted = mountPreferred(false); + if (!mounted && (uint32_t)(now - lastFullScanMs_) >= cfg_.fullScanIntervalMs) { + lastFullScanMs_ = now; + if (cfg_.recoveryRailCycleOnFullScan) { + logf("Watcher: recovery rail cycle before full scan"); + cycleSdRail(cfg_.recoveryRailOffMs, cfg_.recoveryRailOnSettleMs); + delay(150); + } + logf("Watcher: preferred probe failed, running full scan"); + mounted = mountCardFullScan(); + } + + if (mounted) { + presentVotes_++; + absentVotes_ = 0; + if (presentVotes_ >= cfg_.votesToPresent) { + setStateMounted(); + presentVotes_ = 0; + } + } else { + absentVotes_++; + presentVotes_ = 0; + if (absentVotes_ >= cfg_.votesToAbsent) { + setStateAbsent(); + absentVotes_ = 0; + } + } +} + +bool StartupSdManager::consumeMountedEvent() { + bool out = mountedEventPending_; + mountedEventPending_ = false; + return out; +} + +bool StartupSdManager::consumeRemovedEvent() { + bool out = removedEventPending_; + removedEventPending_ = false; + return out; +} + +void StartupSdManager::logf(const char* fmt, ...) { + char msg[196]; + va_list args; + va_start(args, fmt); + vsnprintf(msg, sizeof(msg), fmt, args); + va_end(args); + serial_.printf("[%10lu][%06lu] %s\r\n", + (unsigned long)millis(), + (unsigned long)logSeq_++, + msg); +} + +void StartupSdManager::notify(SdEvent event, const char* message) { + if (callback_ != nullptr) { + callback_(event, message); + } +} + +void StartupSdManager::forceSpiDeselected() { + pinMode(tbeam_supreme::sdCs(), OUTPUT); + digitalWrite(tbeam_supreme::sdCs(), HIGH); + pinMode(tbeam_supreme::imuCs(), OUTPUT); + digitalWrite(tbeam_supreme::imuCs(), HIGH); +} + +void StartupSdManager::dumpSdPins(const char* tag) { + if (!cfg_.enablePinDumps) { + (void)tag; + return; + } + + const gpio_num_t cs = (gpio_num_t)tbeam_supreme::sdCs(); + const gpio_num_t sck = (gpio_num_t)tbeam_supreme::sdSck(); + const gpio_num_t miso = (gpio_num_t)tbeam_supreme::sdMiso(); + const gpio_num_t mosi = (gpio_num_t)tbeam_supreme::sdMosi(); + logf("PINS(%s): CS=%d SCK=%d MISO=%d MOSI=%d", + tag, gpio_get_level(cs), gpio_get_level(sck), gpio_get_level(miso), gpio_get_level(mosi)); +} + +bool StartupSdManager::initPmuForSdPower() { + if (!tbeam_supreme::initPmuForPeripherals(pmu_, &serial_)) { + logf("ERROR: PMU init failed"); + return false; + } + return true; +} + +void StartupSdManager::cycleSdRail(uint32_t offMs, uint32_t onSettleMs) { + if (!cfg_.enableSdRailCycle) { + return; + } + if (!pmu_) { + logf("SD rail cycle skipped: pmu=null"); + return; + } + + forceSpiDeselected(); + pmu_->disablePowerOutput(XPOWERS_BLDO1); + delay(offMs); + pmu_->setPowerChannelVoltage(XPOWERS_BLDO1, 3300); + pmu_->enablePowerOutput(XPOWERS_BLDO1); + delay(onSettleMs); +} + +bool StartupSdManager::tryMountWithBus(SPIClass& bus, const char* busName, uint32_t hz, bool verbose) { + SD.end(); + bus.end(); + delay(10); + forceSpiDeselected(); + + bus.begin(tbeam_supreme::sdSck(), tbeam_supreme::sdMiso(), tbeam_supreme::sdMosi(), tbeam_supreme::sdCs()); + digitalWrite(tbeam_supreme::sdCs(), HIGH); + delay(2); + for (int i = 0; i < 10; i++) { + bus.transfer(0xFF); + } + delay(2); + + if (verbose) { + logf("SD: trying bus=%s freq=%lu Hz", busName, (unsigned long)hz); + } + + if (!SD.begin(tbeam_supreme::sdCs(), bus, hz)) { + if (verbose) { + logf("SD: mount failed (possible non-FAT format, power, or bus issue)"); + } + return false; + } + + if (SD.cardType() == CARD_NONE) { + SD.end(); + return false; + } + + sdSpi_ = &bus; + sdBusName_ = busName; + sdFreq_ = hz; + return true; +} + +bool StartupSdManager::mountPreferred(bool verbose) { + return tryMountWithBus(sdSpiH_, "HSPI", 400000, verbose); +} + +bool StartupSdManager::mountCardFullScan() { + const uint32_t freqs[] = {400000, 1000000, 4000000, 10000000}; + + for (uint8_t i = 0; i < (sizeof(freqs) / sizeof(freqs[0])); ++i) { + if (tryMountWithBus(sdSpiH_, "HSPI", freqs[i], true)) { + logf("SD: card detected and mounted"); + return true; + } + } + for (uint8_t i = 0; i < (sizeof(freqs) / sizeof(freqs[0])); ++i) { + if (tryMountWithBus(sdSpiF_, "FSPI", freqs[i], true)) { + logf("SD: card detected and mounted"); + return true; + } + } + + logf("SD: begin() failed on all bus/frequency attempts"); + return false; +} + +bool StartupSdManager::verifyMountedCard() { + File root = SD.open("/", FILE_READ); + if (!root) { + return false; + } + root.close(); + return true; +} + +const char* StartupSdManager::cardTypeToString(uint8_t type) { + switch (type) { + case CARD_MMC: + return "MMC"; + case CARD_SD: + return "SDSC"; + case CARD_SDHC: + return "SDHC/SDXC"; + default: + return "UNKNOWN"; + } +} + +void StartupSdManager::printCardInfo() { + uint8_t cardType = SD.cardType(); + uint64_t cardSizeMB = SD.cardSize() / (1024ULL * 1024ULL); + uint64_t totalMB = SD.totalBytes() / (1024ULL * 1024ULL); + uint64_t usedMB = SD.usedBytes() / (1024ULL * 1024ULL); + + logf("SD type: %s", cardTypeToString(cardType)); + logf("SD size: %llu MB", cardSizeMB); + logf("FS total: %llu MB", totalMB); + logf("FS used : %llu MB", usedMB); + logf("SPI bus: %s @ %lu Hz", sdBusName_, (unsigned long)sdFreq_); +} + +bool StartupSdManager::ensureDirRecursive(const char* path) { + String full(path); + if (!full.startsWith("/")) { + full = "/" + full; + } + + int start = 1; + while (start > 0 && start < (int)full.length()) { + int slash = full.indexOf('/', start); + String partial = (slash < 0) ? full : full.substring(0, slash); + if (!SD.exists(partial.c_str()) && !SD.mkdir(partial.c_str())) { + logf("ERROR: mkdir failed for %s", partial.c_str()); + return false; + } + if (slash < 0) { + break; + } + start = slash + 1; + } + + return true; +} + +bool StartupSdManager::rewriteFile(const char* path, const char* payload) { + if (SD.exists(path) && !SD.remove(path)) { + logf("ERROR: failed to erase %s", path); + return false; + } + + File f = SD.open(path, FILE_WRITE); + if (!f) { + logf("ERROR: failed to create %s", path); + return false; + } + + size_t wrote = f.println(payload); + f.close(); + if (wrote == 0) { + logf("ERROR: write failed for %s", path); + return false; + } + return true; +} + +void StartupSdManager::permissionsDemo(const char* path) { + logf("Permissions demo: FAT has no Unix chmod/chown, use open mode only."); + File r = SD.open(path, FILE_READ); + if (!r) { + logf("Could not open %s as FILE_READ", path); + return; + } + size_t writeInReadMode = r.print("attempt write while opened read-only"); + if (writeInReadMode == 0) { + logf("As expected, FILE_READ write was blocked."); + } else { + logf("NOTE: FILE_READ write returned %u (unexpected)", (unsigned)writeInReadMode); + } + r.close(); +} + +void StartupSdManager::setStateMounted() { + if (watchState_ != SdWatchState::MOUNTED) { + logf("EVENT: card inserted/mounted"); + mountedEventPending_ = true; + notify(SdEvent::CARD_MOUNTED, "SD card mounted"); + } + watchState_ = SdWatchState::MOUNTED; +} + +void StartupSdManager::setStateAbsent() { + if (watchState_ == SdWatchState::MOUNTED) { + logf("EVENT: card removed/unavailable"); + removedEventPending_ = true; + notify(SdEvent::CARD_REMOVED, "SD card removed"); + } else if (watchState_ != SdWatchState::ABSENT) { + logf("EVENT: no card detected"); + notify(SdEvent::NO_CARD, "Missing SD card or invalid FAT16/FAT32 format"); + } + SD.end(); + watchState_ = SdWatchState::ABSENT; +} diff --git a/exercises/13_SD_Card_Diagnostics/lib/startup_sd/StartupSdManager.h b/exercises/13_SD_Card_Diagnostics/lib/startup_sd/StartupSdManager.h new file mode 100644 index 0000000..be9ef27 --- /dev/null +++ b/exercises/13_SD_Card_Diagnostics/lib/startup_sd/StartupSdManager.h @@ -0,0 +1,90 @@ +#pragma once + +#include +#include +#include +#include +#include "tbeam_supreme_adapter.h" + +enum class SdWatchState : uint8_t { + UNKNOWN = 0, + ABSENT, + MOUNTED +}; + +enum class SdEvent : uint8_t { + NO_CARD, + CARD_MOUNTED, + CARD_REMOVED +}; + +using SdStatusCallback = void (*)(SdEvent event, const char* message); + +struct SdWatcherConfig { + bool enableSdRailCycle = true; + bool enablePinDumps = true; + bool recoveryRailCycleOnFullScan = true; + uint32_t recoveryRailOffMs = 250; + uint32_t recoveryRailOnSettleMs = 700; + uint32_t startupWarmupMs = 1500; + uint32_t pollIntervalAbsentMs = 1000; + uint32_t pollIntervalMountedMs = 2000; + uint32_t fullScanIntervalMs = 10000; + uint8_t votesToPresent = 2; + uint8_t votesToAbsent = 5; +}; + +class StartupSdManager { + public: + explicit StartupSdManager(Print& serial = Serial); + + bool begin(const SdWatcherConfig& cfg, SdStatusCallback callback = nullptr); + void update(); + + bool isMounted() const { return watchState_ == SdWatchState::MOUNTED; } + SdWatchState state() const { return watchState_; } + + bool consumeMountedEvent(); + bool consumeRemovedEvent(); + + void printCardInfo(); + bool ensureDirRecursive(const char* path); + bool rewriteFile(const char* path, const char* payload); + void permissionsDemo(const char* path); + + private: + void logf(const char* fmt, ...); + void notify(SdEvent event, const char* message); + void forceSpiDeselected(); + void dumpSdPins(const char* tag); + bool initPmuForSdPower(); + void cycleSdRail(uint32_t offMs = 250, uint32_t onSettleMs = 600); + bool tryMountWithBus(SPIClass& bus, const char* busName, uint32_t hz, bool verbose); + bool mountPreferred(bool verbose); + bool mountCardFullScan(); + bool verifyMountedCard(); + const char* cardTypeToString(uint8_t type); + void setStateMounted(); + void setStateAbsent(); + + Print& serial_; + SdWatcherConfig cfg_{}; + SdStatusCallback callback_ = nullptr; + + SPIClass sdSpiH_{HSPI}; + SPIClass sdSpiF_{FSPI}; + SPIClass* sdSpi_ = nullptr; + const char* sdBusName_ = "none"; + uint32_t sdFreq_ = 0; + XPowersLibInterface* pmu_ = nullptr; + + SdWatchState watchState_ = SdWatchState::UNKNOWN; + uint8_t presentVotes_ = 0; + uint8_t absentVotes_ = 0; + uint32_t lastPollMs_ = 0; + uint32_t lastFullScanMs_ = 0; + uint32_t logSeq_ = 0; + + bool mountedEventPending_ = false; + bool removedEventPending_ = false; +}; diff --git a/exercises/13_SD_Card_Diagnostics/lib/startup_sd/library.json b/exercises/13_SD_Card_Diagnostics/lib/startup_sd/library.json new file mode 100644 index 0000000..4978fdd --- /dev/null +++ b/exercises/13_SD_Card_Diagnostics/lib/startup_sd/library.json @@ -0,0 +1,12 @@ +{ + "name": "startup_sd", + "version": "0.1.0", + "dependencies": [ + { + "name": "XPowersLib" + }, + { + "name": "Wire" + } + ] +} diff --git a/exercises/13_SD_Card_Diagnostics/platformio.ini b/exercises/13_SD_Card_Diagnostics/platformio.ini new file mode 100644 index 0000000..ea02dd7 --- /dev/null +++ b/exercises/13_SD_Card_Diagnostics/platformio.ini @@ -0,0 +1,57 @@ +; 20260219 ChatGPT +; Exercise 13_SD_Card_Diagnostics + +[platformio] +default_envs = amy + +[env] +platform = espressif32 +framework = arduino +board = esp32-s3-devkitc-1 +monitor_speed = 115200 +extra_scripts = pre:scripts/set_build_epoch.py +lib_deps = + lewisxhe/XPowersLib@0.3.3 + Wire + olikraus/U8g2@^2.36.4 + +build_flags = + -I ../../shared/boards + -I ../../external/microReticulum_Firmware + -D BOARD_MODEL=BOARD_TBEAM_S_V1 + -D OLED_SDA=17 + -D OLED_SCL=18 + -D OLED_ADDR=0x3C + -D ARDUINO_USB_MODE=1 + -D ARDUINO_USB_CDC_ON_BOOT=1 + +[env:amy] +extends = env +build_flags = + ${env.build_flags} + -D NODE_LABEL=\"AMY\" + -D DIAG_TEST_NOTE=\"clear_holder_disconnected_main_screws_removed_pcb_socket_screw_removed\" + +[env:bob] +extends = env +build_flags = + ${env.build_flags} + -D NODE_LABEL=\"BOB\" + +[env:cy] +extends = env +build_flags = + ${env.build_flags} + -D NODE_LABEL=\"CY\" + +[env:dan] +extends = env +build_flags = + ${env.build_flags} + -D NODE_LABEL=\"DAN\" + +[env:ed] +extends = env +build_flags = + ${env.build_flags} + -D NODE_LABEL=\"ED\" diff --git a/exercises/13_SD_Card_Diagnostics/scripts/set_build_epoch.py b/exercises/13_SD_Card_Diagnostics/scripts/set_build_epoch.py new file mode 100644 index 0000000..3011129 --- /dev/null +++ b/exercises/13_SD_Card_Diagnostics/scripts/set_build_epoch.py @@ -0,0 +1,12 @@ +import time +Import("env") + +epoch = int(time.time()) +utc_tag = time.strftime("%Y%m%d_%H%M%S_z", time.gmtime(epoch)) + +env.Append( + CPPDEFINES=[ + ("FW_BUILD_EPOCH", str(epoch)), + ("FW_BUILD_UTC", '\\"%s\\"' % utc_tag), + ] +) diff --git a/exercises/13_SD_Card_Diagnostics/src/main.cpp b/exercises/13_SD_Card_Diagnostics/src/main.cpp new file mode 100644 index 0000000..1eaf954 --- /dev/null +++ b/exercises/13_SD_Card_Diagnostics/src/main.cpp @@ -0,0 +1,512 @@ +// 20260219 ChatGPT +// Exercise 13: SD Card Diagnostics + +#include +#include +#include +#include +#include +#include +#include + +#include "StartupSdManager.h" +#include "tbeam_supreme_adapter.h" + +#ifndef NODE_LABEL +#define NODE_LABEL "DIAG" +#endif + +#ifndef OLED_SDA +#define OLED_SDA 17 +#endif +#ifndef OLED_SCL +#define OLED_SCL 18 +#endif +#ifndef OLED_ADDR +#define OLED_ADDR 0x3C +#endif +#ifndef FILE_APPEND +#define FILE_APPEND FILE_WRITE +#endif +#ifndef FW_BUILD_UTC +#define FW_BUILD_UTC "unknown" +#endif +#ifndef DIAG_TEST_NOTE +#define DIAG_TEST_NOTE "enclosure screws removed; board lightly constrained" +#endif + +static const uint32_t kSerialDelayMs = 1500; +static const uint32_t kLoopDelayMs = 10; +static const uint32_t kHeartbeatMs = 2000; +static const uint32_t kDiagCycleMs = 20000; +static const uint32_t kRailRetestEvery = 3; + +static XPowersLibInterface* g_pmu = nullptr; +static StartupSdManager g_sd(Serial); +static U8G2_SH1106_128X64_NONAME_F_HW_I2C g_oled(U8G2_R0, U8X8_PIN_NONE); +static SPIClass g_spiH(HSPI); +static SPIClass g_spiF(FSPI); + +static uint32_t g_logSeq = 0; +static uint32_t g_lastHeartbeatMs = 0; +static uint32_t g_lastDiagMs = 0; +static uint32_t g_diagCycleCount = 0; + +static bool g_lastMounted = false; +static char g_lastDiagLine1[28] = "Diag: waiting"; +static char g_lastDiagLine2[28] = "No cycle yet"; + +struct PinSnapshot { + int cs = -1; + int sck = -1; + int miso = -1; + int mosi = -1; +}; + +struct ProbeSummary { + uint8_t ffCount = 0; + uint8_t zeroCount = 0; + uint8_t otherCount = 0; + uint8_t firstBytes[8] = {0}; +}; + +struct MountMatrixResult { + bool anySuccess = false; + uint8_t attempts = 0; + const char* successBus = "none"; + uint32_t successHz = 0; +}; + +static ProbeSummary g_lastProbeH{}; +static ProbeSummary g_lastProbeF{}; + +static void logf(const char* fmt, ...) { + char msg[240]; + va_list args; + va_start(args, fmt); + vsnprintf(msg, sizeof(msg), fmt, args); + va_end(args); + Serial.printf("[%10lu][%06lu] %s\r\n", (unsigned long)millis(), (unsigned long)g_logSeq++, msg); +} + +static void oledShowLines(const char* l1, + const char* l2 = nullptr, + const char* l3 = nullptr, + const char* l4 = nullptr, + const char* l5 = nullptr) { + g_oled.clearBuffer(); + g_oled.setFont(u8g2_font_5x8_tf); + if (l1) g_oled.drawUTF8(0, 12, l1); + if (l2) g_oled.drawUTF8(0, 24, l2); + if (l3) g_oled.drawUTF8(0, 36, l3); + if (l4) g_oled.drawUTF8(0, 48, l4); + if (l5) g_oled.drawUTF8(0, 60, l5); + g_oled.sendBuffer(); +} + +static void forceSpiDeselected() { + pinMode(tbeam_supreme::sdCs(), OUTPUT); + digitalWrite(tbeam_supreme::sdCs(), HIGH); + pinMode(tbeam_supreme::imuCs(), OUTPUT); + digitalWrite(tbeam_supreme::imuCs(), HIGH); +} + +static PinSnapshot readPins() { + PinSnapshot s; + s.cs = gpio_get_level((gpio_num_t)tbeam_supreme::sdCs()); + s.sck = gpio_get_level((gpio_num_t)tbeam_supreme::sdSck()); + s.miso = gpio_get_level((gpio_num_t)tbeam_supreme::sdMiso()); + s.mosi = gpio_get_level((gpio_num_t)tbeam_supreme::sdMosi()); + return s; +} + +static void logPins(const char* tag) { + PinSnapshot p = readPins(); + logf("PINS(%s): CS=%d SCK=%d MISO=%d MOSI=%d", tag, p.cs, p.sck, p.miso, p.mosi); +} + +static void readPmu(float& vbusV, float& battV, bool& bldo1On, bool& battPresent) { + vbusV = -1.0f; + battV = -1.0f; + bldo1On = false; + battPresent = false; + if (!g_pmu) return; + + bldo1On = g_pmu->isPowerChannelEnable(XPOWERS_BLDO1); + battPresent = g_pmu->isBatteryConnect(); + vbusV = g_pmu->getVbusVoltage() / 1000.0f; + battV = g_pmu->getBattVoltage() / 1000.0f; +} + +static bool cycleSdRail(uint32_t offMs = 300, uint32_t onSettleMs = 900) { + if (!g_pmu) { + logf("Rail cycle skipped: PMU unavailable"); + return false; + } + + forceSpiDeselected(); + g_pmu->disablePowerOutput(XPOWERS_BLDO1); + delay(offMs); + g_pmu->setPowerChannelVoltage(XPOWERS_BLDO1, 3300); + g_pmu->enablePowerOutput(XPOWERS_BLDO1); + delay(onSettleMs); + logf("Rail cycle complete (off=%lums on_settle=%lums)", (unsigned long)offMs, (unsigned long)onSettleMs); + return true; +} + +static ProbeSummary runIdleProbeOnBus(SPIClass& bus, const char* busName) { + ProbeSummary out; + + SD.end(); + bus.end(); + delay(5); + forceSpiDeselected(); + + bus.begin(tbeam_supreme::sdSck(), tbeam_supreme::sdMiso(), tbeam_supreme::sdMosi(), tbeam_supreme::sdCs()); + digitalWrite(tbeam_supreme::sdCs(), HIGH); + delay(1); + + for (int i = 0; i < 8; ++i) { + uint8_t b = bus.transfer(0xFF); + out.firstBytes[i] = b; + if (b == 0xFF) out.ffCount++; + else if (b == 0x00) out.zeroCount++; + else out.otherCount++; + } + + logf("SPI probe %s: ff=%u zero=%u other=%u bytes=%02X %02X %02X %02X %02X %02X %02X %02X", + busName, + (unsigned)out.ffCount, + (unsigned)out.zeroCount, + (unsigned)out.otherCount, + out.firstBytes[0], + out.firstBytes[1], + out.firstBytes[2], + out.firstBytes[3], + out.firstBytes[4], + out.firstBytes[5], + out.firstBytes[6], + out.firstBytes[7]); + + return out; +} + +static bool tryMount(SPIClass& bus, const char* busName, uint32_t hz) { + SD.end(); + bus.end(); + delay(5); + forceSpiDeselected(); + + bus.begin(tbeam_supreme::sdSck(), tbeam_supreme::sdMiso(), tbeam_supreme::sdMosi(), tbeam_supreme::sdCs()); + digitalWrite(tbeam_supreme::sdCs(), HIGH); + delay(1); + for (int i = 0; i < 10; ++i) { + bus.transfer(0xFF); + } + + uint32_t t0 = millis(); + bool ok = SD.begin(tbeam_supreme::sdCs(), bus, hz); + uint32_t dt = millis() - t0; + if (!ok) { + logf("Mount FAIL bus=%s hz=%lu dt=%lums", busName, (unsigned long)hz, (unsigned long)dt); + return false; + } + + uint8_t type = SD.cardType(); + if (type == CARD_NONE) { + SD.end(); + logf("Mount FAIL bus=%s hz=%lu dt=%lums cardType=NONE", busName, (unsigned long)hz, (unsigned long)dt); + return false; + } + + uint64_t mb = SD.cardSize() / (1024ULL * 1024ULL); + logf("Mount OK bus=%s hz=%lu dt=%lums type=%u size=%lluMB", + busName, + (unsigned long)hz, + (unsigned long)dt, + (unsigned)type, + mb); + return true; +} + +static MountMatrixResult runMountMatrix() { + const uint32_t freqs[] = {400000, 1000000, 4000000, 10000000}; + MountMatrixResult result{}; + + for (size_t i = 0; i < (sizeof(freqs) / sizeof(freqs[0])); ++i) { + result.attempts++; + if (tryMount(g_spiH, "HSPI", freqs[i])) { + result.anySuccess = true; + result.successBus = "HSPI"; + result.successHz = freqs[i]; + return result; + } + } + + for (size_t i = 0; i < (sizeof(freqs) / sizeof(freqs[0])); ++i) { + result.attempts++; + if (tryMount(g_spiF, "FSPI", freqs[i])) { + result.anySuccess = true; + result.successBus = "FSPI"; + result.successHz = freqs[i]; + return result; + } + } + + return result; +} + +static void emitVendorReport(const MountMatrixResult& mm, + const ProbeSummary& ph, + const ProbeSummary& pf, + float vbusV, + float battV, + bool bldo1, + bool battPresent) { + logf("REPORT node=%s cycle=%lu fw=%s", NODE_LABEL, (unsigned long)g_diagCycleCount, FW_BUILD_UTC); + logf("REPORT test_note=%s", DIAG_TEST_NOTE); + logf("REPORT power bldo1=%u vbus=%.3fV batt=%.3fV batt_present=%u", + bldo1 ? 1U : 0U, + vbusV, + battV, + battPresent ? 1U : 0U); + logf("REPORT spi_probe hspi(ff=%u zero=%u other=%u) fspi(ff=%u zero=%u other=%u)", + (unsigned)ph.ffCount, + (unsigned)ph.zeroCount, + (unsigned)ph.otherCount, + (unsigned)pf.ffCount, + (unsigned)pf.zeroCount, + (unsigned)pf.otherCount); + if (mm.anySuccess) { + logf("REPORT mount_matrix status=PASS attempts=%u first_success=%s@%luHz", + (unsigned)mm.attempts, + mm.successBus, + (unsigned long)mm.successHz); + logf("REPORT verdict=SD interface operational in this cycle"); + return; + } + + logf("REPORT mount_matrix status=FAIL attempts=%u first_success=none", + (unsigned)mm.attempts); + + if (bldo1 && vbusV > 4.5f && ph.ffCount == 8 && pf.ffCount == 8) { + logf("REPORT verdict=Power looks good; SPI lines idle high; no card response on any bus/frequency; likely socket/interconnect/baseboard hardware fault"); + } else if (!bldo1) { + logf("REPORT verdict=SD rail appears off; investigate PMU/BLDO1 control path"); + } else { + logf("REPORT verdict=No card response; check SD socket, board interconnect, signal integrity, and card seating"); + } +} + +static bool runFileIoValidation(uint32_t cycleNo) { + if (!SD.exists("/diag")) { + if (!SD.mkdir("/diag")) { + logf("I/O FAIL: cannot create /diag"); + return false; + } + } + + const char* path = "/diag/sd_diag_probe.log"; + File f = SD.open(path, FILE_APPEND); + if (!f) { + logf("I/O FAIL: cannot open %s", path); + return false; + } + + float vbusV = 0.0f, battV = 0.0f; + bool bldo1 = false, battPresent = false; + readPmu(vbusV, battV, bldo1, battPresent); + + uint32_t t0 = millis(); + f.printf("cycle=%lu ms=%lu bldo1=%u vbus=%.3f batt=%.3f batt_present=%u mounted=%u\n", + (unsigned long)cycleNo, + (unsigned long)millis(), + bldo1 ? 1U : 0U, + vbusV, + battV, + battPresent ? 1U : 0U, + g_sd.isMounted() ? 1U : 0U); + f.flush(); + f.close(); + + uint32_t writeMs = millis() - t0; + + File r = SD.open(path, FILE_READ); + if (!r) { + logf("I/O FAIL: reopen for read failed"); + return false; + } + + size_t size = (size_t)r.size(); + if (size == 0) { + r.close(); + logf("I/O FAIL: file size is zero"); + return false; + } + + r.seek(size > 120 ? size - 120 : 0); + String tail = r.readString(); + r.close(); + + if (tail.indexOf(String("cycle=") + cycleNo) < 0) { + logf("I/O FAIL: verification token missing for cycle=%lu", (unsigned long)cycleNo); + return false; + } + + logf("I/O OK: append+flush+readback size=%uB write=%lums", (unsigned)size, (unsigned long)writeMs); + return true; +} + +static void onSdEvent(SdEvent event, const char* message) { + logf("SD event: %s", message ? message : "(null)"); + + if (event == SdEvent::NO_CARD) { + oledShowLines("SD Diagnostics", "NO CARD", "Insert/reseat card"); + } else if (event == SdEvent::CARD_MOUNTED) { + oledShowLines("SD Diagnostics", "CARD MOUNTED", "Running checks"); + } else if (event == SdEvent::CARD_REMOVED) { + oledShowLines("SD Diagnostics", "CARD REMOVED", "Check socket/fit"); + } +} + +static void emitHeartbeat() { + float vbusV = 0.0f, battV = 0.0f; + bool bldo1 = false, battPresent = false; + readPmu(vbusV, battV, bldo1, battPresent); + + PinSnapshot p = readPins(); + logf("HB mounted=%u BLDO1=%u VBUS=%.3fV VBAT=%.3fV batt_present=%u pins cs=%d sck=%d miso=%d mosi=%d", + g_sd.isMounted() ? 1U : 0U, + bldo1 ? 1U : 0U, + vbusV, + battV, + battPresent ? 1U : 0U, + p.cs, + p.sck, + p.miso, + p.mosi); + + char l1[28], l2[28], l3[28], l4[28], l5[28]; + snprintf(l1, sizeof(l1), "%s SD DIAG", NODE_LABEL); + snprintf(l2, sizeof(l2), "mounted:%s bldo1:%u", g_sd.isMounted() ? "yes" : "no", bldo1 ? 1U : 0U); + snprintf(l3, sizeof(l3), "VBUS:%.2f VBAT:%.2f", vbusV, battV); + snprintf(l4, sizeof(l4), "MISO:%d CS:%d", p.miso, p.cs); + snprintf(l5, sizeof(l5), "%s | %s", g_lastDiagLine1, g_lastDiagLine2); + oledShowLines(l1, l2, l3, l4, l5); +} + +static void runDiagnosticCycle() { + g_diagCycleCount++; + logf("========== DIAG CYCLE %lu START =========", (unsigned long)g_diagCycleCount); + + float vbusV = 0.0f, battV = 0.0f; + bool bldo1 = false, battPresent = false; + readPmu(vbusV, battV, bldo1, battPresent); + logf("Power baseline: BLDO1=%u VBUS=%.3fV VBAT=%.3fV batt_present=%u", + bldo1 ? 1U : 0U, + vbusV, + battV, + battPresent ? 1U : 0U); + + logPins("diag-start"); + g_lastProbeH = runIdleProbeOnBus(g_spiH, "HSPI"); + g_lastProbeF = runIdleProbeOnBus(g_spiF, "FSPI"); + + MountMatrixResult mm = runMountMatrix(); + if (!mm.anySuccess) { + snprintf(g_lastDiagLine1, sizeof(g_lastDiagLine1), "Mount scan: FAIL"); + snprintf(g_lastDiagLine2, sizeof(g_lastDiagLine2), "No bus/freq worked"); + SD.end(); + } else { + bool ioOk = runFileIoValidation(g_diagCycleCount); + snprintf(g_lastDiagLine1, sizeof(g_lastDiagLine1), "Mount scan: OK"); + snprintf(g_lastDiagLine2, sizeof(g_lastDiagLine2), "File I/O: %s", ioOk ? "OK" : "FAIL"); + SD.end(); + } + + if ((g_diagCycleCount % kRailRetestEvery) == 0) { + logf("Rail retest step"); + if (cycleSdRail()) { + MountMatrixResult remount = runMountMatrix(); + logf("Rail retest remount: %s", remount.anySuccess ? "OK" : "FAIL"); + SD.end(); + } + } + + emitVendorReport(mm, g_lastProbeH, g_lastProbeF, vbusV, battV, bldo1, battPresent); + + logf("========== DIAG CYCLE %lu END =========", (unsigned long)g_diagCycleCount); +} + +void setup() { + Serial.begin(115200); + delay(kSerialDelayMs); + + Serial.println("\r\n=================================================="); + Serial.println("Exercise 13: SD Card Diagnostics"); + Serial.println("=================================================="); + + logf("Node: %s", NODE_LABEL); + logf("FW build UTC: %s", FW_BUILD_UTC); + logf("Test note: %s", DIAG_TEST_NOTE); + logf("Pins: CS=%d SCK=%d MISO=%d MOSI=%d IMU_CS=%d", tbeam_supreme::sdCs(), tbeam_supreme::sdSck(), tbeam_supreme::sdMiso(), tbeam_supreme::sdMosi(), tbeam_supreme::imuCs()); + logf("PMU I2C: SDA1=%d SCL1=%d", tbeam_supreme::i2cSda(), tbeam_supreme::i2cScl()); + + Wire.begin(OLED_SDA, OLED_SCL); + g_oled.setI2CAddress(OLED_ADDR << 1); + g_oled.begin(); + oledShowLines("Exercise 13", "SD Card Diagnostics", NODE_LABEL, "Booting..."); + + if (!tbeam_supreme::initPmuForPeripherals(g_pmu, &Serial)) { + logf("WARN: PMU init failed via adapter"); + } + + forceSpiDeselected(); + logPins("boot"); + + SdWatcherConfig cfg{}; + cfg.enableSdRailCycle = true; + cfg.enablePinDumps = true; + cfg.recoveryRailCycleOnFullScan = true; + cfg.startupWarmupMs = 1500; + cfg.pollIntervalAbsentMs = 1000; + cfg.pollIntervalMountedMs = 2000; + cfg.fullScanIntervalMs = 8000; + cfg.votesToPresent = 2; + cfg.votesToAbsent = 5; + + if (!g_sd.begin(cfg, onSdEvent)) { + logf("WARN: StartupSdManager begin() failed"); + } + + g_lastMounted = g_sd.isMounted(); + g_lastHeartbeatMs = millis(); + g_lastDiagMs = millis() - kDiagCycleMs + 2000; +} + +void loop() { + g_sd.update(); + + if (g_sd.consumeMountedEvent()) { + g_lastMounted = true; + logf("Event: mounted"); + } + if (g_sd.consumeRemovedEvent()) { + g_lastMounted = false; + logf("Event: removed"); + } + + uint32_t now = millis(); + + if ((uint32_t)(now - g_lastHeartbeatMs) >= kHeartbeatMs) { + g_lastHeartbeatMs = now; + emitHeartbeat(); + } + + if ((uint32_t)(now - g_lastDiagMs) >= kDiagCycleMs) { + g_lastDiagMs = now; + runDiagnosticCycle(); + } + + delay(kLoopDelayMs); +} diff --git a/exercises/14_Power/README.md b/exercises/14_Power/README.md new file mode 100644 index 0000000..3b0d140 --- /dev/null +++ b/exercises/14_Power/README.md @@ -0,0 +1,30 @@ +# Exercise 14: Power (Charging + Visual) + +This exercise is intentionally narrow in scope: +- Detect if a battery is present. +- Detect if USB/VBUS power is present. +- Determine if charging is needed. +- Keep charging enabled through AXP2101 PMU settings. +- Flash the PMU charge LED while charging. +- If fully charged, leave LED off (do nothing). + +OLED behavior: +- For the first 2 minutes after boot, OLED shows: + - `Exercise 14 Power` + - node name (`NODE_LABEL`) + - time (RTC/system time if available, else uptime) + - charging state and battery stats +- After 2 minutes, it switches to a steady `Power Monitor` header while continuing live stats. + +## Meshtastic references used +- `src/Power.cpp` + - charging detection path (`isCharging()`, `isVbusIn()`, battery checks) +- `src/modules/StatusLEDModule.cpp` + - PMU charging LED control via `PMU->setChargingLedMode(...)` + +## Build and upload +```bash +cd /usr/local/src/microreticulum/microReticulumTbeam/exercises/14_Power +pio run -e ed -t upload +pio device monitor -b 115200 +``` diff --git a/exercises/14_Power/platformio.ini b/exercises/14_Power/platformio.ini new file mode 100644 index 0000000..18ef06f --- /dev/null +++ b/exercises/14_Power/platformio.ini @@ -0,0 +1,65 @@ +; 20260220 Codex +; Exercise 14_Power + +[platformio] +default_envs = ed + +[env] +platform = espressif32 +framework = arduino +board = esp32-s3-devkitc-1 +monitor_speed = 115200 +lib_deps = + lewisxhe/XPowersLib@0.3.3 + Wire + olikraus/U8g2@^2.36.4 + +build_flags = + -I ../../shared/boards + -I ../../external/microReticulum_Firmware + -D BOARD_MODEL=BOARD_TBEAM_S_V1 + -D OLED_SDA=17 + -D OLED_SCL=18 + -D OLED_ADDR=0x3C + -D ARDUINO_USB_MODE=1 + -D ARDUINO_USB_CDC_ON_BOOT=1 + +[env:amy] +extends = env +upload_port = /dev/ttytAMY +monitor_port = /dev/ttytAMY +build_flags = + ${env.build_flags} + -D NODE_LABEL=\"AMY\" + +[env:bob] +extends = env +upload_port = /dev/ttytBOB +monitor_port = /dev/ttytBOB +build_flags = + ${env.build_flags} + -D NODE_LABEL=\"BOB\" + +[env:cy] +extends = env +upload_port = /dev/ttytCY +monitor_port = /dev/ttytCY +build_flags = + ${env.build_flags} + -D NODE_LABEL=\"CY\" + +[env:dan] +extends = env +upload_port = /dev/ttytDAN +monitor_port = /dev/ttytDAN +build_flags = + ${env.build_flags} + -D NODE_LABEL=\"DAN\" + +[env:ed] +extends = env +upload_port = /dev/ttytED +monitor_port = /dev/ttytED +build_flags = + ${env.build_flags} + -D NODE_LABEL=\"ED\" diff --git a/exercises/14_Power/src/main.cpp b/exercises/14_Power/src/main.cpp new file mode 100644 index 0000000..ef300be --- /dev/null +++ b/exercises/14_Power/src/main.cpp @@ -0,0 +1,192 @@ +// 20260220 Codex +// Exercise 14: Power / Charging Visual Indicator + +#include +#include +#include +#include +#include + +#include "tbeam_supreme_adapter.h" + +#ifndef NODE_LABEL +#define NODE_LABEL "POWER" +#endif + +#ifndef OLED_SDA +#define OLED_SDA 17 +#endif +#ifndef OLED_SCL +#define OLED_SCL 18 +#endif +#ifndef OLED_ADDR +#define OLED_ADDR 0x3C +#endif + +static XPowersLibInterface *g_pmu = nullptr; +static U8G2_SH1106_128X64_NONAME_F_HW_I2C g_oled(U8G2_R0, U8X8_PIN_NONE); + +static const uint32_t kBlinkIntervalMs = 500; +static const uint32_t kStatusIntervalMs = 1000; +static const uint32_t kSerialIntervalMs = 2000; +static const uint32_t kStartupDisplayMs = 120000; + +static bool g_ledOn = false; +static uint32_t g_lastBlinkMs = 0; +static uint32_t g_lastStatusMs = 0; +static uint32_t g_lastSerialMs = 0; +static uint32_t g_bootMs = 0; + +static void oledShow(const char *l1, + const char *l2 = nullptr, + const char *l3 = nullptr, + const char *l4 = nullptr, + const char *l5 = nullptr) +{ + g_oled.clearBuffer(); + g_oled.setFont(u8g2_font_5x8_tf); + if (l1) g_oled.drawUTF8(0, 12, l1); + if (l2) g_oled.drawUTF8(0, 24, l2); + if (l3) g_oled.drawUTF8(0, 36, l3); + if (l4) g_oled.drawUTF8(0, 48, l4); + if (l5) g_oled.drawUTF8(0, 60, l5); + g_oled.sendBuffer(); +} + +static void setChargeLed(bool on) +{ + if (!g_pmu) return; + g_pmu->setChargingLedMode(on ? XPOWERS_CHG_LED_ON : XPOWERS_CHG_LED_OFF); +} + +static void setupChargingDefaults() +{ + if (!g_pmu) return; + g_pmu->setChargeTargetVoltage(XPOWERS_AXP2101_CHG_VOL_4V2); + g_pmu->setChargerConstantCurr(XPOWERS_AXP2101_CHG_CUR_500MA); +} + +static const char *powerState(bool batteryPresent, bool usbPresent, bool fullyCharged, bool chargingNow) +{ + if (!batteryPresent) return "NO BATTERY"; + if (!usbPresent) return "USB NOT PRESENT"; + if (fullyCharged) return "FULL"; + if (chargingNow) return "CHARGING"; + return "IDLE"; +} + +static void formatDisplayTime(char *out, size_t outSize) +{ + const time_t now = time(nullptr); + if (now > 1700000000) { + struct tm tmNow; + localtime_r(&now, &tmNow); + snprintf(out, outSize, "Time: %02d:%02d:%02d", tmNow.tm_hour, tmNow.tm_min, tmNow.tm_sec); + return; + } + + uint32_t sec = millis() / 1000; + uint32_t hh = sec / 3600; + uint32_t mm = (sec % 3600) / 60; + uint32_t ss = sec % 60; + snprintf(out, outSize, "Uptime: %02lu:%02lu:%02lu", (unsigned long)hh, (unsigned long)mm, (unsigned long)ss); +} + +void setup() +{ + g_bootMs = millis(); + + Serial.begin(115200); + delay(1200); + Serial.println("Exercise 14_Power boot"); + + Wire.begin(OLED_SDA, OLED_SCL); + g_oled.setI2CAddress(OLED_ADDR << 1); + g_oled.begin(); + oledShow("Exercise 14 Power", "Node: " NODE_LABEL, "Booting..."); + + if (!tbeam_supreme::initPmuForPeripherals(g_pmu, &Serial)) { + Serial.println("ERROR: PMU init failed"); + oledShow("Exercise 14 Power", "Node: " NODE_LABEL, "PMU init FAILED"); + return; + } + + setupChargingDefaults(); + setChargeLed(false); + + Serial.println("PMU init OK"); + oledShow("Exercise 14 Power", "Node: " NODE_LABEL, "PMU ready"); +} + +void loop() +{ + if (!g_pmu) { + delay(1000); + return; + } + + const bool batteryPresent = g_pmu->isBatteryConnect(); + const bool usbPresent = g_pmu->isVbusIn(); + const bool chargingNow = g_pmu->isCharging(); + const int battPercent = g_pmu->getBatteryPercent(); + const float battV = g_pmu->getBattVoltage() / 1000.0f; + + const bool fullyCharged = batteryPresent && battPercent >= 100; + const bool shouldCharge = batteryPresent && usbPresent && !fullyCharged; + + if (shouldCharge) { + if (millis() - g_lastBlinkMs >= kBlinkIntervalMs) { + g_lastBlinkMs = millis(); + g_ledOn = !g_ledOn; + setChargeLed(g_ledOn); + } + } else { + g_ledOn = false; + setChargeLed(false); + } + + if (millis() - g_lastStatusMs >= kStatusIntervalMs) { + g_lastStatusMs = millis(); + + char l1[32]; + char l2[32]; + char l3[32]; + char l4[32]; + char l5[32]; + + const char *state = powerState(batteryPresent, usbPresent, fullyCharged, chargingNow); + const bool startupWindow = (millis() - g_bootMs) < kStartupDisplayMs; + + if (startupWindow) { + snprintf(l1, sizeof(l1), "Exercise 14 Power"); + } else { + snprintf(l1, sizeof(l1), "Power Monitor"); + } + + snprintf(l2, sizeof(l2), "Node: %s", NODE_LABEL); + formatDisplayTime(l3, sizeof(l3)); + snprintf(l4, sizeof(l4), "State: %s", state); + if (battPercent >= 0) { + snprintf(l5, sizeof(l5), "VBAT:%.3fV %d%%", battV, battPercent); + } else { + snprintf(l5, sizeof(l5), "VBAT:%.3fV pct:?", battV); + } + + oledShow(l1, l2, l3, l4, l5); + } + + if (millis() - g_lastSerialMs >= kSerialIntervalMs) { + g_lastSerialMs = millis(); + Serial.printf("node=%s usb=%u batt=%u charging=%u full=%u led=%u vbatt=%.3fV pct=%d\r\n", + NODE_LABEL, + usbPresent ? 1 : 0, + batteryPresent ? 1 : 0, + chargingNow ? 1 : 0, + fullyCharged ? 1 : 0, + g_ledOn ? 1 : 0, + battV, + battPercent); + } + + delay(20); +} diff --git a/exercises/15_RAM/README.md b/exercises/15_RAM/README.md new file mode 100644 index 0000000..7f35cf7 --- /dev/null +++ b/exercises/15_RAM/README.md @@ -0,0 +1,16 @@ +# Exercise 15: RAM + +This exercise shows available RAM on the console and on the OLED display on a T-Beam Supreme. + +Behavior: +- Reports heap statistics every second over serial. +- Shows live heap status on the OLED display. +- Designed as the first step toward volatile /tmp RAM-backed storage. + +Build and upload: + +```bash +cd /usr/local/src/microreticulum/microReticulumTbeam/exercises/15_RAM +pio run -e amy -t upload +pio device monitor -b 115200 +``` diff --git a/exercises/15_RAM/platformio.ini b/exercises/15_RAM/platformio.ini new file mode 100644 index 0000000..f8e943d --- /dev/null +++ b/exercises/15_RAM/platformio.ini @@ -0,0 +1,55 @@ +; 20260403 ChatGPT +; Exercise 15_RAM + +[platformio] +default_envs = amy + +[env] +platform = espressif32 +framework = arduino +board = esp32-s3-devkitc-1 +monitor_speed = 115200 +extra_scripts = pre:scripts/set_build_epoch.py +lib_deps = + Wire + olikraus/U8g2@^2.36.4 + +build_flags = + -I ../../shared/boards + -I ../../external/microReticulum_Firmware + -D BOARD_MODEL=BOARD_TBEAM_S_V1 + -D OLED_SDA=17 + -D OLED_SCL=18 + -D OLED_ADDR=0x3C + -D ARDUINO_USB_MODE=1 + -D ARDUINO_USB_CDC_ON_BOOT=1 + +[env:amy] +extends = env +build_flags = + ${env.build_flags} + -D NODE_LABEL=\"AMY\" + +[env:bob] +extends = env +build_flags = + ${env.build_flags} + -D NODE_LABEL=\"BOB\" + +[env:cy] +extends = env +build_flags = + ${env.build_flags} + -D NODE_LABEL=\"CY\" + +[env:dan] +extends = env +build_flags = + ${env.build_flags} + -D NODE_LABEL=\"DAN\" + +[env:ed] +extends = env +build_flags = + ${env.build_flags} + -D NODE_LABEL=\"ED\" diff --git a/exercises/15_RAM/scripts/set_build_epoch.py b/exercises/15_RAM/scripts/set_build_epoch.py new file mode 100644 index 0000000..3011129 --- /dev/null +++ b/exercises/15_RAM/scripts/set_build_epoch.py @@ -0,0 +1,12 @@ +import time +Import("env") + +epoch = int(time.time()) +utc_tag = time.strftime("%Y%m%d_%H%M%S_z", time.gmtime(epoch)) + +env.Append( + CPPDEFINES=[ + ("FW_BUILD_EPOCH", str(epoch)), + ("FW_BUILD_UTC", '\\"%s\\"' % utc_tag), + ] +) diff --git a/exercises/15_RAM/src/main.cpp b/exercises/15_RAM/src/main.cpp new file mode 100644 index 0000000..7d535c1 --- /dev/null +++ b/exercises/15_RAM/src/main.cpp @@ -0,0 +1,272 @@ +// 20260403 ChatGPT +// Exercise 15_RAM + +#include +#include +#include +#include + +#ifndef NODE_LABEL +#define NODE_LABEL "RAM" +#endif + +#ifndef OLED_SDA +#define OLED_SDA 17 +#endif +#ifndef OLED_SCL +#define OLED_SCL 18 +#endif +#ifndef OLED_ADDR +#define OLED_ADDR 0x3C +#endif + +static U8G2_SH1106_128X64_NONAME_F_HW_I2C g_oled(U8G2_R0, U8X8_PIN_NONE); + +static const char *kTmpPath = "/tmp/AMY_output.log"; +static const size_t kTmpFileCapacity = 32768; +static char g_tmpFileBuffer[kTmpFileCapacity]; +static size_t g_tmpFileSize = 0; +static unsigned g_tmpLineNumber = 0; + +static void oledShowLines(const char *l1, + const char *l2 = nullptr, + const char *l3 = nullptr, + const char *l4 = nullptr, + const char *l5 = nullptr) +{ + g_oled.clearBuffer(); + g_oled.setFont(u8g2_font_5x8_tf); + if (l1) g_oled.drawUTF8(0, 12, l1); + if (l2) g_oled.drawUTF8(0, 24, l2); + if (l3) g_oled.drawUTF8(0, 36, l3); + if (l4) g_oled.drawUTF8(0, 48, l4); + if (l5) g_oled.drawUTF8(0, 60, l5); + g_oled.sendBuffer(); +} + +static size_t getAvailableRamBytes() +{ + return ESP.getFreeHeap(); +} + +static void getTimestamp(char *out, size_t outSize) +{ + const time_t now = time(nullptr); + if (now > 1700000000) { + struct tm tmNow; + localtime_r(&now, &tmNow); + snprintf(out, outSize, "%04d-%02d-%02d %02d:%02d:%02d", + tmNow.tm_year + 1900, + tmNow.tm_mon + 1, + tmNow.tm_mday, + tmNow.tm_hour, + tmNow.tm_min, + tmNow.tm_sec); + return; + } + + const uint32_t sec = millis() / 1000; + const uint32_t hh = sec / 3600; + const uint32_t mm = (sec % 3600) / 60; + const uint32_t ss = sec % 60; + snprintf(out, outSize, "uptime %02u:%02u:%02u", (unsigned)hh, (unsigned)mm, (unsigned)ss); +} + +static void appendTimestampLine() +{ + char timestamp[32]; + getTimestamp(timestamp, sizeof(timestamp)); + + char line[96]; + const int written = snprintf(line, sizeof(line), "%u, %s\r\n", g_tmpLineNumber + 1, timestamp); + if (written <= 0) { + return; + } + + const size_t lineLen = (size_t)written; + if (g_tmpFileSize + lineLen > kTmpFileCapacity - 1) { + Serial.println("Warning: /tmp log full, stopping writes"); + return; + } + + memcpy(g_tmpFileBuffer + g_tmpFileSize, line, lineLen); + g_tmpFileSize += lineLen; + g_tmpLineNumber++; +} + +static void printRamStatus() +{ + const size_t freeBytes = getAvailableRamBytes(); + const size_t totalBytes = ESP.getHeapSize(); + const size_t maxAlloc = ESP.getMaxAllocHeap(); + + Serial.printf("RAM total=%u free=%u maxAlloc=%u\r\n", (unsigned)totalBytes, (unsigned)freeBytes, (unsigned)maxAlloc); + + char line1[32]; + char line2[32]; + char line3[32]; + + snprintf(line1, sizeof(line1), "Exercise 15 RAM"); + snprintf(line2, sizeof(line2), "Node: %s", NODE_LABEL); + snprintf(line3, sizeof(line3), "Free: %u KB", (unsigned)(freeBytes / 1024U)); + char line4[32]; + snprintf(line4, sizeof(line4), "Total: %u KB", (unsigned)(totalBytes / 1024U)); + char line5[32]; + snprintf(line5, sizeof(line5), "Lines: %u", (unsigned)g_tmpLineNumber); + + oledShowLines(line1, line2, line3, line4, line5); +} + +static void showHelp() +{ + Serial.println("RAM command list:"); + Serial.println(" help - show this menu"); + Serial.println(" stat - show /tmp file state"); + Serial.println(" read - read /tmp contents"); + Serial.println(" clear - clear /tmp contents"); + Serial.println(" write - write text to /tmp"); + Serial.println(" append - append text to /tmp"); +} + +static void printTmpFileStat() +{ + Serial.printf("Path: %s\r\n", kTmpPath); + Serial.printf("Size: %u bytes\r\n", (unsigned)g_tmpFileSize); + Serial.printf("Lines: %u\r\n", (unsigned)g_tmpLineNumber); + Serial.printf("Capacity: %u bytes\r\n", (unsigned)kTmpFileCapacity); +} + +static void printTmpFileContents() +{ + if (g_tmpFileSize == 0) { + Serial.println("/tmp file is empty"); + return; + } + + Serial.print("/tmp contents: "); + Serial.write((const uint8_t *)g_tmpFileBuffer, g_tmpFileSize); + if (g_tmpFileBuffer[g_tmpFileSize - 1] != '\n') + Serial.println(); +} + +static void setTmpFileContent(const char *text) +{ + if (!text) { + g_tmpFileSize = 0; + return; + } + const size_t newLen = strlen(text); + if (newLen > kTmpFileCapacity - 1) { + Serial.printf("Error: content too large (%u/%u)\r\n", (unsigned)newLen, (unsigned)kTmpFileCapacity); + return; + } + memcpy(g_tmpFileBuffer, text, newLen); + g_tmpFileSize = newLen; +} + +static void appendTmpFileContent(const char *text) +{ + if (!text || text[0] == '\0') return; + const size_t textLen = strlen(text); + if (g_tmpFileSize + textLen > kTmpFileCapacity - 1) { + Serial.printf("Error: append would exceed %u bytes\r\n", (unsigned)kTmpFileCapacity); + return; + } + memcpy(g_tmpFileBuffer + g_tmpFileSize, text, textLen); + g_tmpFileSize += textLen; +} + +static void processSerialCommand(const char *line) +{ + if (!line || line[0] == '\0') return; + + char tmp[384]; + strncpy(tmp, line, sizeof(tmp) - 1); + tmp[sizeof(tmp) - 1] = '\0'; + + char *cmd = strtok(tmp, " \t\r\n"); + if (!cmd) return; + + if (strcasecmp(cmd, "help") == 0) { + showHelp(); + return; + } + + if (strcasecmp(cmd, "stat") == 0) { + printTmpFileStat(); + return; + } + + if (strcasecmp(cmd, "read") == 0) { + printTmpFileContents(); + return; + } + + if (strcasecmp(cmd, "clear") == 0) { + g_tmpFileSize = 0; + Serial.println("/tmp cleared"); + return; + } + + if (strcasecmp(cmd, "write") == 0 || strcasecmp(cmd, "append") == 0) { + const char *payload = line + strlen(cmd); + while (*payload == ' ' || *payload == '\t') payload++; + if (strcasecmp(cmd, "write") == 0) + setTmpFileContent(payload); + else + appendTmpFileContent(payload); + + Serial.printf("%s: %u bytes\r\n", cmd, + (unsigned)g_tmpFileSize); + return; + } + + Serial.println("Unknown command (help for list)"); +} + +void setup() +{ + Serial.begin(115200); + delay(800); + Serial.println("Exercise 15_RAM boot"); + + Wire.begin(OLED_SDA, OLED_SCL); + g_oled.setI2CAddress(OLED_ADDR << 1); + g_oled.begin(); + oledShowLines("Exercise 15_RAM", "Node: " NODE_LABEL, "Booting..."); + + delay(1000); +} + +void loop() +{ + static uint32_t lastMs = 0; + const uint32_t now = millis(); + + // check serial commands at all times + static char rxLine[384]; + static size_t rxLen = 0; + + while (Serial.available()) { + int c = Serial.read(); + if (c <= 0) continue; + if (c == '\r' || c == '\n') { + if (rxLen > 0) { + rxLine[rxLen] = '\0'; + processSerialCommand(rxLine); + rxLen = 0; + } + } else if (rxLen + 1 < sizeof(rxLine)) { + rxLine[rxLen++] = (char)c; + } + } + + if (now - lastMs < 1000) { + delay(10); + return; + } + lastMs = now; + + appendTimestampLine(); + printRamStatus(); +} diff --git a/exercises/16_PSRAM/README.md b/exercises/16_PSRAM/README.md new file mode 100644 index 0000000..06d8f39 --- /dev/null +++ b/exercises/16_PSRAM/README.md @@ -0,0 +1,32 @@ +# Exercise 16: PSRAM + +This exercise demonstrates usage of PSRAM (Pseudo SRAM) on an ESP32-S3 board, alongside regular RAM metrics. + +Behavior: +- Reports heap and PSRAM statistics every second over serial. +- Shows live heap and PSRAM status on the OLED display (both on same line). +- Allows you to write/append/read/clear data in a PSRAM-backed buffer (up to ~2MB). +- Designed as an extension of Exercise 15_RAM to explore larger volatile storage. + +Note: the exercise now targets a PSRAM-enabled ESP32-S3 board definition (`freenove_esp32_s3_wroom`). This board profile has 8MB flash + 8MB PSRAM, matching the T-Beam Supreme specifications. If your hardware differs, adjust accordingly. + +Sources: +- LilyGo T-Beam SUPREME datasheet/wiki: https://wiki.lilygo.cc/get_started/en/LoRa_GPS/T-Beam-SUPREME/T-Beam-SUPREME.html +- PlatformIO board definition: https://docs.platformio.org/page/boards/espressif32/freenove_esp32_s3_wroom.html +- Local PlatformIO board metadata: ~/.platformio/platforms/espressif32/boards/freenove_esp32_s3_wroom.json + +Build and upload: + +```bash +cd /usr/local/src/microreticulum/microReticulumTbeam/exercises/16_PSRAM +pio run -e amy -t upload +pio device monitor -b 115200 +``` + +Commands: +- `help` - show command menu +- `stat` - show PSRAM buffer state +- `read` - read PSRAM buffer contents +- `clear` - clear PSRAM buffer +- `write ` - write text to PSRAM buffer +- `append ` - append text to PSRAM buffer diff --git a/exercises/16_PSRAM/platformio.ini b/exercises/16_PSRAM/platformio.ini new file mode 100644 index 0000000..785b26c --- /dev/null +++ b/exercises/16_PSRAM/platformio.ini @@ -0,0 +1,54 @@ +; 20260403 ChatGPT +; Exercise 16_PSRAM + +[platformio] +default_envs = amy + +[env] +platform = espressif32 +framework = arduino +board = freenove_esp32_s3_wroom +monitor_speed = 115200 +extra_scripts = pre:scripts/set_build_epoch.py +lib_deps = + Wire + olikraus/U8g2@^2.36.4 + +build_flags = + -I ../../shared/boards + -I ../../external/microReticulum_Firmware + -D BOARD_MODEL=BOARD_TBEAM_S_V1 + -D OLED_SDA=17 + -D OLED_SCL=18 + -D OLED_ADDR=0x3C + -D ARDUINO_USB_MODE=1 + -D ARDUINO_USB_CDC_ON_BOOT=1 + +board_build.flash_mode = qio +board_build.psram = 1 +board_build.psram_type = spi +board_build.arduino.memory_type = qio_qspi + +[env:amy] +extends = env +build_flags = + ${env.build_flags} + -D NODE_LABEL=\"AMY\" + +[env:bob] +extends = env +build_flags = + ${env.build_flags} + -D NODE_LABEL=\"BOB\" + +[env:cy] +extends = env +build_flags = + ${env.build_flags} + -D NODE_LABEL=\"CY\" + +[env:dan] +extends = env +build_flags = + ${env.build_flags} + -D NODE_LABEL=\"DAN\" diff --git a/exercises/16_PSRAM/scripts/set_build_epoch.py b/exercises/16_PSRAM/scripts/set_build_epoch.py new file mode 100644 index 0000000..3011129 --- /dev/null +++ b/exercises/16_PSRAM/scripts/set_build_epoch.py @@ -0,0 +1,12 @@ +import time +Import("env") + +epoch = int(time.time()) +utc_tag = time.strftime("%Y%m%d_%H%M%S_z", time.gmtime(epoch)) + +env.Append( + CPPDEFINES=[ + ("FW_BUILD_EPOCH", str(epoch)), + ("FW_BUILD_UTC", '\\"%s\\"' % utc_tag), + ] +) diff --git a/exercises/16_PSRAM/src/main.cpp b/exercises/16_PSRAM/src/main.cpp new file mode 100644 index 0000000..ce3afa2 --- /dev/null +++ b/exercises/16_PSRAM/src/main.cpp @@ -0,0 +1,342 @@ +// 20260403 ChatGPT +// Exercise 16_PSRAM - Extended Exercise 15_RAM with PSRAM support + +#include +#include +#include +#include +#include + +#ifndef NODE_LABEL +#define NODE_LABEL "PSRAM" +#endif + +#ifndef OLED_SDA +#define OLED_SDA 17 +#endif +#ifndef OLED_SCL +#define OLED_SCL 18 +#endif +#ifndef OLED_ADDR +#define OLED_ADDR 0x3C +#endif + +static U8G2_SH1106_128X64_NONAME_F_HW_I2C g_oled(U8G2_R0, U8X8_PIN_NONE); + +static const size_t kTmpFileCapacity = 2097152; // 2MB in PSRAM +static char *g_tmpFileBuffer = nullptr; +static size_t g_tmpFileSize = 0; +static unsigned g_tmpLineNumber = 0; + +static void oledShowLines(const char *l1, + const char *l2 = nullptr, + const char *l3 = nullptr, + const char *l4 = nullptr, + const char *l5 = nullptr) +{ + g_oled.clearBuffer(); + g_oled.setFont(u8g2_font_5x8_tf); + if (l1) g_oled.drawUTF8(0, 12, l1); + if (l2) g_oled.drawUTF8(0, 24, l2); + if (l3) g_oled.drawUTF8(0, 36, l3); + if (l4) g_oled.drawUTF8(0, 48, l4); + if (l5) g_oled.drawUTF8(0, 60, l5); + g_oled.sendBuffer(); +} + +static size_t getAvailableRamBytes() +{ + return ESP.getFreeHeap(); +} + +static size_t getTotalRamBytes() +{ + return ESP.getHeapSize(); +} + +static size_t getPSRAMFreeBytes() +{ + size_t freeBytes = heap_caps_get_free_size(MALLOC_CAP_SPIRAM); + if (freeBytes == 0 && ESP.getFreePsram() > 0) { + freeBytes = ESP.getFreePsram(); + } + return freeBytes; +} + +static size_t getPSRAMTotalBytes() +{ + size_t totalBytes = heap_caps_get_total_size(MALLOC_CAP_SPIRAM); + if (totalBytes == 0 && ESP.getPsramSize() > 0) { + totalBytes = ESP.getPsramSize(); + } + return totalBytes; +} + +static void getTimestamp(char *out, size_t outSize) +{ + const time_t now = time(nullptr); + if (now > 1700000000) { + struct tm tmNow; + localtime_r(&now, &tmNow); + snprintf(out, outSize, "%04d-%02d-%02d %02d:%02d:%02d", + tmNow.tm_year + 1900, + tmNow.tm_mon + 1, + tmNow.tm_mday, + tmNow.tm_hour, + tmNow.tm_min, + tmNow.tm_sec); + return; + } + + const uint32_t sec = millis() / 1000; + const uint32_t hh = sec / 3600; + const uint32_t mm = (sec % 3600) / 60; + const uint32_t ss = sec % 60; + snprintf(out, outSize, "uptime %02u:%02u:%02u", (unsigned)hh, (unsigned)mm, (unsigned)ss); +} + +static void appendTimestampLine() +{ + if (!g_tmpFileBuffer) return; + + char timestamp[32]; + getTimestamp(timestamp, sizeof(timestamp)); + + char line[96]; + const int written = snprintf(line, sizeof(line), "%u, %s\r\n", g_tmpLineNumber + 1, timestamp); + if (written <= 0) { + return; + } + + const size_t lineLen = (size_t)written; + if (g_tmpFileSize + lineLen > kTmpFileCapacity - 1) { + Serial.println("Warning: PSRAM log full, stopping writes"); + return; + } + + memcpy(g_tmpFileBuffer + g_tmpFileSize, line, lineLen); + g_tmpFileSize += lineLen; + g_tmpLineNumber++; +} + +static void printRamStatus() +{ + const size_t freeRam = getAvailableRamBytes(); + const size_t totalRam = getTotalRamBytes(); + const size_t maxAllocRam = ESP.getMaxAllocHeap(); + + const size_t freePSRAM = getPSRAMFreeBytes(); + const size_t totalPSRAM = getPSRAMTotalBytes(); + + Serial.printf("RAM total=%u free=%u maxAlloc=%u | PSRAM total=%u free=%u\r\n", + (unsigned)totalRam, (unsigned)freeRam, (unsigned)maxAllocRam, + (unsigned)totalPSRAM, (unsigned)freePSRAM); + + char line1[32]; + char line2[32]; + char line3[32]; + char line4[32]; + char line5[32]; + + snprintf(line1, sizeof(line1), "Exercise 16 PSRAM"); + snprintf(line2, sizeof(line2), "Node: %s", NODE_LABEL); + + // Display format: "Free XXXKb/8.0Mbs" + const float psramMb = totalPSRAM / (1024.0f * 1024.0f); + const size_t ramKb = freeRam / 1024U; + snprintf(line3, sizeof(line3), "Free %uKb/%.1fMbs", (unsigned)ramKb, psramMb); + + snprintf(line4, sizeof(line4), "PSRAM: %u KB", (unsigned)(freePSRAM / 1024U)); + snprintf(line5, sizeof(line5), "Lines: %u", (unsigned)g_tmpLineNumber); + + oledShowLines(line1, line2, line3, line4, line5); +} + +static void showHelp() +{ + Serial.println("PSRAM command list:"); + Serial.println(" help - show this menu"); + Serial.println(" stat - show PSRAM buffer state"); + Serial.println(" read - read PSRAM buffer contents"); + Serial.println(" clear - clear PSRAM buffer contents"); + Serial.println(" write - write text to PSRAM buffer"); + Serial.println(" append - append text to PSRAM buffer"); +} + +static void printPSRAMFileStat() +{ + Serial.printf("PSRAM Buffer Capacity: %u bytes\r\n", (unsigned)kTmpFileCapacity); + Serial.printf("Current Size: %u bytes\r\n", (unsigned)g_tmpFileSize); + Serial.printf("Lines: %u\r\n", (unsigned)g_tmpLineNumber); + Serial.printf("PSRAM Total: %u bytes (%.2f MB)\r\n", (unsigned)getPSRAMTotalBytes(), + getPSRAMTotalBytes() / (1024.0f * 1024.0f)); + Serial.printf("PSRAM Free: %u bytes\r\n", (unsigned)getPSRAMFreeBytes()); +} + +static void printPSRAMFileContents() +{ + if (!g_tmpFileBuffer) { + Serial.println("PSRAM buffer not allocated"); + return; + } + + if (g_tmpFileSize == 0) { + Serial.println("PSRAM buffer is empty"); + return; + } + + Serial.print("PSRAM contents: "); + Serial.write((const uint8_t *)g_tmpFileBuffer, g_tmpFileSize); + if (g_tmpFileBuffer[g_tmpFileSize - 1] != '\n') + Serial.println(); +} + +static void setPSRAMFileContent(const char *text) +{ + if (!g_tmpFileBuffer) { + Serial.println("Error: PSRAM buffer not allocated"); + return; + } + + if (!text) { + g_tmpFileSize = 0; + g_tmpLineNumber = 0; + return; + } + const size_t newLen = strlen(text); + if (newLen > kTmpFileCapacity - 1) { + Serial.printf("Error: content too large (%u/%u)\r\n", (unsigned)newLen, (unsigned)kTmpFileCapacity); + return; + } + memcpy(g_tmpFileBuffer, text, newLen); + g_tmpFileSize = newLen; + g_tmpLineNumber = 0; +} + +static void appendPSRAMFileContent(const char *text) +{ + if (!g_tmpFileBuffer) { + Serial.println("Error: PSRAM buffer not allocated"); + return; + } + + if (!text || text[0] == '\0') return; + const size_t textLen = strlen(text); + if (g_tmpFileSize + textLen > kTmpFileCapacity - 1) { + Serial.printf("Error: append would exceed %u bytes\r\n", (unsigned)kTmpFileCapacity); + return; + } + memcpy(g_tmpFileBuffer + g_tmpFileSize, text, textLen); + g_tmpFileSize += textLen; +} + +static void processSerialCommand(const char *line) +{ + if (!line || line[0] == '\0') return; + + char tmp[384]; + strncpy(tmp, line, sizeof(tmp) - 1); + tmp[sizeof(tmp) - 1] = '\0'; + + char *cmd = strtok(tmp, " \t\r\n"); + if (!cmd) return; + + if (strcasecmp(cmd, "help") == 0) { + showHelp(); + return; + } + + if (strcasecmp(cmd, "stat") == 0) { + printPSRAMFileStat(); + return; + } + + if (strcasecmp(cmd, "read") == 0) { + printPSRAMFileContents(); + return; + } + + if (strcasecmp(cmd, "clear") == 0) { + g_tmpFileSize = 0; + g_tmpLineNumber = 0; + Serial.println("PSRAM buffer cleared"); + return; + } + + if (strcasecmp(cmd, "write") == 0 || strcasecmp(cmd, "append") == 0) { + const char *payload = line + strlen(cmd); + while (*payload == ' ' || *payload == '\t') payload++; + if (strcasecmp(cmd, "write") == 0) + setPSRAMFileContent(payload); + else + appendPSRAMFileContent(payload); + + Serial.printf("%s: %u bytes\r\n", cmd, + (unsigned)g_tmpFileSize); + return; + } + + Serial.println("Unknown command (help for list)"); +} + +void setup() +{ + Serial.begin(115200); + delay(800); + Serial.println("Exercise 16_PSRAM boot"); + + // Boot-time PSRAM diagnostics + Serial.printf("Boot PSRAM size: %u bytes\r\n", (unsigned)ESP.getPsramSize()); + Serial.printf("Boot PSRAM free: %u bytes\r\n", (unsigned)ESP.getFreePsram()); + + // Allocate PSRAM buffer + g_tmpFileBuffer = (char *)heap_caps_malloc(kTmpFileCapacity, MALLOC_CAP_SPIRAM); + if (!g_tmpFileBuffer) { + Serial.println("ERROR: Failed to allocate PSRAM buffer!"); + oledShowLines("Exercise 16_PSRAM", "Node: " NODE_LABEL, "PSRAM alloc FAILED"); + } else { + Serial.printf("PSRAM buffer allocated: %u bytes\r\n", (unsigned)kTmpFileCapacity); + } + + Wire.begin(OLED_SDA, OLED_SCL); + g_oled.setI2CAddress(OLED_ADDR << 1); + g_oled.begin(); + oledShowLines("Exercise 16_PSRAM", "Node: " NODE_LABEL, "Booting..."); + + delay(1000); +} + +void loop() +{ + static uint32_t lastMs = 0; + const uint32_t now = millis(); + + // check serial commands at all times + static char rxLine[384]; + static size_t rxLen = 0; + + while (Serial.available()) { + int c = Serial.read(); + if (c <= 0) continue; + if (c == '\r' || c == '\n') { + if (rxLen > 0) { + rxLine[rxLen] = '\0'; + processSerialCommand(rxLine); + rxLen = 0; + } + } else if (rxLen + 1 < sizeof(rxLine)) { + rxLine[rxLen++] = (char)c; + } + } + + if (now - lastMs < 1000) { + delay(10); + return; + } + lastMs = now; + + if (g_tmpFileBuffer) { + appendTimestampLine(); + } + printRamStatus(); +} diff --git a/exercises/17_Flash/README.md b/exercises/17_Flash/README.md new file mode 100644 index 0000000..cf123c6 --- /dev/null +++ b/exercises/17_Flash/README.md @@ -0,0 +1,32 @@ +# Exercise 17_Flash + +This exercise demonstrates using Flash storage as a persistent directory-like file system on an ESP32-S3 board. + +Behavior: +- Mounts SPIFFS at boot and reports total / used / free flash space. +- Ensures a flash directory at `/flash_logs` exists. +- Creates a new log file when the device boots, based on the current timestamp: `YYYYMMDD_HHMM.log`. +- Writes a timestamped line into the new log file once per second. +- Supports console commands to inspect the current file, read it, clear it, append or rewrite it, and list stored files. +- Files persist across reboots and are stored in flash. + +Build and upload: + +```bash +cd /usr/local/src/microreticulum/microReticulumTbeam/exercises/17_Flash +pio run -e amy -t upload +pio device monitor -b 115200 +``` + +Commands: +- `help` - show command menu +- `stat` - show flash / current file status +- `list` - list files under `/flash_logs` +- `read` - read the current flash file contents +- `clear` - clear the current flash file contents +- `write ` - overwrite the current flash file with text +- `append ` - append text to the current flash file + +Notes: +- If the current timestamp file name already exists, the exercise will append a numeric suffix to keep the file unique. +- On each reboot a new file is created so persistent flash logs accumulate. diff --git a/exercises/17_Flash/platformio.ini b/exercises/17_Flash/platformio.ini new file mode 100644 index 0000000..605b6c9 --- /dev/null +++ b/exercises/17_Flash/platformio.ini @@ -0,0 +1,50 @@ +; 20260403 ChatGPT +; Exercise 17_Flash + +[platformio] +default_envs = amy + +[env] +platform = espressif32 +framework = arduino +board = esp32-s3-devkitc-1 +monitor_speed = 115200 +extra_scripts = pre:scripts/set_build_epoch.py +lib_deps = + Wire + olikraus/U8g2@^2.36.4 + lewisxhe/XPowersLib@0.3.3 + +build_flags = + -I ../../shared/boards + -I ../../external/microReticulum_Firmware + -D BOARD_MODEL=BOARD_TBEAM_S_V1 + -D OLED_SDA=17 + -D OLED_SCL=18 + -D OLED_ADDR=0x3C + -D ARDUINO_USB_MODE=1 + -D ARDUINO_USB_CDC_ON_BOOT=1 + +[env:amy] +extends = env +build_flags = + ${env.build_flags} + -D NODE_LABEL=\"AMY\" + +[env:bob] +extends = env +build_flags = + ${env.build_flags} + -D NODE_LABEL=\"BOB\" + +[env:cy] +extends = env +build_flags = + ${env.build_flags} + -D NODE_LABEL=\"CY\" + +[env:dan] +extends = env +build_flags = + ${env.build_flags} + -D NODE_LABEL=\"DAN\" diff --git a/exercises/17_Flash/read_partition_bin.py b/exercises/17_Flash/read_partition_bin.py new file mode 100644 index 0000000..3dc21d7 --- /dev/null +++ b/exercises/17_Flash/read_partition_bin.py @@ -0,0 +1,11 @@ +import struct +with open('AMY_test_partitions_read.bin', 'rb') as f: + data = f.read() + seq0 = struct.unpack(' seq1: + print("→ app0 is active, new uploads go to app1") + else: + print("→ app1 is active, new uploads go to app0") diff --git a/exercises/17_Flash/scripts/set_build_epoch.py b/exercises/17_Flash/scripts/set_build_epoch.py new file mode 100644 index 0000000..44b46a0 --- /dev/null +++ b/exercises/17_Flash/scripts/set_build_epoch.py @@ -0,0 +1,12 @@ +import time +Import("env") + +epoch = int(time.time()) +utc_tag = time.strftime("%Y%m%d_%H%M%S_z", time.gmtime(epoch)) + +env.Append( + CPPDEFINES=[ + ("FW_BUILD_EPOCH", str(epoch)), + ("FW_BUILD_UTC", '\"%s\"' % utc_tag), + ] +) diff --git a/exercises/17_Flash/show_partition_table.py b/exercises/17_Flash/show_partition_table.py new file mode 100644 index 0000000..3bf3a7e --- /dev/null +++ b/exercises/17_Flash/show_partition_table.py @@ -0,0 +1,26 @@ +import struct + +with open('partitions_backup.bin', 'rb') as f: + data = f.read() + +print("Name | Type | SubType | Offset | Size | Flags") +print("-" * 75) + +for i in range(0, len(data), 32): + entry = data[i:i+32] + if len(entry) < 32: + break + + magic = struct.unpack(' +#include +#include +#include +#include + +#include "tbeam_supreme_adapter.h" + +#ifndef NODE_LABEL +#define NODE_LABEL "FLASH" +#endif + +#ifndef RTC_I2C_ADDR +#define RTC_I2C_ADDR 0x51 +#endif + +#ifndef OLED_SDA +#define OLED_SDA 17 +#endif +#ifndef OLED_SCL +#define OLED_SCL 18 +#endif +#ifndef OLED_ADDR +#define OLED_ADDR 0x3C +#endif + +static U8G2_SH1106_128X64_NONAME_F_HW_I2C g_oled(U8G2_R0, U8X8_PIN_NONE); +static const char *kFlashDir = "/flash_logs"; +static char g_currentFilePath[64] = {0}; +static File g_flashFile; +static unsigned g_flashLineNumber = 0; +static XPowersLibInterface* g_pmu = nullptr; +static bool g_hasRtc = false; +static bool g_rtcLowVoltage = false; + +struct RtcDateTime { + uint16_t year; + uint8_t month; + uint8_t day; + uint8_t hour; + uint8_t minute; + uint8_t second; + uint8_t weekday; +}; + +static void oledShowLines(const char *l1, + const char *l2 = nullptr, + const char *l3 = nullptr, + const char *l4 = nullptr, + const char *l5 = nullptr) +{ + g_oled.clearBuffer(); + g_oled.setFont(u8g2_font_5x8_tf); + if (l1) g_oled.drawUTF8(0, 12, l1); + if (l2) g_oled.drawUTF8(0, 24, l2); + if (l3) g_oled.drawUTF8(0, 36, l3); + if (l4) g_oled.drawUTF8(0, 48, l4); + if (l5) g_oled.drawUTF8(0, 60, l5); + g_oled.sendBuffer(); +} + +static size_t getFlashTotalBytes() +{ + return SPIFFS.totalBytes(); +} + +static size_t getFlashUsedBytes() +{ + return SPIFFS.usedBytes(); +} + +static size_t getFlashFreeBytes() +{ + const size_t total = getFlashTotalBytes(); + const size_t used = getFlashUsedBytes(); + return total > used ? total - used : 0; +} + +static uint8_t toBcd(uint8_t v) { + return ((v / 10U) << 4U) | (v % 10U); +} + +static uint8_t fromBcd(uint8_t b) { + return ((b >> 4U) * 10U) + (b & 0x0FU); +} + +static bool isRtcDateTimeValid(const RtcDateTime& dt) { + if (dt.year < 2020 || dt.year > 2099) return false; + if (dt.month < 1 || dt.month > 12) return false; + if (dt.day < 1 || dt.day > 31) return false; + if (dt.hour > 23 || dt.minute > 59 || dt.second > 59) return false; + return true; +} + +static bool rtcRead(RtcDateTime& out, bool& lowVoltageFlag) { + Wire1.beginTransmission(RTC_I2C_ADDR); + Wire1.write(0x02); + if (Wire1.endTransmission(false) != 0) { + return false; + } + + const uint8_t need = 7; + uint8_t got = Wire1.requestFrom((int)RTC_I2C_ADDR, (int)need); + if (got != need) { + return false; + } + + uint8_t sec = Wire1.read(); + uint8_t min = Wire1.read(); + uint8_t hour = Wire1.read(); + uint8_t day = Wire1.read(); + uint8_t weekday = Wire1.read(); + uint8_t month = Wire1.read(); + uint8_t year = Wire1.read(); + + lowVoltageFlag = (sec & 0x80U) != 0; + out.second = fromBcd(sec & 0x7FU); + out.minute = fromBcd(min & 0x7FU); + out.hour = fromBcd(hour & 0x3FU); + out.day = fromBcd(day & 0x3FU); + out.weekday = fromBcd(weekday & 0x07U); + out.month = fromBcd(month & 0x1FU); + uint8_t yy = fromBcd(year); + bool century = (month & 0x80U) != 0; + out.year = century ? (1900U + yy) : (2000U + yy); + + return true; +} + +static bool initRtc() { + if (!tbeam_supreme::initPmuForPeripherals(g_pmu, &Serial)) { + Serial.println("RTC init: PMU/i2c init failed"); + return false; + } + RtcDateTime now{}; + if (!rtcRead(now, g_rtcLowVoltage) || !isRtcDateTimeValid(now)) { + Serial.println("RTC init: no valid time available"); + return false; + } + g_hasRtc = true; + Serial.printf("RTC init: %04u-%02u-%02u %02u:%02u:%02u%s\r\n", + (unsigned)now.year, (unsigned)now.month, (unsigned)now.day, + (unsigned)now.hour, (unsigned)now.minute, (unsigned)now.second, + g_rtcLowVoltage ? " [LOW_BATT]" : ""); + return true; +} + +static bool getRtcTimestamp(char *out, size_t outSize) { + if (!g_hasRtc) { + return false; + } + RtcDateTime now{}; + bool low = false; + if (!rtcRead(now, low) || !isRtcDateTimeValid(now)) { + return false; + } + g_rtcLowVoltage = low; + snprintf(out, outSize, "%04u-%02u-%02u %02u:%02u:%02u", + now.year, + now.month, + now.day, + now.hour, + now.minute, + now.second); + return true; +} + +static void getTimestamp(char *out, size_t outSize) +{ + if (getRtcTimestamp(out, outSize)) { + return; + } + + const time_t now = time(nullptr); + if (now > 1700000000) { + struct tm tmNow; + localtime_r(&now, &tmNow); + snprintf(out, outSize, "%04d-%02d-%02d %02d:%02d:%02d", + tmNow.tm_year + 1900, + tmNow.tm_mon + 1, + tmNow.tm_mday, + tmNow.tm_hour, + tmNow.tm_min, + tmNow.tm_sec); + return; + } + + const uint32_t sec = millis() / 1000; + const uint32_t hh = sec / 3600; + const uint32_t mm = (sec % 3600) / 60; + const uint32_t ss = sec % 60; + snprintf(out, outSize, "uptime %02u:%02u:%02u", (unsigned)hh, (unsigned)mm, (unsigned)ss); +} + +static void getFilenameTimestamp(char *out, size_t outSize) +{ + if (g_hasRtc) { + RtcDateTime now{}; + bool low = false; + if (rtcRead(now, low) && isRtcDateTimeValid(now)) { + snprintf(out, outSize, "%04u%02u%02u_%02u%02u", + now.year, + now.month, + now.day, + now.hour, + now.minute); + return; + } + } + + const time_t now = time(nullptr); + if (now > 1700000000) { + struct tm tmNow; + localtime_r(&now, &tmNow); + snprintf(out, outSize, "%04d%02d%02d_%02d%02d", + tmNow.tm_year + 1900, + tmNow.tm_mon + 1, + tmNow.tm_mday, + tmNow.tm_hour, + tmNow.tm_min); + return; + } + + const uint32_t sec = millis() / 1000; + const uint32_t hh = sec / 3600; + const uint32_t mm = (sec % 3600) / 60; + const uint32_t ss = sec % 60; + snprintf(out, outSize, "uptime_%02u%02u%02u", (unsigned)hh, (unsigned)mm, (unsigned)ss); +} + +static String getNewFlashFilePath() +{ + char baseName[64]; + getFilenameTimestamp(baseName, sizeof(baseName)); + + char candidate[96]; + snprintf(candidate, sizeof(candidate), "%s/%s.log", kFlashDir, baseName); + if (!SPIFFS.exists(candidate)) { + return String(candidate); + } + + int suffix = 1; + do { + snprintf(candidate, sizeof(candidate), "%s/%s-%d.log", kFlashDir, baseName, suffix); + suffix += 1; + } while (SPIFFS.exists(candidate)); + + return String(candidate); +} + +static bool ensureFlashDirectory() +{ + if (SPIFFS.exists(kFlashDir)) { + return true; + } + if (!SPIFFS.mkdir(kFlashDir)) { + Serial.printf("Warning: failed to create %s\r\n", kFlashDir); + return false; + } + return true; +} + +static bool openCurrentFlashFile(bool truncate = false) +{ + if (g_flashFile) { + g_flashFile.close(); + } + + if (truncate) { + g_flashFile = SPIFFS.open(g_currentFilePath, FILE_WRITE); + } else { + g_flashFile = SPIFFS.open(g_currentFilePath, FILE_APPEND); + } + + if (!g_flashFile) { + Serial.printf("ERROR: cannot open %s\r\n", g_currentFilePath); + return false; + } + return true; +} + +static bool createFlashLogFile() +{ + if (!ensureFlashDirectory()) { + return false; + } + + String path = getNewFlashFilePath(); + path.toCharArray(g_currentFilePath, sizeof(g_currentFilePath)); + + g_flashFile = SPIFFS.open(g_currentFilePath, FILE_WRITE); + if (!g_flashFile) { + Serial.printf("ERROR: could not create %s\r\n", g_currentFilePath); + return false; + } + + const char *header = "FLASH log file created\r\n"; + g_flashFile.print(header); + g_flashFile.flush(); + g_flashLineNumber = 0; + return true; +} + +static void appendFlashTimestampLine() +{ + if (!g_flashFile) { + return; + } + + char timestamp[32]; + getTimestamp(timestamp, sizeof(timestamp)); + + char line[96]; + const int written = snprintf(line, sizeof(line), "%u, %s\r\n", g_flashLineNumber + 1, timestamp); + if (written <= 0) { + return; + } + + const size_t lineLen = (size_t)written; + if (g_flashFile.write(reinterpret_cast(line), lineLen) != lineLen) { + Serial.println("Warning: flash write failed"); + return; + } + g_flashFile.flush(); + g_flashLineNumber += 1; +} + +static void printFlashStatus() +{ + const size_t total = getFlashTotalBytes(); + const size_t used = getFlashUsedBytes(); + const size_t freeBytes = getFlashFreeBytes(); + + Serial.printf("FLASH total=%u used=%u free=%u\r\n", + (unsigned)total, (unsigned)used, (unsigned)freeBytes); + + char line1[32]; + char line2[32]; + char line3[32]; + char line4[32]; + char line5[32]; + + snprintf(line1, sizeof(line1), "Exercise 17 Flash"); + snprintf(line2, sizeof(line2), "Node: %s", NODE_LABEL); + snprintf(line3, sizeof(line3), "Free: %u KB", (unsigned)(freeBytes / 1024U)); + snprintf(line4, sizeof(line4), "Used: %u KB", (unsigned)(used / 1024U)); + snprintf(line5, sizeof(line5), "Lines: %u", (unsigned)g_flashLineNumber); + + oledShowLines(line1, line2, line3, line4, line5); +} + +static void showHelp() +{ + Serial.println("Flash command list:"); + Serial.println(" help - show this menu"); + Serial.println(" stat - show flash/file state"); + Serial.println(" rtc - show RTC time status"); + Serial.println(" list - list files in /flash_logs"); + Serial.println(" read - read current flash file"); + Serial.println(" clear - clear current flash file"); + Serial.println(" write - overwrite current flash file"); + Serial.println(" append - append text to current flash file"); +} + +static void printFlashFileStat() +{ + Serial.printf("Current file: %s\r\n", g_currentFilePath); + if (!SPIFFS.exists(g_currentFilePath)) { + Serial.println("Current file missing"); + return; + } + + File file = SPIFFS.open(g_currentFilePath, FILE_READ); + if (!file) { + Serial.println("Unable to open current file for stats"); + return; + } + + Serial.printf("Size: %u bytes\r\n", (unsigned)file.size()); + Serial.printf("Lines written: %u\r\n", (unsigned)g_flashLineNumber); + file.close(); +} + +static void printFlashFileContents() +{ + if (!SPIFFS.exists(g_currentFilePath)) { + Serial.println("Current flash file does not exist"); + return; + } + + File file = SPIFFS.open(g_currentFilePath, FILE_READ); + if (!file) { + Serial.println("Unable to open current flash file"); + return; + } + + if (file.size() == 0) { + Serial.println("Current flash file is empty"); + file.close(); + return; + } + + Serial.print("Flash file contents: "); + while (file.available()) { + Serial.write(file.read()); + } + if (file.size() > 0) { + Serial.println(); + } + file.close(); +} + +static void clearFlashFileContents() +{ + if (!SPIFFS.exists(g_currentFilePath)) { + Serial.println("No current file to clear"); + return; + } + + if (!openCurrentFlashFile(true)) { + return; + } + g_flashFile.close(); + g_flashLineNumber = 0; + openCurrentFlashFile(false); + Serial.println("Current flash file cleared"); +} + +static void setFlashFileContent(const char *text) +{ + if (!text) { + clearFlashFileContents(); + return; + } + + File file = SPIFFS.open(g_currentFilePath, FILE_WRITE); + if (!file) { + Serial.println("Unable to overwrite current flash file"); + return; + } + + file.print(text); + file.close(); + openCurrentFlashFile(false); + g_flashLineNumber = 0; +} + +static void appendFlashFileContent(const char *text) +{ + if (!text || text[0] == '\0') { + return; + } + + if (!openCurrentFlashFile(false)) { + return; + } + + g_flashFile.print(text); + g_flashFile.flush(); +} + +static void listFlashFiles() +{ + File dir = SPIFFS.open(kFlashDir); + if (!dir || !dir.isDirectory()) { + Serial.printf("Unable to list files in %s\r\n", kFlashDir); + return; + } + + Serial.printf("Files in %s:\r\n", kFlashDir); + File file = dir.openNextFile(); + while (file) { + Serial.printf(" %s (%u bytes)\r\n", file.name(), (unsigned)file.size()); + file = dir.openNextFile(); + } + dir.close(); +} + +static void processSerialCommand(const char *line) +{ + if (!line || line[0] == '\0') return; + + char tmp[384]; + strncpy(tmp, line, sizeof(tmp) - 1); + tmp[sizeof(tmp) - 1] = '\0'; + + char *cmd = strtok(tmp, " \t\r\n"); + if (!cmd) return; + + if (strcasecmp(cmd, "help") == 0) { + showHelp(); + return; + } + + if (strcasecmp(cmd, "stat") == 0) { + printFlashFileStat(); + return; + } + + if (strcasecmp(cmd, "rtc") == 0) { + if (g_hasRtc) { + char ts[32]; + if (getRtcTimestamp(ts, sizeof(ts))) { + Serial.printf("RTC now: %s\r\n", ts); + if (g_rtcLowVoltage) { + Serial.println("RTC low-voltage flag is set"); + } + } else { + Serial.println("RTC present but time read failed"); + } + } else { + Serial.println("RTC unavailable"); + } + return; + } + + if (strcasecmp(cmd, "list") == 0) { + listFlashFiles(); + return; + } + + if (strcasecmp(cmd, "read") == 0) { + printFlashFileContents(); + return; + } + + if (strcasecmp(cmd, "clear") == 0) { + clearFlashFileContents(); + return; + } + + if (strcasecmp(cmd, "write") == 0 || strcasecmp(cmd, "append") == 0) { + const char *payload = line + strlen(cmd); + while (*payload == ' ' || *payload == '\t') payload++; + if (strcasecmp(cmd, "write") == 0) + setFlashFileContent(payload); + else + appendFlashFileContent(payload); + + Serial.printf("%s: %s\r\n", cmd, payload); + return; + } + + Serial.println("Unknown command (help for list)"); +} + +void setup() +{ + Serial.begin(115200); + delay(800); + Serial.println("Exercise 17_Flash boot"); + + initRtc(); + + if (!SPIFFS.begin(true)) { + Serial.println("ERROR: SPIFFS mount failed"); + oledShowLines("Exercise 17_Flash", "Node: " NODE_LABEL, "SPIFFS mount FAILED"); + } else { + Serial.println("SPIFFS mounted successfully"); + if (createFlashLogFile()) { + Serial.printf("Current flash file: %s\r\n", g_currentFilePath); + } + } + + Wire.begin(OLED_SDA, OLED_SCL); + g_oled.setI2CAddress(OLED_ADDR << 1); + g_oled.begin(); + oledShowLines("Exercise 17_Flash", "Node: " NODE_LABEL, "Booting..."); + + delay(1000); +} + +void loop() +{ + static uint32_t lastMs = 0; + const uint32_t now = millis(); + + static char rxLine[384]; + static size_t rxLen = 0; + + while (Serial.available()) { + int c = Serial.read(); + if (c <= 0) continue; + if (c == '\r' || c == '\n') { + if (rxLen > 0) { + rxLine[rxLen] = '\0'; + processSerialCommand(rxLine); + rxLen = 0; + } + } else if (rxLen + 1 < sizeof(rxLine)) { + rxLine[rxLen++] = (char)c; + } + } + + if (now - lastMs < 1000) { + delay(10); + return; + } + lastMs = now; + + if (g_flashFile) { + appendFlashTimestampLine(); + } + printFlashStatus(); +} diff --git a/exercises/18_GPS_Field_QA/README.md b/exercises/18_GPS_Field_QA/README.md new file mode 100644 index 0000000..c5f0c7c --- /dev/null +++ b/exercises/18_GPS_Field_QA/README.md @@ -0,0 +1,38 @@ +## Exercise 18: GPS Field QA + +Survey/reconnaissance firmware for LilyGO T-Beam SUPREME. + +This exercise measures GNSS visibility and solution quality, disciplines the RTC from GNSS before creating any capture log, writes CSV captures to SD card, and exposes the SD tree over the field AP for download and erase operations. + +Current storage choice: + +- `SD` + +Current environments: + +- `bob_l76k` +- `guy_ublox` + +Primary serial commands: + +- `status` +- `summary` +- `ls` +- `cat ` +- `erase ` +- `stop` +- `start` +- `flush` +- `discipline` +- `erase_logs` + +Notes: + +- Default environment is `cy`. +- No log file is created until GNSS UTC plus PPS has disciplined the RTC. +- The capture file naming format is `YYYYMMDD_HHMMSS_.csv`. +- Samples are aggregated once per second. +- Records are double-buffered in RAM and flushed to SD every 10 seconds. +- Satellite snapshot records are written as additional CSV lines when GSV data is available. +- The web UI exposes SD download links and `/cmd?...` actions such as `erase=/logs/20260406_093912_CY.csv`. +- The implementation uses common NMEA parsing so it can normalize L76K and MAX-M10S output without adding a new GNSS dependency. diff --git a/exercises/18_GPS_Field_QA/lib/field_qa/ClockDiscipline.cpp b/exercises/18_GPS_Field_QA/lib/field_qa/ClockDiscipline.cpp new file mode 100644 index 0000000..dc7436d --- /dev/null +++ b/exercises/18_GPS_Field_QA/lib/field_qa/ClockDiscipline.cpp @@ -0,0 +1,241 @@ +#include "ClockDiscipline.h" + +#include "Config.h" + +namespace field_qa { + +ClockDiscipline::ClockDiscipline(TwoWire& wire) : m_wire(wire) {} + +uint8_t ClockDiscipline::toBcd(uint8_t value) { + return (uint8_t)(((value / 10U) << 4U) | (value % 10U)); +} + +uint8_t ClockDiscipline::fromBcd(uint8_t value) { + return (uint8_t)(((value >> 4U) * 10U) + (value & 0x0FU)); +} + +bool ClockDiscipline::isLeapYear(uint16_t year) { + return ((year % 4U) == 0U && (year % 100U) != 0U) || ((year % 400U) == 0U); +} + +uint8_t ClockDiscipline::daysInMonth(uint16_t year, uint8_t month) { + static const uint8_t kDays[12] = {31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31}; + if (month == 2U) { + return (uint8_t)(isLeapYear(year) ? 29U : 28U); + } + if (month >= 1U && month <= 12U) { + return kDays[month - 1U]; + } + return 0; +} + +bool ClockDiscipline::isValidDateTime(const ClockDateTime& dt) { + if (dt.year < 2000U || dt.year > 2099U) { + return false; + } + if (dt.month < 1U || dt.month > 12U) { + return false; + } + if (dt.day < 1U || dt.day > daysInMonth(dt.year, dt.month)) { + return false; + } + if (dt.hour > 23U || dt.minute > 59U || dt.second > 59U) { + return false; + } + return true; +} + +int64_t ClockDiscipline::daysFromCivil(int year, unsigned month, unsigned day) { + year -= (month <= 2U); + const int era = (year >= 0 ? year : year - 399) / 400; + const unsigned yoe = (unsigned)(year - era * 400); + const unsigned doy = (153U * (month + (month > 2U ? (unsigned)-3 : 9U)) + 2U) / 5U + day - 1U; + const unsigned doe = yoe * 365U + yoe / 4U - yoe / 100U + doy; + return era * 146097 + (int)doe - 719468; +} + +int64_t ClockDiscipline::toEpochSeconds(const ClockDateTime& dt) { + const int64_t days = daysFromCivil((int)dt.year, dt.month, dt.day); + return days * 86400LL + (int64_t)dt.hour * 3600LL + (int64_t)dt.minute * 60LL + (int64_t)dt.second; +} + +bool ClockDiscipline::fromEpochSeconds(int64_t seconds, ClockDateTime& out) { + if (seconds < 0) { + return false; + } + + int64_t days = seconds / 86400LL; + int64_t remainder = seconds % 86400LL; + if (remainder < 0) { + remainder += 86400LL; + days -= 1; + } + + out.hour = (uint8_t)(remainder / 3600LL); + remainder %= 3600LL; + out.minute = (uint8_t)(remainder / 60LL); + out.second = (uint8_t)(remainder % 60LL); + + days += 719468; + const int era = (days >= 0 ? days : days - 146096) / 146097; + const unsigned doe = (unsigned)(days - era * 146097); + const unsigned yoe = (doe - doe / 1460U + doe / 36524U - doe / 146096U) / 365U; + int year = (int)yoe + era * 400; + const unsigned doy = doe - (365U * yoe + yoe / 4U - yoe / 100U); + const unsigned mp = (5U * doy + 2U) / 153U; + const unsigned day = doy - (153U * mp + 2U) / 5U + 1U; + const unsigned month = mp + (mp < 10U ? 3U : (unsigned)-9); + year += (month <= 2U); + + out.year = (uint16_t)year; + out.month = (uint8_t)month; + out.day = (uint8_t)day; + return isValidDateTime(out); +} + +bool ClockDiscipline::readRtc(ClockDateTime& out, bool& lowVoltageFlag) const { + m_wire.beginTransmission(RTC_I2C_ADDR); + m_wire.write(0x02); + if (m_wire.endTransmission(false) != 0) { + return false; + } + + const uint8_t need = 7; + const uint8_t got = m_wire.requestFrom((int)RTC_I2C_ADDR, (int)need); + if (got != need) { + return false; + } + + const uint8_t sec = m_wire.read(); + const uint8_t min = m_wire.read(); + const uint8_t hour = m_wire.read(); + const uint8_t day = m_wire.read(); + (void)m_wire.read(); + const uint8_t month = m_wire.read(); + const uint8_t year = m_wire.read(); + + lowVoltageFlag = (sec & 0x80U) != 0; + out.second = fromBcd(sec & 0x7FU); + out.minute = fromBcd(min & 0x7FU); + out.hour = fromBcd(hour & 0x3FU); + out.day = fromBcd(day & 0x3FU); + out.month = fromBcd(month & 0x1FU); + const uint8_t yy = fromBcd(year); + out.year = (month & 0x80U) ? (1900U + yy) : (2000U + yy); + return true; +} + +bool ClockDiscipline::readValidRtc(ClockDateTime& out, int64_t* epochOut) const { + bool lowVoltage = false; + if (!readRtc(out, lowVoltage) || lowVoltage || !isValidDateTime(out)) { + return false; + } + if (epochOut != nullptr) { + *epochOut = toEpochSeconds(out); + } + return true; +} + +bool ClockDiscipline::writeRtc(const ClockDateTime& dt) const { + if (!isValidDateTime(dt)) { + return false; + } + + m_wire.beginTransmission(RTC_I2C_ADDR); + m_wire.write(0x02); + m_wire.write(toBcd(dt.second & 0x7FU)); + m_wire.write(toBcd(dt.minute)); + m_wire.write(toBcd(dt.hour)); + m_wire.write(toBcd(dt.day)); + m_wire.write(0x00); + + uint8_t monthReg = toBcd(dt.month); + if (dt.year < 2000U) { + monthReg |= 0x80U; + } + m_wire.write(monthReg); + m_wire.write(toBcd((uint8_t)(dt.year % 100U))); + return m_wire.endTransmission() == 0; +} + +void ClockDiscipline::formatIsoUtc(const ClockDateTime& dt, char* out, size_t outSize) { + snprintf(out, + outSize, + "%04u-%02u-%02uT%02u:%02u:%02uZ", + (unsigned)dt.year, + (unsigned)dt.month, + (unsigned)dt.day, + (unsigned)dt.hour, + (unsigned)dt.minute, + (unsigned)dt.second); +} + +void ClockDiscipline::formatCompactUtc(const ClockDateTime& dt, char* out, size_t outSize) { + snprintf(out, + outSize, + "%04u%02u%02u_%02u%02u%02u", + (unsigned)dt.year, + (unsigned)dt.month, + (unsigned)dt.day, + (unsigned)dt.hour, + (unsigned)dt.minute, + (unsigned)dt.second); +} + +void ClockDiscipline::makeRunId(const ClockDateTime& dt, const char* boardId, char* out, size_t outSize) { + snprintf(out, + outSize, + "%04u%02u%02u_%02u%02u%02u_%s", + (unsigned)dt.year, + (unsigned)dt.month, + (unsigned)dt.day, + (unsigned)dt.hour, + (unsigned)dt.minute, + (unsigned)dt.second, + boardId ? boardId : "NODE"); +} + +bool ClockDiscipline::fromGnssSample(const GnssSample& sample, ClockDateTime& out) { + if (!sample.validTime) { + return false; + } + out.year = sample.year; + out.month = sample.month; + out.day = sample.day; + out.hour = sample.hour; + out.minute = sample.minute; + out.second = sample.second; + return isValidDateTime(out); +} + +bool ClockDiscipline::disciplineFromGnss(const GnssSample& sample, + WaitForPpsCallback waitForPps, + void* context, + ClockDateTime& disciplinedUtc, + bool& hadPriorRtc, + int64_t& driftSeconds) const { + ClockDateTime gpsUtc{}; + if (!fromGnssSample(sample, gpsUtc) || waitForPps == nullptr) { + return false; + } + + ClockDateTime priorRtc{}; + hadPriorRtc = readValidRtc(priorRtc, nullptr); + + if (!waitForPps(context, kClockPpsWaitTimeoutMs)) { + return false; + } + + const int64_t snappedEpoch = toEpochSeconds(gpsUtc); + if (!fromEpochSeconds(snappedEpoch + 1, disciplinedUtc)) { + return false; + } + if (!writeRtc(disciplinedUtc)) { + return false; + } + + driftSeconds = hadPriorRtc ? (toEpochSeconds(priorRtc) - toEpochSeconds(disciplinedUtc)) : 0; + return true; +} + +} // namespace field_qa diff --git a/exercises/18_GPS_Field_QA/lib/field_qa/ClockDiscipline.h b/exercises/18_GPS_Field_QA/lib/field_qa/ClockDiscipline.h new file mode 100644 index 0000000..317186d --- /dev/null +++ b/exercises/18_GPS_Field_QA/lib/field_qa/ClockDiscipline.h @@ -0,0 +1,53 @@ +#pragma once + +#include +#include +#include "GnssTypes.h" + +namespace field_qa { + +struct ClockDateTime { + uint16_t year = 0; + uint8_t month = 0; + uint8_t day = 0; + uint8_t hour = 0; + uint8_t minute = 0; + uint8_t second = 0; +}; + +using WaitForPpsCallback = bool (*)(void* context, uint32_t timeoutMs); + +class ClockDiscipline { + public: + explicit ClockDiscipline(TwoWire& wire = Wire1); + + bool readRtc(ClockDateTime& out, bool& lowVoltageFlag) const; + bool readValidRtc(ClockDateTime& out, int64_t* epochOut = nullptr) const; + bool writeRtc(const ClockDateTime& dt) const; + + bool disciplineFromGnss(const GnssSample& sample, + WaitForPpsCallback waitForPps, + void* context, + ClockDateTime& disciplinedUtc, + bool& hadPriorRtc, + int64_t& driftSeconds) const; + + static bool isValidDateTime(const ClockDateTime& dt); + static int64_t toEpochSeconds(const ClockDateTime& dt); + static bool fromEpochSeconds(int64_t seconds, ClockDateTime& out); + static void formatIsoUtc(const ClockDateTime& dt, char* out, size_t outSize); + static void formatCompactUtc(const ClockDateTime& dt, char* out, size_t outSize); + static void makeRunId(const ClockDateTime& dt, const char* boardId, char* out, size_t outSize); + static bool fromGnssSample(const GnssSample& sample, ClockDateTime& out); + + private: + static uint8_t toBcd(uint8_t value); + static uint8_t fromBcd(uint8_t value); + static bool isLeapYear(uint16_t year); + static uint8_t daysInMonth(uint16_t year, uint8_t month); + static int64_t daysFromCivil(int year, unsigned month, unsigned day); + + TwoWire& m_wire; +}; + +} // namespace field_qa diff --git a/exercises/18_GPS_Field_QA/lib/field_qa/Config.h b/exercises/18_GPS_Field_QA/lib/field_qa/Config.h new file mode 100644 index 0000000..c15297a --- /dev/null +++ b/exercises/18_GPS_Field_QA/lib/field_qa/Config.h @@ -0,0 +1,84 @@ +#pragma once + +#include + +#ifndef BOARD_ID +#define BOARD_ID "BOB" +#endif + +#ifndef GNSS_CHIP_NAME +#define GNSS_CHIP_NAME "L76K" +#endif + +#ifndef OLED_SDA +#define OLED_SDA 17 +#endif + +#ifndef OLED_SCL +#define OLED_SCL 18 +#endif + +#ifndef OLED_ADDR +#define OLED_ADDR 0x3C +#endif + +#ifndef RTC_I2C_ADDR +#define RTC_I2C_ADDR 0x51 +#endif + +#ifndef GPS_BAUD +#define GPS_BAUD 9600 +#endif + +#ifndef GPS_RX_PIN +#define GPS_RX_PIN 9 +#endif + +#ifndef GPS_TX_PIN +#define GPS_TX_PIN 8 +#endif + +#ifndef BUTTON_PIN +#define BUTTON_PIN 0 +#endif + +#ifndef FW_BUILD_UTC +#define FW_BUILD_UTC unknown +#endif + +#define FIELD_QA_STR_INNER(x) #x +#define FIELD_QA_STR(x) FIELD_QA_STR_INNER(x) + +namespace field_qa { + +static constexpr const char* kExerciseName = "18_GPS_Field_QA"; +static constexpr const char* kFirmwareVersion = FIELD_QA_STR(FW_BUILD_UTC); +static constexpr const char* kBoardId = BOARD_ID; +static constexpr const char* kGnssChip = GNSS_CHIP_NAME; +static constexpr const char* kStorageName = "SD"; +static constexpr const char* kLogDir = "/logs"; +static constexpr const char* kLogApPrefix = "GPSQA-"; +static constexpr const char* kLogApPassword = ""; +static constexpr uint8_t kLogApIpOctet = 23; +static constexpr uint32_t kSerialDelayMs = 4000; +static constexpr uint32_t kSamplePeriodMs = 1000; +static constexpr uint32_t kLogFlushPeriodMs = 10000; +static constexpr uint32_t kDisplayPeriodMs = 1000; +static constexpr uint32_t kStatusPeriodMs = 1000; +static constexpr uint32_t kProbeWindowL76kMs = 20000; +static constexpr uint32_t kProbeWindowUbloxMs = 45000; +static constexpr uint32_t kFixFreshMs = 5000; +static constexpr uint8_t kPoorMinSatsUsed = 4; +static constexpr uint8_t kGoodMinSatsUsed = 10; +static constexpr uint8_t kExcellentMinSatsUsed = 16; +static constexpr float kMarginalHdop = 3.0f; +static constexpr float kExcellentHdop = 1.5f; +static constexpr size_t kBufferedSamples = 10; +static constexpr size_t kMaxSatellites = 64; +static constexpr size_t kStorageBufferBytes = 4096; +static constexpr uint32_t kClockDisciplineRetryMs = 5000; +static constexpr uint32_t kClockPpsWaitTimeoutMs = 1500; +static constexpr uint32_t kClockFreshSampleMs = 2000; +static constexpr uint32_t kMaxLogFilesBeforePause = 1000; + +} // namespace field_qa diff --git a/exercises/18_GPS_Field_QA/lib/field_qa/DisplayManager.cpp b/exercises/18_GPS_Field_QA/lib/field_qa/DisplayManager.cpp new file mode 100644 index 0000000..4a3cc42 --- /dev/null +++ b/exercises/18_GPS_Field_QA/lib/field_qa/DisplayManager.cpp @@ -0,0 +1,72 @@ +#include "DisplayManager.h" + +#include +#include "Config.h" + +namespace field_qa { + +namespace { + +static void formatElapsed(uint32_t ms, char* out, size_t outSize) { + const uint32_t sec = ms / 1000U; + const uint32_t hh = sec / 3600U; + const uint32_t mm = (sec % 3600U) / 60U; + const uint32_t ss = sec % 60U; + snprintf(out, outSize, "%02lu:%02lu:%02lu", (unsigned long)hh, (unsigned long)mm, (unsigned long)ss); +} + +} // namespace + +void DisplayManager::begin() { + Wire.begin(OLED_SDA, OLED_SCL); + m_oled.setI2CAddress(OLED_ADDR << 1); + m_oled.begin(); +} + +void DisplayManager::drawLines(const char* l1, + const char* l2, + const char* l3, + const char* l4, + const char* l5, + const char* l6) { + m_oled.clearBuffer(); + m_oled.setFont(u8g2_font_5x8_tf); + if (l1) m_oled.drawUTF8(0, 10, l1); + if (l2) m_oled.drawUTF8(0, 20, l2); + if (l3) m_oled.drawUTF8(0, 30, l3); + if (l4) m_oled.drawUTF8(0, 40, l4); + if (l5) m_oled.drawUTF8(0, 50, l5); + if (l6) m_oled.drawUTF8(0, 60, l6); + m_oled.sendBuffer(); +} + +void DisplayManager::showBoot(const char* line2, const char* line3) { + drawLines(kExerciseName, kFirmwareVersion, line2, line3); +} + +void DisplayManager::showError(const char* line1, const char* line2) { + drawLines(kExerciseName, "ERROR", line1, line2); +} + +void DisplayManager::showSample(const GnssSample& sample, const RunStats& stats, bool recording) { + char l1[24]; + char l2[20]; + char l3[20]; + char l4[20]; + char l5[20]; + char l6[20]; + + snprintf(l1, sizeof(l1), "%s", recording ? "*RECORDING" : "Halted"); + snprintf(l2, sizeof(l2), "FIX: %s", fixTypeToString(sample.fixType)); + snprintf(l3, sizeof(l3), "USED: %d/%d", sample.satsUsed < 0 ? 0 : sample.satsUsed, sample.satsInView < 0 ? 0 : sample.satsInView); + if (sample.validHdop) { + snprintf(l4, sizeof(l4), "HDOP: %.1f", sample.hdop); + } else { + snprintf(l4, sizeof(l4), "HDOP: --"); + } + snprintf(l5, sizeof(l5), "Q: %s", qualityClassForSample(sample)); + formatElapsed(stats.elapsedMs(millis()), l6, sizeof(l6)); + drawLines(l1, l2, l3, l4, l5, l6); +} + +} // namespace field_qa diff --git a/exercises/18_GPS_Field_QA/lib/field_qa/DisplayManager.h b/exercises/18_GPS_Field_QA/lib/field_qa/DisplayManager.h new file mode 100644 index 0000000..2859675 --- /dev/null +++ b/exercises/18_GPS_Field_QA/lib/field_qa/DisplayManager.h @@ -0,0 +1,28 @@ +#pragma once + +#include +#include +#include "GnssTypes.h" +#include "RunStats.h" + +namespace field_qa { + +class DisplayManager { + public: + void begin(); + void showBoot(const char* line2, const char* line3 = nullptr); + void showError(const char* line1, const char* line2 = nullptr); + void showSample(const GnssSample& sample, const RunStats& stats, bool recording); + + private: + void drawLines(const char* l1, + const char* l2 = nullptr, + const char* l3 = nullptr, + const char* l4 = nullptr, + const char* l5 = nullptr, + const char* l6 = nullptr); + + U8G2_SH1106_128X64_NONAME_F_HW_I2C m_oled{U8G2_R0, U8X8_PIN_NONE}; +}; + +} // namespace field_qa diff --git a/exercises/18_GPS_Field_QA/lib/field_qa/GnssManager.cpp b/exercises/18_GPS_Field_QA/lib/field_qa/GnssManager.cpp new file mode 100644 index 0000000..2269bd4 --- /dev/null +++ b/exercises/18_GPS_Field_QA/lib/field_qa/GnssManager.cpp @@ -0,0 +1,488 @@ +#include "GnssManager.h" + +#include +#include +#include +#include "Config.h" + +namespace field_qa { + +namespace { + +enum class GpsModuleKind : uint8_t { + Unknown = 0, + L76K, + Ublox +}; + +#if defined(GPS_UBLOX) +static constexpr GpsModuleKind kExpectedGpsModule = GpsModuleKind::Ublox; +#elif defined(GPS_L76K) +static constexpr GpsModuleKind kExpectedGpsModule = GpsModuleKind::L76K; +#else +static constexpr GpsModuleKind kExpectedGpsModule = GpsModuleKind::Unknown; +#endif + +static GpsModuleKind talkerToConstellation(const char* talker) { + if (!talker) return GpsModuleKind::Unknown; + if (strcmp(talker, "GP") == 0) return GpsModuleKind::L76K; + if (strcmp(talker, "GA") == 0) return GpsModuleKind::Ublox; + return GpsModuleKind::Unknown; +} + +static FixType fixTypeFromQuality(int quality, int dimension) { + switch (quality) { + case 2: + return FixType::Dgps; + case 4: + return FixType::RtkFixed; + case 5: + return FixType::RtkFloat; + default: + if (dimension >= 3) return FixType::Fix3D; + if (dimension == 2) return FixType::Fix2D; + return FixType::NoFix; + } +} + +static void copyTalker(const char* header, char* out) { + if (!header || strlen(header) < 3) { + out[0] = '?'; + out[1] = '?'; + out[2] = '\0'; + return; + } + out[0] = header[1]; + out[1] = header[2]; + out[2] = '\0'; +} + +} // namespace + +void GnssManager::begin() { + m_bootMs = millis(); + strlcpy(m_detectedChip, kGnssChip, sizeof(m_detectedChip)); +#ifdef GPS_1PPS_PIN + pinMode(GPS_1PPS_PIN, INPUT); +#endif +#ifdef GPS_WAKEUP_PIN + pinMode(GPS_WAKEUP_PIN, INPUT); +#endif + startUart(GPS_BAUD, GPS_RX_PIN, GPS_TX_PIN); +} + +void GnssManager::startUart(uint32_t baud, int rxPin, int txPin) { + m_serial.end(); + delay(20); + m_serial.setRxBufferSize(2048); + m_serial.begin(baud, SERIAL_8N1, rxPin, txPin); +} + +bool GnssManager::collectTraffic(uint32_t windowMs) { + uint32_t start = millis(); + bool sawBytes = false; + while ((uint32_t)(millis() - start) < windowMs) { + if (m_serial.available() > 0) { + sawBytes = true; + } + poll(); + delay(2); + } + return sawBytes || m_sawSentence; +} + +bool GnssManager::probeAtBaud(uint32_t baud, int rxPin, int txPin) { + startUart(baud, rxPin, txPin); + if (collectTraffic(700)) { + return true; + } + m_serial.write("$PCAS06,0*1B\r\n"); + m_serial.write("$PMTK605*31\r\n"); + m_serial.write("$PQTMVERNO*58\r\n"); + m_serial.write("$PUBX,00*33\r\n"); + m_serial.write("$PMTK353,1,1,1,1,1*2A\r\n"); + m_serial.write("$PMTK314,0,1,0,1,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0*29\r\n"); + return collectTraffic(1200); +} + +bool GnssManager::probeAtStartup(Stream& serialOut) { + const uint32_t bauds[] = {GPS_BAUD, 115200, 38400, 57600, 19200}; + int pins[2][2] = {{GPS_RX_PIN, GPS_TX_PIN}, {34, 12}}; + size_t pinCount = (kExpectedGpsModule == GpsModuleKind::Ublox && !(GPS_RX_PIN == 34 && GPS_TX_PIN == 12)) ? 2 : 1; + for (size_t p = 0; p < pinCount; ++p) { + for (size_t i = 0; i < sizeof(bauds) / sizeof(bauds[0]); ++i) { + if (probeAtBaud(bauds[i], pins[p][0], pins[p][1])) { + serialOut.printf("GPS traffic detected at baud=%lu rx=%d tx=%d\n", + (unsigned long)bauds[i], pins[p][0], pins[p][1]); + return true; + } + } + } + serialOut.println("WARNING: no GPS traffic detected during startup probe"); + return false; +} + +bool GnssManager::parseUInt2(const char* s, uint8_t& out) { + if (!s || !isdigit((unsigned char)s[0]) || !isdigit((unsigned char)s[1])) { + return false; + } + out = (uint8_t)((s[0] - '0') * 10 + (s[1] - '0')); + return true; +} + +double GnssManager::parseNmeaCoord(const char* value, const char* hemi) { + if (!value || !value[0] || !hemi || !hemi[0]) { + return 0.0; + } + double raw = atof(value); + double deg = floor(raw / 100.0); + double minutes = raw - (deg * 100.0); + double result = deg + minutes / 60.0; + if (hemi[0] == 'S' || hemi[0] == 'W') { + result = -result; + } + return result; +} + +int GnssManager::splitCsvPreserveEmpty(char* line, char* fields[], int maxFields) { + if (!line || !fields || maxFields <= 0) { + return 0; + } + int count = 0; + char* p = line; + fields[count++] = p; + while (*p && count < maxFields) { + if (*p == ',') { + *p = '\0'; + fields[count++] = p + 1; + } + ++p; + } + return count; +} + +void GnssManager::parseGga(char* fields[], int count) { + if (count < 10) { + return; + } + const int quality = atoi(fields[6]); + const int satsUsed = atoi(fields[7]); + if (satsUsed >= 0) { + m_state.satsUsed = satsUsed; + } + if (fields[8] && fields[8][0]) { + m_state.hdop = atof(fields[8]); + m_state.validHdop = true; + } + if (fields[9] && fields[9][0]) { + m_state.altitudeM = atof(fields[9]); + m_state.validAltitude = true; + } + if (fields[2] && fields[2][0] && fields[4] && fields[4][0]) { + m_state.latitude = parseNmeaCoord(fields[2], fields[3]); + m_state.longitude = parseNmeaCoord(fields[4], fields[5]); + m_state.validLocation = true; + } + if (quality > 0) { + m_state.validFix = true; + m_lastFixMs = millis(); + } else { + m_state.validFix = false; + } + m_state.fixType = fixTypeFromQuality(quality, m_state.fixDimension); +} + +void GnssManager::parseGsa(char* fields[], int count) { + if (count < 18) { + return; + } + const int dim = atoi(fields[2]); + m_state.fixDimension = dim; + if (count > 15 && fields[15] && fields[15][0]) { + m_state.pdop = atof(fields[15]); + m_state.validPdop = true; + } + if (count > 16 && fields[16] && fields[16][0]) { + m_state.hdop = atof(fields[16]); + m_state.validHdop = true; + } + if (count > 17 && fields[17] && fields[17][0]) { + m_state.vdop = atof(fields[17]); + m_state.validVdop = true; + } + + int satsUsed = 0; + m_usedPrnCount = 0; + for (int i = 3; i <= 14 && i < count; ++i) { + if (fields[i] && fields[i][0]) { + ++satsUsed; + if (m_usedPrnCount < sizeof(m_usedPrns) / sizeof(m_usedPrns[0])) { + m_usedPrns[m_usedPrnCount++] = (uint8_t)atoi(fields[i]); + } + } + } + if (satsUsed > 0) { + m_state.satsUsed = satsUsed; + } + if (dim >= 2) { + m_state.validFix = true; + m_lastFixMs = millis(); + } + m_state.fixType = fixTypeFromQuality(m_state.validFix ? 1 : 0, dim); +} + +void GnssManager::clearSatelliteView() { + m_satCount = 0; + for (size_t i = 0; i < kMaxSatellites; ++i) { + m_satellites[i] = SatelliteInfo{}; + } + m_state.gpsCount = 0; + m_state.galileoCount = 0; + m_state.glonassCount = 0; + m_state.beidouCount = 0; + m_state.navicCount = 0; + m_state.qzssCount = 0; + m_state.sbasCount = 0; + m_state.meanSnr = -1.0f; + m_state.maxSnr = 0; +} + +void GnssManager::finalizeSatelliteStats() { + uint32_t snrSum = 0; + uint32_t snrCount = 0; + for (size_t i = 0; i < m_satCount; ++i) { + SatelliteInfo& sat = m_satellites[i]; + if (!sat.valid) { + continue; + } + sat.usedInSolution = prnUsedInSolution(sat.prn); + if (strcmp(sat.talker, "GP") == 0 || strcmp(sat.talker, "GN") == 0) { + ++m_state.gpsCount; + } else if (strcmp(sat.talker, "GA") == 0) { + ++m_state.galileoCount; + } else if (strcmp(sat.talker, "GL") == 0) { + ++m_state.glonassCount; + } else if (strcmp(sat.talker, "GB") == 0 || strcmp(sat.talker, "BD") == 0) { + ++m_state.beidouCount; + } else if (strcmp(sat.talker, "GI") == 0) { + ++m_state.navicCount; + } else if (strcmp(sat.talker, "GQ") == 0) { + ++m_state.qzssCount; + } else if (strcmp(sat.talker, "GS") == 0) { + ++m_state.sbasCount; + } + if (sat.snr > 0) { + snrSum += sat.snr; + ++snrCount; + if (sat.snr > m_state.maxSnr) { + m_state.maxSnr = sat.snr; + } + } + } + m_state.meanSnr = snrCount > 0 ? ((float)snrSum / (float)snrCount) : -1.0f; +} + +void GnssManager::parseGsv(char* fields[], int count) { + if (count < 4) { + return; + } + const int totalMsgs = atoi(fields[1]); + const int msgNum = atoi(fields[2]); + const int satsInView = atoi(fields[3]); + if (msgNum == 1) { + clearSatelliteView(); + } + if (satsInView >= 0) { + m_state.satsInView = satsInView; + } + char talker[3]; + copyTalker(fields[0], talker); + for (int i = 4; i + 3 < count && m_satCount < kMaxSatellites; i += 4) { + if (!fields[i] || !fields[i][0]) { + continue; + } + SatelliteInfo& sat = m_satellites[m_satCount++]; + sat.valid = true; + sat.talker[0] = talker[0]; + sat.talker[1] = talker[1]; + sat.talker[2] = '\0'; + sat.prn = (uint8_t)atoi(fields[i]); + sat.usedInSolution = prnUsedInSolution(sat.prn); + sat.elevation = (uint8_t)atoi(fields[i + 1]); + sat.azimuth = (uint16_t)atoi(fields[i + 2]); + sat.snr = (uint8_t)atoi(fields[i + 3]); + } + if (msgNum == totalMsgs) { + finalizeSatelliteStats(); + } + m_lastGsvMs = millis(); +} + +bool GnssManager::prnUsedInSolution(uint8_t prn) const { + for (size_t i = 0; i < m_usedPrnCount; ++i) { + if (m_usedPrns[i] == prn) { + return true; + } + } + return false; +} + +void GnssManager::parseRmc(char* fields[], int count) { + if (count < 10) { + return; + } + const char* utc = fields[1]; + const char* status = fields[2]; + if (status && status[0] == 'A') { + m_state.validFix = true; + m_lastFixMs = millis(); + } + if (utc && strlen(utc) >= 6 && fields[9] && strlen(fields[9]) >= 6) { + uint8_t hh = 0, mm = 0, ss = 0, dd = 0, mo = 0, yy = 0; + if (parseUInt2(utc + 0, hh) && parseUInt2(utc + 2, mm) && parseUInt2(utc + 4, ss) && + parseUInt2(fields[9] + 0, dd) && parseUInt2(fields[9] + 2, mo) && parseUInt2(fields[9] + 4, yy)) { + m_state.hour = hh; + m_state.minute = mm; + m_state.second = ss; + m_state.day = dd; + m_state.month = mo; + m_state.year = (uint16_t)(2000U + yy); + m_state.validTime = true; + } + } + if (fields[3] && fields[3][0] && fields[5] && fields[5][0]) { + m_state.latitude = parseNmeaCoord(fields[3], fields[4]); + m_state.longitude = parseNmeaCoord(fields[5], fields[6]); + m_state.validLocation = true; + } + if (fields[7] && fields[7][0]) { + m_state.speedMps = (float)(atof(fields[7]) * 0.514444); + m_state.validSpeed = true; + } + if (fields[8] && fields[8][0]) { + m_state.courseDeg = atof(fields[8]); + m_state.validCourse = true; + } +} + +void GnssManager::parseVtg(char* fields[], int count) { + if (count > 1 && fields[1] && fields[1][0]) { + m_state.courseDeg = atof(fields[1]); + m_state.validCourse = true; + } + if (count > 7 && fields[7] && fields[7][0]) { + m_state.speedMps = (float)(atof(fields[7]) / 3.6); + m_state.validSpeed = true; + } +} + +void GnssManager::parseTxt(char* fields[], int count) { + if (count <= 4 || !fields[4]) { + return; + } + String text(fields[4]); + text.toUpperCase(); + if (text.indexOf("L76K") >= 0 || text.indexOf("QUECTEL") >= 0) { + strlcpy(m_detectedChip, "L76K", sizeof(m_detectedChip)); + } +} + +void GnssManager::processNmeaLine(char* line) { + if (!line || line[0] != '$') { + return; + } + m_sawSentence = true; + m_state.sawSentence = true; + char* star = strchr(line, '*'); + if (star) { + *star = '\0'; + } + char* fields[32] = {0}; + int count = splitCsvPreserveEmpty(line, fields, 32); + if (count <= 0 || !fields[0]) { + return; + } + if (strcmp(fields[0], "$PUBX") == 0) { + m_seenUbloxPubx = true; + strlcpy(m_detectedChip, "MAX-M10S", sizeof(m_detectedChip)); + return; + } + size_t n = strlen(fields[0]); + if (n < 6) { + return; + } + const char* type = fields[0] + (n - 3); + if (strcmp(type, "GGA") == 0) { + parseGga(fields, count); + } else if (strcmp(type, "GSA") == 0) { + parseGsa(fields, count); + } else if (strcmp(type, "GSV") == 0) { + parseGsv(fields, count); + } else if (strcmp(type, "RMC") == 0) { + parseRmc(fields, count); + } else if (strcmp(type, "VTG") == 0) { + parseVtg(fields, count); + } else if (strcmp(type, "TXT") == 0) { + parseTxt(fields, count); + } +} + +void GnssManager::poll() { +#ifdef GPS_1PPS_PIN + m_hasPps = (digitalRead(GPS_1PPS_PIN) == HIGH); +#endif + while (m_serial.available() > 0) { + char c = (char)m_serial.read(); + if (c == '\r') { + continue; + } + if (c == '\n') { + if (m_lineLen > 0) { + m_line[m_lineLen] = '\0'; + processNmeaLine(m_line); + m_lineLen = 0; + } + continue; + } + if (m_lineLen + 1 < sizeof(m_line)) { + m_line[m_lineLen++] = c; + } else { + m_lineLen = 0; + } + } +} + +GnssSample GnssManager::makeSample() const { + GnssSample sample = m_state; + sample.ppsSeen = m_hasPps; + sample.sampleMillis = millis(); + if (m_lastFixMs > 0) { + sample.ageOfFixMs = millis() - m_lastFixMs; + } + sample.ttffMs = (m_lastFixMs > 0) ? (m_lastFixMs - m_bootMs) : 0; + if (sample.fixType == FixType::NoFix) { + if (sample.fixDimension >= 3) { + sample.fixType = FixType::Fix3D; + } else if (sample.fixDimension == 2) { + sample.fixType = FixType::Fix2D; + } + } + return sample; +} + +size_t GnssManager::copySatellites(SatelliteInfo* out, size_t maxCount) const { + if (!out || maxCount == 0) { + return 0; + } + size_t n = m_satCount < maxCount ? m_satCount : maxCount; + for (size_t i = 0; i < n; ++i) { + out[i] = m_satellites[i]; + } + return n; +} + +const char* GnssManager::detectedChipName() const { + return m_detectedChip; +} + +} // namespace field_qa diff --git a/exercises/18_GPS_Field_QA/lib/field_qa/GnssManager.h b/exercises/18_GPS_Field_QA/lib/field_qa/GnssManager.h new file mode 100644 index 0000000..8b3d204 --- /dev/null +++ b/exercises/18_GPS_Field_QA/lib/field_qa/GnssManager.h @@ -0,0 +1,54 @@ +#pragma once + +#include +#include "Config.h" +#include "GnssTypes.h" + +namespace field_qa { + +class GnssManager { + public: + void begin(); + void poll(); + bool probeAtStartup(Stream& serialOut); + GnssSample makeSample() const; + size_t copySatellites(SatelliteInfo* out, size_t maxCount) const; + const char* detectedChipName() const; + + private: + void startUart(uint32_t baud, int rxPin, int txPin); + bool probeAtBaud(uint32_t baud, int rxPin, int txPin); + bool collectTraffic(uint32_t windowMs); + void processNmeaLine(char* line); + void parseGga(char* fields[], int count); + void parseGsa(char* fields[], int count); + void parseGsv(char* fields[], int count); + void parseRmc(char* fields[], int count); + void parseVtg(char* fields[], int count); + void parseTxt(char* fields[], int count); + int splitCsvPreserveEmpty(char* line, char* fields[], int maxFields); + static bool parseUInt2(const char* s, uint8_t& out); + static double parseNmeaCoord(const char* value, const char* hemi); + void clearSatelliteView(); + void finalizeSatelliteStats(); + bool prnUsedInSolution(uint8_t prn) const; + + HardwareSerial m_serial{1}; + char m_line[160] = {0}; + size_t m_lineLen = 0; + char m_detectedChip[16] = {0}; + bool m_sawSentence = false; + bool m_seenUbloxPubx = false; + bool m_hasPps = false; + + GnssSample m_state; + SatelliteInfo m_satellites[kMaxSatellites]; + uint8_t m_usedPrns[16] = {0}; + size_t m_usedPrnCount = 0; + size_t m_satCount = 0; + uint32_t m_lastGsvMs = 0; + uint32_t m_lastFixMs = 0; + uint32_t m_bootMs = 0; +}; + +} // namespace field_qa diff --git a/exercises/18_GPS_Field_QA/lib/field_qa/GnssTypes.cpp b/exercises/18_GPS_Field_QA/lib/field_qa/GnssTypes.cpp new file mode 100644 index 0000000..f5c26fa --- /dev/null +++ b/exercises/18_GPS_Field_QA/lib/field_qa/GnssTypes.cpp @@ -0,0 +1,41 @@ +#include "GnssTypes.h" +#include "Config.h" + +namespace field_qa { + +const char* fixTypeToString(FixType type) { + switch (type) { + case FixType::Fix2D: + return "2D"; + case FixType::Fix3D: + return "3D"; + case FixType::Dgps: + return "DGPS"; + case FixType::RtkFloat: + return "RTK_FLOAT"; + case FixType::RtkFixed: + return "RTK_FIXED"; + case FixType::NoFix: + default: + return "NO_FIX"; + } +} + +const char* qualityClassForSample(const GnssSample& sample) { + if (!sample.validFix || sample.fixDimension < 2 || sample.satsUsed < (int)kPoorMinSatsUsed || + (!sample.validHdop && sample.fixDimension < 3)) { + return "POOR"; + } + if (sample.fixDimension < 3 || sample.satsUsed < (int)kGoodMinSatsUsed || + (sample.validHdop && sample.hdop >= kMarginalHdop)) { + return "MARGINAL"; + } + if (sample.fixDimension >= 3 && sample.satsUsed >= (int)kExcellentMinSatsUsed && + sample.validHdop && sample.hdop < kExcellentHdop) { + return "EXCELLENT"; + } + return "GOOD"; +} + +} // namespace field_qa + diff --git a/exercises/18_GPS_Field_QA/lib/field_qa/GnssTypes.h b/exercises/18_GPS_Field_QA/lib/field_qa/GnssTypes.h new file mode 100644 index 0000000..6c0faa3 --- /dev/null +++ b/exercises/18_GPS_Field_QA/lib/field_qa/GnssTypes.h @@ -0,0 +1,78 @@ +#pragma once + +#include + +namespace field_qa { + +enum class FixType : uint8_t { + NoFix = 0, + Fix2D, + Fix3D, + Dgps, + RtkFloat, + RtkFixed +}; + +struct SatelliteInfo { + bool valid = false; + bool usedInSolution = false; + char talker[3] = {'?', '?', '\0'}; + uint8_t prn = 0; + uint8_t elevation = 0; + uint16_t azimuth = 0; + uint8_t snr = 0; +}; + +struct GnssSample { + bool sawSentence = false; + bool validTime = false; + bool validFix = false; + bool validLocation = false; + bool validAltitude = false; + bool validCourse = false; + bool validSpeed = false; + bool validHdop = false; + bool validVdop = false; + bool validPdop = false; + bool ppsSeen = false; + + FixType fixType = FixType::NoFix; + int fixDimension = 0; + int satsInView = -1; + int satsUsed = -1; + float hdop = -1.0f; + float vdop = -1.0f; + float pdop = -1.0f; + double latitude = 0.0; + double longitude = 0.0; + double altitudeM = 0.0; + float speedMps = -1.0f; + float courseDeg = -1.0f; + + uint16_t year = 0; + uint8_t month = 0; + uint8_t day = 0; + uint8_t hour = 0; + uint8_t minute = 0; + uint8_t second = 0; + + uint8_t gpsCount = 0; + uint8_t galileoCount = 0; + uint8_t glonassCount = 0; + uint8_t beidouCount = 0; + uint8_t navicCount = 0; + uint8_t qzssCount = 0; + uint8_t sbasCount = 0; + + float meanSnr = -1.0f; + uint8_t maxSnr = 0; + uint32_t ageOfFixMs = 0; + uint32_t ttffMs = 0; + uint32_t longestNoFixMs = 0; + uint32_t sampleMillis = 0; +}; + +const char* fixTypeToString(FixType type); +const char* qualityClassForSample(const GnssSample& sample); + +} // namespace field_qa diff --git a/exercises/18_GPS_Field_QA/lib/field_qa/RunStats.cpp b/exercises/18_GPS_Field_QA/lib/field_qa/RunStats.cpp new file mode 100644 index 0000000..189a921 --- /dev/null +++ b/exercises/18_GPS_Field_QA/lib/field_qa/RunStats.cpp @@ -0,0 +1,53 @@ +#include "RunStats.h" + +namespace field_qa { + +void RunStats::begin(uint32_t nowMs) { + m_bootMs = nowMs; + m_noFixStartMs = nowMs; + m_longestNoFixMs = 0; + m_ttffMs = 0; + m_started = true; + m_haveFirstFix = false; +} + +void RunStats::updateFromSample(const GnssSample& sample, uint32_t nowMs) { + if (!m_started) { + begin(nowMs); + } + + if (sample.validFix) { + if (!m_haveFirstFix) { + m_ttffMs = nowMs - m_bootMs; + m_haveFirstFix = true; + } + if (m_noFixStartMs != 0) { + uint32_t noFixMs = nowMs - m_noFixStartMs; + if (noFixMs > m_longestNoFixMs) { + m_longestNoFixMs = noFixMs; + } + m_noFixStartMs = 0; + } + } else if (m_noFixStartMs == 0) { + m_noFixStartMs = nowMs; + } +} + +uint32_t RunStats::elapsedMs(uint32_t nowMs) const { + return m_started ? (nowMs - m_bootMs) : 0; +} + +uint32_t RunStats::longestNoFixMs() const { + return m_longestNoFixMs; +} + +uint32_t RunStats::ttffMs() const { + return m_ttffMs; +} + +bool RunStats::hasFirstFix() const { + return m_haveFirstFix; +} + +} // namespace field_qa + diff --git a/exercises/18_GPS_Field_QA/lib/field_qa/RunStats.h b/exercises/18_GPS_Field_QA/lib/field_qa/RunStats.h new file mode 100644 index 0000000..0f25f54 --- /dev/null +++ b/exercises/18_GPS_Field_QA/lib/field_qa/RunStats.h @@ -0,0 +1,27 @@ +#pragma once + +#include +#include "GnssTypes.h" + +namespace field_qa { + +class RunStats { + public: + void begin(uint32_t nowMs); + void updateFromSample(const GnssSample& sample, uint32_t nowMs); + uint32_t elapsedMs(uint32_t nowMs) const; + uint32_t longestNoFixMs() const; + uint32_t ttffMs() const; + bool hasFirstFix() const; + + private: + uint32_t m_bootMs = 0; + uint32_t m_noFixStartMs = 0; + uint32_t m_longestNoFixMs = 0; + uint32_t m_ttffMs = 0; + bool m_started = false; + bool m_haveFirstFix = false; +}; + +} // namespace field_qa + diff --git a/exercises/18_GPS_Field_QA/lib/field_qa/StorageManager.cpp b/exercises/18_GPS_Field_QA/lib/field_qa/StorageManager.cpp new file mode 100644 index 0000000..d368233 --- /dev/null +++ b/exercises/18_GPS_Field_QA/lib/field_qa/StorageManager.cpp @@ -0,0 +1,611 @@ +#include "StorageManager.h" + +#include "Config.h" +#include "GnssTypes.h" + +namespace field_qa { + +namespace { + +static constexpr char kLogFieldDelimiter = ','; + +static bool isRecognizedLogName(const String& name) { + return name.endsWith(".csv") || name.endsWith(".tsv"); +} + +static String formatFloat(float value, bool valid, uint8_t decimals = 1) { + if (!valid) { + return ""; + } + return String(value, (unsigned int)decimals); +} + +static String formatDouble(double value, bool valid, uint8_t decimals = 6) { + if (!valid) { + return ""; + } + return String(value, (unsigned int)decimals); +} + +static String sampleTimestamp(const GnssSample& sample) { + if (!sample.validTime) { + return ""; + } + char buf[24]; + snprintf(buf, + sizeof(buf), + "%04u-%02u-%02uT%02u:%02u:%02uZ", + (unsigned)sample.year, + (unsigned)sample.month, + (unsigned)sample.day, + (unsigned)sample.hour, + (unsigned)sample.minute, + (unsigned)sample.second); + return String(buf); +} + +static const char* constellationForTalker(const char* talker) { + if (!talker) return "UNKNOWN"; + if (strcmp(talker, "GP") == 0 || strcmp(talker, "GN") == 0) return "GPS"; + if (strcmp(talker, "GA") == 0) return "GALILEO"; + if (strcmp(talker, "GL") == 0) return "GLONASS"; + if (strcmp(talker, "GB") == 0 || strcmp(talker, "BD") == 0) return "BEIDOU"; + if (strcmp(talker, "GI") == 0) return "NAVIC"; + if (strcmp(talker, "GQ") == 0) return "QZSS"; + if (strcmp(talker, "GS") == 0) return "SBAS"; + return "UNKNOWN"; +} + +} // namespace + +bool StorageManager::startLog(const char* runId, const char* bootTimestampUtc) { + close(); + m_ready = false; + m_lastError = ""; + m_path = makeFilePath(runId); + m_newFile = !SD.exists(m_path.c_str()); + if (!ensureDir() || !openFile()) { + return false; + } + m_ready = true; + writeHeader(runId, bootTimestampUtc); + return true; +} + +bool StorageManager::mounted() const { + File root = SD.open("/", FILE_READ); + const bool ok = root && root.isDirectory(); + root.close(); + return ok; +} + +bool StorageManager::ready() const { + return m_ready; +} + +const char* StorageManager::currentPath() const { + return m_path.c_str(); +} + +const char* StorageManager::lastError() const { + return m_lastError.c_str(); +} + +bool StorageManager::fileOpen() const { + return (bool)m_file; +} + +size_t StorageManager::bufferedBytes() const { + return m_bufferLengths[0] + m_bufferLengths[1]; +} + +size_t StorageManager::countLogsRecursive(const char* path) const { + File dir = SD.open(path, FILE_READ); + if (!dir || !dir.isDirectory()) { + dir.close(); + return 0; + } + + size_t count = 0; + File entry = dir.openNextFile(); + while (entry) { + String name = entry.name(); + if (entry.isDirectory()) { + count += countLogsRecursive(name.c_str()); + } else if (isRecognizedLogName(name)) { + ++count; + } + entry.close(); + entry = dir.openNextFile(); + } + dir.close(); + return count; +} + +size_t StorageManager::logFileCount() const { + return countLogsRecursive(kLogDir); +} + +bool StorageManager::ensureDir() { + String full(kLogDir); + if (!full.startsWith("/")) { + full = "/" + full; + } + + int start = 1; + while (start > 0 && start < (int)full.length()) { + const int slash = full.indexOf('/', start); + String partial = (slash < 0) ? full : full.substring(0, slash); + if (!SD.exists(partial.c_str()) && !SD.mkdir(partial.c_str())) { + m_lastError = "SD.mkdir failed"; + return false; + } + if (slash < 0) { + break; + } + start = slash + 1; + } + return true; +} + +String StorageManager::makeFilePath(const char* runId) const { + char basePath[96]; + char candidatePath[112]; + const char* rid = (runId && runId[0] != '\0') ? runId : "run"; + snprintf(basePath, sizeof(basePath), "%s/%s", kLogDir, rid); + snprintf(candidatePath, sizeof(candidatePath), "%s.csv", basePath); + if (!SD.exists(candidatePath)) { + return String(candidatePath); + } + + for (uint16_t suffix = 2; suffix < 1000; ++suffix) { + snprintf(candidatePath, sizeof(candidatePath), "%s_%02u.csv", basePath, (unsigned)suffix); + if (!SD.exists(candidatePath)) { + return String(candidatePath); + } + } + + snprintf(candidatePath, sizeof(candidatePath), "%s_overflow.csv", basePath); + return String(candidatePath); +} + +bool StorageManager::openFile() { + m_file = SD.open(m_path.c_str(), FILE_WRITE); + if (!m_file) { + m_lastError = "SD.open write failed"; + return false; + } + return true; +} + +void StorageManager::writeHeader(const char* runId, const char* bootTimestampUtc) { + if (!m_file || !m_newFile) { + return; + } + m_file.printf("# exercise: %s\n", kExerciseName); + m_file.printf("# version: %s\n", kFirmwareVersion); + m_file.printf("# board_id: %s\n", kBoardId); + m_file.printf("# gnss_chip: %s\n", kGnssChip); + m_file.printf("# storage: %s\n", kStorageName); + m_file.printf("# sample_period_ms: %lu\n", (unsigned long)kSamplePeriodMs); + m_file.printf("# log_period_ms: %lu\n", (unsigned long)kLogFlushPeriodMs); + m_file.printf("# run_id: %s\n", runId ? runId : ""); + m_file.printf("# boot_timestamp_utc: %s\n", bootTimestampUtc ? bootTimestampUtc : ""); + m_file.printf("# created_by: ChatGPT/Codex handoff\n"); + m_file.print("record_type,timestamp_utc,sample_seq,ms_since_run_start,board_id,gnss_chip,firmware_exercise_name,firmware_version,boot_timestamp_utc,run_id,fix_type,fix_dimension,sats_in_view,sat_seen,sats_used,hdop,vdop,pdop,latitude,longitude,altitude_m,speed_mps,course_deg,pps_seen,quality_class,gps_count,galileo_count,glonass_count,beidou_count,navic_count,qzss_count,sbas_count,mean_cn0,max_cn0,age_of_fix_ms,ttff_ms,longest_no_fix_ms,sat_talker,sat_constellation,sat_prn,sat_elevation_deg,sat_azimuth_deg,sat_snr,sat_used_in_solution\n"); + m_file.flush(); + m_newFile = false; +} + +bool StorageManager::writePendingBuffer() { + if (!m_file) { + return false; + } + + for (uint8_t i = 0; i < 2; ++i) { + if (!m_bufferPending[i] || m_bufferLengths[i] == 0) { + continue; + } + const size_t wrote = m_file.write((const uint8_t*)m_buffers[i], m_bufferLengths[i]); + if (wrote != m_bufferLengths[i]) { + m_lastError = "SD.write failed"; + m_ready = false; + return false; + } + m_bufferLengths[i] = 0; + m_bufferPending[i] = false; + } + return true; +} + +bool StorageManager::appendBytes(const char* data, size_t len) { + if (!m_file || !data || len == 0) { + return false; + } + if (len > kStorageBufferBytes) { + if (!writePendingBuffer()) { + return false; + } + const size_t wrote = m_file.write((const uint8_t*)data, len); + if (wrote != len) { + m_lastError = "SD.write large block failed"; + m_ready = false; + return false; + } + return true; + } + + if ((m_bufferLengths[m_activeBuffer] + len) > kStorageBufferBytes) { + m_bufferPending[m_activeBuffer] = true; + m_activeBuffer ^= 1U; + if (m_bufferPending[m_activeBuffer]) { + if (!writePendingBuffer()) { + return false; + } + } + if (m_bufferLengths[m_activeBuffer] != 0) { + m_bufferPending[m_activeBuffer] = true; + if (!writePendingBuffer()) { + return false; + } + } + } + + memcpy(m_buffers[m_activeBuffer] + m_bufferLengths[m_activeBuffer], data, len); + m_bufferLengths[m_activeBuffer] += len; + return true; +} + +bool StorageManager::appendLine(const String& line) { + if (line.endsWith("\n")) { + return appendBytes(line.c_str(), line.length()); + } + String record = line; + record += '\n'; + return appendBytes(record.c_str(), record.length()); +} + +void StorageManager::appendSampleCsv(const GnssSample& sample, + uint32_t sampleSeq, + uint32_t msSinceRunStart, + const char* runId, + const char* bootTimestampUtc) { + if (!m_file) { + return; + } + if (m_file.size() == 0) { + writeHeader(runId, bootTimestampUtc); + } + + String line = "sample,"; + line += sampleTimestamp(sample); + line += kLogFieldDelimiter; + line += String(sampleSeq); + line += kLogFieldDelimiter; + line += String(msSinceRunStart); + line += kLogFieldDelimiter; + line += kBoardId; + line += kLogFieldDelimiter; + line += kGnssChip; + line += kLogFieldDelimiter; + line += kExerciseName; + line += kLogFieldDelimiter; + line += kFirmwareVersion; + line += kLogFieldDelimiter; + line += (bootTimestampUtc ? bootTimestampUtc : ""); + line += kLogFieldDelimiter; + line += (runId ? runId : ""); + line += kLogFieldDelimiter; + line += fixTypeToString(sample.fixType); + line += kLogFieldDelimiter; + line += String(sample.fixDimension); + line += kLogFieldDelimiter; + line += (sample.satsInView >= 0 ? String(sample.satsInView) : ""); + line += kLogFieldDelimiter; + line += (sample.satsInView >= 0 ? String(sample.satsInView) : ""); + line += kLogFieldDelimiter; + line += (sample.satsUsed >= 0 ? String(sample.satsUsed) : ""); + line += kLogFieldDelimiter; + line += formatFloat(sample.hdop, sample.validHdop); + line += kLogFieldDelimiter; + line += formatFloat(sample.vdop, sample.validVdop); + line += kLogFieldDelimiter; + line += formatFloat(sample.pdop, sample.validPdop); + line += kLogFieldDelimiter; + line += formatDouble(sample.latitude, sample.validLocation); + line += kLogFieldDelimiter; + line += formatDouble(sample.longitude, sample.validLocation); + line += kLogFieldDelimiter; + line += formatDouble(sample.altitudeM, sample.validAltitude, 2); + line += kLogFieldDelimiter; + line += formatFloat(sample.speedMps, sample.validSpeed, 2); + line += kLogFieldDelimiter; + line += formatFloat(sample.courseDeg, sample.validCourse, 1); + line += kLogFieldDelimiter; + line += sample.ppsSeen ? "1" : "0"; + line += kLogFieldDelimiter; + line += qualityClassForSample(sample); + line += kLogFieldDelimiter; + line += String(sample.gpsCount); + line += kLogFieldDelimiter; + line += String(sample.galileoCount); + line += kLogFieldDelimiter; + line += String(sample.glonassCount); + line += kLogFieldDelimiter; + line += String(sample.beidouCount); + line += kLogFieldDelimiter; + line += String(sample.navicCount); + line += kLogFieldDelimiter; + line += String(sample.qzssCount); + line += kLogFieldDelimiter; + line += String(sample.sbasCount); + line += kLogFieldDelimiter; + line += formatFloat(sample.meanSnr, sample.meanSnr >= 0.0f, 1); + line += kLogFieldDelimiter; + line += (sample.maxSnr > 0 ? String(sample.maxSnr) : ""); + line += kLogFieldDelimiter; + line += String(sample.ageOfFixMs); + line += kLogFieldDelimiter; + line += String(sample.ttffMs); + line += kLogFieldDelimiter; + line += String(sample.longestNoFixMs); + line += ",,,,,,,"; + (void)appendLine(line); +} + +void StorageManager::appendSatelliteCsv(const GnssSample& sample, + uint32_t sampleSeq, + uint32_t msSinceRunStart, + const SatelliteInfo* satellites, + size_t satelliteCount, + const char* runId, + const char* bootTimestampUtc) { + if (!satellites || satelliteCount == 0 || !m_file) { + return; + } + if (m_file.size() == 0) { + writeHeader(runId, bootTimestampUtc); + } + + for (size_t i = 0; i < satelliteCount; ++i) { + const SatelliteInfo& sat = satellites[i]; + if (!sat.valid) { + continue; + } + String line = "satellite,"; + line += sampleTimestamp(sample); + line += kLogFieldDelimiter; + line += String(sampleSeq); + line += kLogFieldDelimiter; + line += String(msSinceRunStart); + line += kLogFieldDelimiter; + line += kBoardId; + line += kLogFieldDelimiter; + line += kGnssChip; + line += kLogFieldDelimiter; + line += kExerciseName; + line += kLogFieldDelimiter; + line += kFirmwareVersion; + line += kLogFieldDelimiter; + line += (bootTimestampUtc ? bootTimestampUtc : ""); + line += kLogFieldDelimiter; + line += (runId ? runId : ""); + line += kLogFieldDelimiter; + line += fixTypeToString(sample.fixType); + line += kLogFieldDelimiter; + line += String(sample.fixDimension); + line += kLogFieldDelimiter; + line += (sample.satsInView >= 0 ? String(sample.satsInView) : ""); + line += kLogFieldDelimiter; + line += (sample.satsInView >= 0 ? String(sample.satsInView) : ""); + line += kLogFieldDelimiter; + line += (sample.satsUsed >= 0 ? String(sample.satsUsed) : ""); + line += kLogFieldDelimiter; + line += formatFloat(sample.hdop, sample.validHdop); + line += kLogFieldDelimiter; + line += formatFloat(sample.vdop, sample.validVdop); + line += kLogFieldDelimiter; + line += formatFloat(sample.pdop, sample.validPdop); + line += kLogFieldDelimiter; + line += formatDouble(sample.latitude, sample.validLocation); + line += kLogFieldDelimiter; + line += formatDouble(sample.longitude, sample.validLocation); + line += kLogFieldDelimiter; + line += formatDouble(sample.altitudeM, sample.validAltitude, 2); + line += kLogFieldDelimiter; + line += formatFloat(sample.speedMps, sample.validSpeed, 2); + line += kLogFieldDelimiter; + line += formatFloat(sample.courseDeg, sample.validCourse, 1); + line += kLogFieldDelimiter; + line += sample.ppsSeen ? "1" : "0"; + line += kLogFieldDelimiter; + line += qualityClassForSample(sample); + line += kLogFieldDelimiter; + line += String(sample.gpsCount); + line += kLogFieldDelimiter; + line += String(sample.galileoCount); + line += kLogFieldDelimiter; + line += String(sample.glonassCount); + line += kLogFieldDelimiter; + line += String(sample.beidouCount); + line += kLogFieldDelimiter; + line += String(sample.navicCount); + line += kLogFieldDelimiter; + line += String(sample.qzssCount); + line += kLogFieldDelimiter; + line += String(sample.sbasCount); + line += kLogFieldDelimiter; + line += formatFloat(sample.meanSnr, sample.meanSnr >= 0.0f, 1); + line += kLogFieldDelimiter; + line += (sample.maxSnr > 0 ? String(sample.maxSnr) : ""); + line += kLogFieldDelimiter; + line += String(sample.ageOfFixMs); + line += kLogFieldDelimiter; + line += String(sample.ttffMs); + line += kLogFieldDelimiter; + line += String(sample.longestNoFixMs); + line += kLogFieldDelimiter; + line += sat.talker; + line += kLogFieldDelimiter; + line += constellationForTalker(sat.talker); + line += kLogFieldDelimiter; + line += String(sat.prn); + line += kLogFieldDelimiter; + line += String(sat.elevation); + line += kLogFieldDelimiter; + line += String(sat.azimuth); + line += kLogFieldDelimiter; + line += String(sat.snr); + line += kLogFieldDelimiter; + line += sat.usedInSolution ? "1" : "0"; + (void)appendLine(line); + } +} + +void StorageManager::flush() { + if (!m_file) { + return; + } + + if (m_bufferLengths[m_activeBuffer] > 0) { + m_bufferPending[m_activeBuffer] = true; + } + if (!writePendingBuffer()) { + return; + } + m_file.flush(); +} + +void StorageManager::close() { + flush(); + if (m_file) { + m_file.close(); + } + m_ready = false; + m_newFile = false; +} + +bool StorageManager::normalizePath(const char* input, String& normalized) const { + normalized = ""; + if (!input || input[0] == '\0') { + return false; + } + + normalized = input[0] == '/' ? String(input) : (String("/") + input); + if (normalized.indexOf("..") >= 0) { + normalized = ""; + return false; + } + return true; +} + +void StorageManager::listFilesRecursive(File& dir, Stream& out) { + File entry = dir.openNextFile(); + while (entry) { + String name = entry.name(); + if (entry.isDirectory()) { + out.printf("%s/\n", name.c_str()); + listFilesRecursive(entry, out); + } else if (isRecognizedLogName(name)) { + out.printf("%s\t%u\n", name.c_str(), (unsigned)entry.size()); + } + entry.close(); + entry = dir.openNextFile(); + } +} + +void StorageManager::listFiles(Stream& out) { + if (!mounted()) { + out.println("storage not mounted"); + return; + } + File dir = SD.open(kLogDir, FILE_READ); + if (!dir || !dir.isDirectory()) { + out.println("log directory unavailable"); + dir.close(); + return; + } + listFilesRecursive(dir, out); + dir.close(); +} + +void StorageManager::catFile(Stream& out, const char* path) { + if (!mounted()) { + out.println("storage not mounted"); + return; + } + String fullPath; + if (!normalizePath(path, fullPath)) { + out.println("cat requires a valid filename"); + return; + } + File file = SD.open(fullPath.c_str(), FILE_READ); + if (!file) { + out.printf("unable to open %s\n", fullPath.c_str()); + return; + } + while (file.available()) { + out.write(file.read()); + } + if (file.size() > 0) { + out.println(); + } + file.close(); +} + +void StorageManager::eraseLogsRecursive(File& dir) { + File entry = dir.openNextFile(); + while (entry) { + String path = entry.name(); + const bool isDir = entry.isDirectory(); + entry.close(); + if (isDir) { + File subdir = SD.open(path.c_str(), FILE_READ); + if (subdir) { + eraseLogsRecursive(subdir); + subdir.close(); + } + } else if (isRecognizedLogName(path)) { + SD.remove(path.c_str()); + } + entry = dir.openNextFile(); + } +} + +void StorageManager::eraseLogs(Stream& out) { + if (!mounted()) { + out.println("storage not mounted"); + return; + } + File dir = SD.open(kLogDir, FILE_READ); + if (!dir || !dir.isDirectory()) { + out.println("log directory unavailable"); + dir.close(); + return; + } + eraseLogsRecursive(dir); + dir.close(); + out.println("logs erased"); +} + +bool StorageManager::eraseFile(const char* path) { + String fullPath; + if (!normalizePath(path, fullPath)) { + m_lastError = "invalid path"; + return false; + } + if (m_path == fullPath && m_file) { + close(); + } + if (!SD.exists(fullPath.c_str())) { + m_lastError = "path not found"; + return false; + } + if (!SD.remove(fullPath.c_str())) { + m_lastError = "SD.remove failed"; + return false; + } + return true; +} + +} // namespace field_qa diff --git a/exercises/18_GPS_Field_QA/lib/field_qa/StorageManager.h b/exercises/18_GPS_Field_QA/lib/field_qa/StorageManager.h new file mode 100644 index 0000000..5acf8cf --- /dev/null +++ b/exercises/18_GPS_Field_QA/lib/field_qa/StorageManager.h @@ -0,0 +1,63 @@ +#pragma once + +#include +#include +#include "Config.h" +#include "GnssTypes.h" + +namespace field_qa { + +class StorageManager { + public: + bool startLog(const char* runId, const char* bootTimestampUtc); + bool mounted() const; + bool ready() const; + const char* currentPath() const; + const char* lastError() const; + bool fileOpen() const; + size_t bufferedBytes() const; + size_t logFileCount() const; + void appendSampleCsv(const GnssSample& sample, + uint32_t sampleSeq, + uint32_t msSinceRunStart, + const char* runId, + const char* bootTimestampUtc); + void appendSatelliteCsv(const GnssSample& sample, + uint32_t sampleSeq, + uint32_t msSinceRunStart, + const SatelliteInfo* satellites, + size_t satelliteCount, + const char* runId, + const char* bootTimestampUtc); + void flush(); + void close(); + void listFiles(Stream& out); + void catFile(Stream& out, const char* path); + void eraseLogs(Stream& out); + bool eraseFile(const char* path); + bool normalizePath(const char* input, String& normalized) const; + + private: + bool ensureDir(); + bool openFile(); + void writeHeader(const char* runId, const char* bootTimestampUtc); + String makeFilePath(const char* runId) const; + bool appendLine(const String& line); + bool appendBytes(const char* data, size_t len); + bool writePendingBuffer(); + size_t countLogsRecursive(const char* path) const; + void listFilesRecursive(File& dir, Stream& out); + void eraseLogsRecursive(File& dir); + + bool m_ready = false; + bool m_newFile = false; + String m_path; + String m_lastError; + File m_file; + char m_buffers[2][kStorageBufferBytes] = {}; + size_t m_bufferLengths[2] = {0, 0}; + bool m_bufferPending[2] = {false, false}; + uint8_t m_activeBuffer = 0; +}; + +} // namespace field_qa diff --git a/exercises/18_GPS_Field_QA/lib/startup_sd/StartupSdManager.cpp b/exercises/18_GPS_Field_QA/lib/startup_sd/StartupSdManager.cpp new file mode 100644 index 0000000..f768cb4 --- /dev/null +++ b/exercises/18_GPS_Field_QA/lib/startup_sd/StartupSdManager.cpp @@ -0,0 +1,379 @@ +#include "StartupSdManager.h" + +#include +#include "driver/gpio.h" + +StartupSdManager::StartupSdManager(Print& serial) : serial_(serial) {} + +bool StartupSdManager::begin(const SdWatcherConfig& cfg, SdStatusCallback callback) { + cfg_ = cfg; + callback_ = callback; + + forceSpiDeselected(); + dumpSdPins("very-early"); + + if (!initPmuForSdPower()) { + return false; + } + + cycleSdRail(); + delay(cfg_.startupWarmupMs); + + bool warmMounted = false; + for (uint8_t i = 0; i < 3; ++i) { + if (mountPreferred(false)) { + warmMounted = true; + break; + } + delay(200); + } + + // Some cards need a longer power/settle window after cold boot. + // Before declaring ABSENT, retry with extended settle and a full scan. + if (!warmMounted) { + logf("Watcher: startup preferred mount failed, retrying with extended settle"); + cycleSdRail(400, 1200); + delay(cfg_.startupWarmupMs + 1500); + warmMounted = mountCardFullScan(); + } + + if (warmMounted) { + setStateMounted(); + } else { + setStateAbsent(); + } + return true; +} + +void StartupSdManager::update() { + const uint32_t now = millis(); + const uint32_t pollInterval = + (watchState_ == SdWatchState::MOUNTED) ? cfg_.pollIntervalMountedMs : cfg_.pollIntervalAbsentMs; + + if ((uint32_t)(now - lastPollMs_) < pollInterval) { + return; + } + lastPollMs_ = now; + + if (watchState_ == SdWatchState::MOUNTED) { + if (verifyMountedCard()) { + presentVotes_ = 0; + absentVotes_ = 0; + return; + } + + if (mountPreferred(false) && verifyMountedCard()) { + presentVotes_ = 0; + absentVotes_ = 0; + return; + } + + absentVotes_++; + presentVotes_ = 0; + if (absentVotes_ >= cfg_.votesToAbsent) { + setStateAbsent(); + absentVotes_ = 0; + } + return; + } + + bool mounted = mountPreferred(false); + if (!mounted && (uint32_t)(now - lastFullScanMs_) >= cfg_.fullScanIntervalMs) { + lastFullScanMs_ = now; + if (cfg_.recoveryRailCycleOnFullScan) { + logf("Watcher: recovery rail cycle before full scan"); + cycleSdRail(cfg_.recoveryRailOffMs, cfg_.recoveryRailOnSettleMs); + delay(150); + } + logf("Watcher: preferred probe failed, running full scan"); + mounted = mountCardFullScan(); + } + + if (mounted) { + presentVotes_++; + absentVotes_ = 0; + if (presentVotes_ >= cfg_.votesToPresent) { + setStateMounted(); + presentVotes_ = 0; + } + } else { + absentVotes_++; + presentVotes_ = 0; + if (absentVotes_ >= cfg_.votesToAbsent) { + setStateAbsent(); + absentVotes_ = 0; + } + } +} + +bool StartupSdManager::consumeMountedEvent() { + bool out = mountedEventPending_; + mountedEventPending_ = false; + return out; +} + +bool StartupSdManager::consumeRemovedEvent() { + bool out = removedEventPending_; + removedEventPending_ = false; + return out; +} + +bool StartupSdManager::forceRemount() { + logf("Watcher: manual rescan requested"); + presentVotes_ = 0; + absentVotes_ = 0; + lastPollMs_ = 0; + lastFullScanMs_ = millis(); + + cycleSdRail(cfg_.recoveryRailOffMs, cfg_.recoveryRailOnSettleMs); + delay(cfg_.startupWarmupMs); + + if (mountCardFullScan()) { + setStateMounted(); + return true; + } + + setStateAbsent(); + return false; +} + +void StartupSdManager::logf(const char* fmt, ...) { + char msg[196]; + va_list args; + va_start(args, fmt); + vsnprintf(msg, sizeof(msg), fmt, args); + va_end(args); + serial_.printf("[%10lu][%06lu] %s\r\n", + (unsigned long)millis(), + (unsigned long)logSeq_++, + msg); +} + +void StartupSdManager::notify(SdEvent event, const char* message) { + if (callback_ != nullptr) { + callback_(event, message); + } +} + +void StartupSdManager::forceSpiDeselected() { + pinMode(tbeam_supreme::sdCs(), OUTPUT); + digitalWrite(tbeam_supreme::sdCs(), HIGH); + pinMode(tbeam_supreme::imuCs(), OUTPUT); + digitalWrite(tbeam_supreme::imuCs(), HIGH); +} + +void StartupSdManager::dumpSdPins(const char* tag) { + if (!cfg_.enablePinDumps) { + (void)tag; + return; + } + + const gpio_num_t cs = (gpio_num_t)tbeam_supreme::sdCs(); + const gpio_num_t sck = (gpio_num_t)tbeam_supreme::sdSck(); + const gpio_num_t miso = (gpio_num_t)tbeam_supreme::sdMiso(); + const gpio_num_t mosi = (gpio_num_t)tbeam_supreme::sdMosi(); + logf("PINS(%s): CS=%d SCK=%d MISO=%d MOSI=%d", + tag, gpio_get_level(cs), gpio_get_level(sck), gpio_get_level(miso), gpio_get_level(mosi)); +} + +bool StartupSdManager::initPmuForSdPower() { + if (!tbeam_supreme::initPmuForPeripherals(pmu_, &serial_)) { + logf("ERROR: PMU init failed"); + return false; + } + return true; +} + +void StartupSdManager::cycleSdRail(uint32_t offMs, uint32_t onSettleMs) { + if (!cfg_.enableSdRailCycle) { + return; + } + if (!pmu_) { + logf("SD rail cycle skipped: pmu=null"); + return; + } + + forceSpiDeselected(); + pmu_->disablePowerOutput(XPOWERS_BLDO1); + delay(offMs); + pmu_->setPowerChannelVoltage(XPOWERS_BLDO1, 3300); + pmu_->enablePowerOutput(XPOWERS_BLDO1); + delay(onSettleMs); +} + +bool StartupSdManager::tryMountWithBus(SPIClass& bus, const char* busName, uint32_t hz, bool verbose) { + SD.end(); + bus.end(); + delay(10); + forceSpiDeselected(); + + bus.begin(tbeam_supreme::sdSck(), tbeam_supreme::sdMiso(), tbeam_supreme::sdMosi(), tbeam_supreme::sdCs()); + digitalWrite(tbeam_supreme::sdCs(), HIGH); + delay(2); + for (int i = 0; i < 10; i++) { + bus.transfer(0xFF); + } + delay(2); + + if (verbose) { + logf("SD: trying bus=%s freq=%lu Hz", busName, (unsigned long)hz); + } + + if (!SD.begin(tbeam_supreme::sdCs(), bus, hz)) { + if (verbose) { + logf("SD: mount failed (possible non-FAT format, power, or bus issue)"); + } + return false; + } + + if (SD.cardType() == CARD_NONE) { + SD.end(); + return false; + } + + sdSpi_ = &bus; + sdBusName_ = busName; + sdFreq_ = hz; + return true; +} + +bool StartupSdManager::mountPreferred(bool verbose) { + return tryMountWithBus(sdSpiH_, "HSPI", 400000, verbose); +} + +bool StartupSdManager::mountCardFullScan() { + const uint32_t freqs[] = {400000, 1000000, 4000000, 10000000}; + + for (uint8_t i = 0; i < (sizeof(freqs) / sizeof(freqs[0])); ++i) { + if (tryMountWithBus(sdSpiH_, "HSPI", freqs[i], true)) { + logf("SD: card detected and mounted"); + return true; + } + } + for (uint8_t i = 0; i < (sizeof(freqs) / sizeof(freqs[0])); ++i) { + if (tryMountWithBus(sdSpiF_, "FSPI", freqs[i], true)) { + logf("SD: card detected and mounted"); + return true; + } + } + + logf("SD: begin() failed on all bus/frequency attempts"); + return false; +} + +bool StartupSdManager::verifyMountedCard() { + File root = SD.open("/", FILE_READ); + if (!root) { + return false; + } + root.close(); + return true; +} + +const char* StartupSdManager::cardTypeToString(uint8_t type) { + switch (type) { + case CARD_MMC: + return "MMC"; + case CARD_SD: + return "SDSC"; + case CARD_SDHC: + return "SDHC/SDXC"; + default: + return "UNKNOWN"; + } +} + +void StartupSdManager::printCardInfo() { + uint8_t cardType = SD.cardType(); + uint64_t cardSizeMB = SD.cardSize() / (1024ULL * 1024ULL); + uint64_t totalMB = SD.totalBytes() / (1024ULL * 1024ULL); + uint64_t usedMB = SD.usedBytes() / (1024ULL * 1024ULL); + + logf("SD type: %s", cardTypeToString(cardType)); + logf("SD size: %llu MB", cardSizeMB); + logf("FS total: %llu MB", totalMB); + logf("FS used : %llu MB", usedMB); + logf("SPI bus: %s @ %lu Hz", sdBusName_, (unsigned long)sdFreq_); +} + +bool StartupSdManager::ensureDirRecursive(const char* path) { + String full(path); + if (!full.startsWith("/")) { + full = "/" + full; + } + + int start = 1; + while (start > 0 && start < (int)full.length()) { + int slash = full.indexOf('/', start); + String partial = (slash < 0) ? full : full.substring(0, slash); + if (!SD.exists(partial.c_str()) && !SD.mkdir(partial.c_str())) { + logf("ERROR: mkdir failed for %s", partial.c_str()); + return false; + } + if (slash < 0) { + break; + } + start = slash + 1; + } + + return true; +} + +bool StartupSdManager::rewriteFile(const char* path, const char* payload) { + if (SD.exists(path) && !SD.remove(path)) { + logf("ERROR: failed to erase %s", path); + return false; + } + + File f = SD.open(path, FILE_WRITE); + if (!f) { + logf("ERROR: failed to create %s", path); + return false; + } + + size_t wrote = f.println(payload); + f.close(); + if (wrote == 0) { + logf("ERROR: write failed for %s", path); + return false; + } + return true; +} + +void StartupSdManager::permissionsDemo(const char* path) { + logf("Permissions demo: FAT has no Unix chmod/chown, use open mode only."); + File r = SD.open(path, FILE_READ); + if (!r) { + logf("Could not open %s as FILE_READ", path); + return; + } + size_t writeInReadMode = r.print("attempt write while opened read-only"); + if (writeInReadMode == 0) { + logf("As expected, FILE_READ write was blocked."); + } else { + logf("NOTE: FILE_READ write returned %u (unexpected)", (unsigned)writeInReadMode); + } + r.close(); +} + +void StartupSdManager::setStateMounted() { + if (watchState_ != SdWatchState::MOUNTED) { + logf("EVENT: card inserted/mounted"); + mountedEventPending_ = true; + notify(SdEvent::CARD_MOUNTED, "SD card mounted"); + } + watchState_ = SdWatchState::MOUNTED; +} + +void StartupSdManager::setStateAbsent() { + if (watchState_ == SdWatchState::MOUNTED) { + logf("EVENT: card removed/unavailable"); + removedEventPending_ = true; + notify(SdEvent::CARD_REMOVED, "SD card removed"); + } else if (watchState_ != SdWatchState::ABSENT) { + logf("EVENT: no card detected"); + notify(SdEvent::NO_CARD, "Missing SD card or invalid FAT16/FAT32 format"); + } + SD.end(); + watchState_ = SdWatchState::ABSENT; +} diff --git a/exercises/18_GPS_Field_QA/lib/startup_sd/StartupSdManager.h b/exercises/18_GPS_Field_QA/lib/startup_sd/StartupSdManager.h new file mode 100644 index 0000000..9a10cfd --- /dev/null +++ b/exercises/18_GPS_Field_QA/lib/startup_sd/StartupSdManager.h @@ -0,0 +1,91 @@ +#pragma once + +#include +#include +#include +#include +#include "tbeam_supreme_adapter.h" + +enum class SdWatchState : uint8_t { + UNKNOWN = 0, + ABSENT, + MOUNTED +}; + +enum class SdEvent : uint8_t { + NO_CARD, + CARD_MOUNTED, + CARD_REMOVED +}; + +using SdStatusCallback = void (*)(SdEvent event, const char* message); + +struct SdWatcherConfig { + bool enableSdRailCycle = true; + bool enablePinDumps = true; + bool recoveryRailCycleOnFullScan = true; + uint32_t recoveryRailOffMs = 250; + uint32_t recoveryRailOnSettleMs = 700; + uint32_t startupWarmupMs = 1500; + uint32_t pollIntervalAbsentMs = 1000; + uint32_t pollIntervalMountedMs = 2000; + uint32_t fullScanIntervalMs = 10000; + uint8_t votesToPresent = 2; + uint8_t votesToAbsent = 5; +}; + +class StartupSdManager { + public: + explicit StartupSdManager(Print& serial = Serial); + + bool begin(const SdWatcherConfig& cfg, SdStatusCallback callback = nullptr); + void update(); + + bool isMounted() const { return watchState_ == SdWatchState::MOUNTED; } + SdWatchState state() const { return watchState_; } + + bool consumeMountedEvent(); + bool consumeRemovedEvent(); + bool forceRemount(); + + void printCardInfo(); + bool ensureDirRecursive(const char* path); + bool rewriteFile(const char* path, const char* payload); + void permissionsDemo(const char* path); + + private: + void logf(const char* fmt, ...); + void notify(SdEvent event, const char* message); + void forceSpiDeselected(); + void dumpSdPins(const char* tag); + bool initPmuForSdPower(); + void cycleSdRail(uint32_t offMs = 250, uint32_t onSettleMs = 600); + bool tryMountWithBus(SPIClass& bus, const char* busName, uint32_t hz, bool verbose); + bool mountPreferred(bool verbose); + bool mountCardFullScan(); + bool verifyMountedCard(); + const char* cardTypeToString(uint8_t type); + void setStateMounted(); + void setStateAbsent(); + + Print& serial_; + SdWatcherConfig cfg_{}; + SdStatusCallback callback_ = nullptr; + + SPIClass sdSpiH_{HSPI}; + SPIClass sdSpiF_{FSPI}; + SPIClass* sdSpi_ = nullptr; + const char* sdBusName_ = "none"; + uint32_t sdFreq_ = 0; + XPowersLibInterface* pmu_ = nullptr; + + SdWatchState watchState_ = SdWatchState::UNKNOWN; + uint8_t presentVotes_ = 0; + uint8_t absentVotes_ = 0; + uint32_t lastPollMs_ = 0; + uint32_t lastFullScanMs_ = 0; + uint32_t logSeq_ = 0; + + bool mountedEventPending_ = false; + bool removedEventPending_ = false; +}; diff --git a/exercises/18_GPS_Field_QA/lib/startup_sd/library.json b/exercises/18_GPS_Field_QA/lib/startup_sd/library.json new file mode 100644 index 0000000..4978fdd --- /dev/null +++ b/exercises/18_GPS_Field_QA/lib/startup_sd/library.json @@ -0,0 +1,12 @@ +{ + "name": "startup_sd", + "version": "0.1.0", + "dependencies": [ + { + "name": "XPowersLib" + }, + { + "name": "Wire" + } + ] +} diff --git a/exercises/18_GPS_Field_QA/platformio.ini b/exercises/18_GPS_Field_QA/platformio.ini new file mode 100644 index 0000000..49c13ea --- /dev/null +++ b/exercises/18_GPS_Field_QA/platformio.ini @@ -0,0 +1,104 @@ +; 20260405 ChatGPT +; Exercise 18_GPS_Field_QA + +[platformio] +default_envs = cy + +[env] +platform = espressif32 +framework = arduino +board = esp32-s3-devkitc-1 +board_build.partitions = default_8MB.csv +monitor_speed = 115200 +extra_scripts = pre:scripts/set_build_epoch.py +lib_deps = + Wire + olikraus/U8g2@^2.36.4 + lewisxhe/XPowersLib@0.3.3 + +build_flags = + -I ../../shared/boards + -I ../../external/microReticulum_Firmware + -D BOARD_MODEL=BOARD_TBEAM_S_V1 + -D OLED_SDA=17 + -D OLED_SCL=18 + -D OLED_ADDR=0x3C + -D GPS_RX_PIN=9 + -D GPS_TX_PIN=8 + -D GPS_WAKEUP_PIN=7 + -D GPS_1PPS_PIN=6 + -D GPS_L76K + -D NODE_SLOT_COUNT=7 + -D ARDUINO_USB_MODE=1 + -D ARDUINO_USB_CDC_ON_BOOT=1 + +[env:amy] +extends = env +build_flags = + ${env.build_flags} + -D BOARD_ID=\"AMY\" + -D NODE_LABEL=\"Amy\" + -D NODE_SHORT=\"A\" + -D NODE_SLOT_INDEX=0 + -D GNSS_CHIP_NAME=\"L76K\" + +[env:bob] +extends = env +build_flags = + ${env.build_flags} + -D BOARD_ID=\"BOB\" + -D NODE_LABEL=\"Bob\" + -D NODE_SHORT=\"B\" + -D NODE_SLOT_INDEX=1 + -D GNSS_CHIP_NAME=\"L76K\" + +[env:cy] +extends = env +build_flags = + ${env.build_flags} + -D BOARD_ID=\"CY\" + -D NODE_LABEL=\"Cy\" + -D NODE_SHORT=\"C\" + -D NODE_SLOT_INDEX=2 + -D GNSS_CHIP_NAME=\"L76K\" + +[env:dan] +extends = env +build_flags = + ${env.build_flags} + -D BOARD_ID=\"DAN\" + -D NODE_LABEL=\"Dan\" + -D NODE_SHORT=\"D\" + -D NODE_SLOT_INDEX=3 + -D GNSS_CHIP_NAME=\"L76K\" + +[env:ed] +extends = env +build_flags = + ${env.build_flags} + -D BOARD_ID=\"ED\" + -D NODE_LABEL=\"Ed\" + -D NODE_SHORT=\"E\" + -D NODE_SLOT_INDEX=4 + -D GNSS_CHIP_NAME=\"L76K\" + +[env:flo] +extends = env +build_flags = + ${env.build_flags} + -D BOARD_ID=\"FLO\" + -D NODE_LABEL=\"Flo\" + -D NODE_SHORT=\"F\" + -D NODE_SLOT_INDEX=5 + -D GNSS_CHIP_NAME=\"L76K\" + +[env:guy] +extends = env +build_flags = + ${env.build_flags} + -D BOARD_ID=\"GUY\" + -D NODE_LABEL=\"Guy\" + -D NODE_SHORT=\"G\" + -D NODE_SLOT_INDEX=6 + -D GNSS_CHIP_NAME=\"MAX-M10S\" + -D GPS_UBLOX diff --git a/exercises/18_GPS_Field_QA/scripts/import_satellite_logs.pl b/exercises/18_GPS_Field_QA/scripts/import_satellite_logs.pl new file mode 100644 index 0000000..91bd36e --- /dev/null +++ b/exercises/18_GPS_Field_QA/scripts/import_satellite_logs.pl @@ -0,0 +1,419 @@ +#!/usr/bin/env perl +# 20260406 ChatGPT +# $Header$ +# +# Example: +# perl import_satellite_logs.pl \ +# --dbname satellite_data \ +# --host localhost \ +# --user jlpoole \ +# --schema public \ +# /path/to/20260406_175441_GUY.csv +# +# Notes: +# * Imports one or more CSV files into tables logs and log_data. +# * Preserves all leading hash-prefixed header lines in logs.raw_header_text. +# * Uses the file's own CSV header row when present. +# * When no CSV header row is present, it falls back by column count: +# - legacy schema without sample_seq/ms_since_run_start +# - enhanced schema with sample_seq/ms_since_run_start + +use strict; +use warnings; +use utf8; + +use DBI; +use Digest::SHA qw(sha256_hex); +use File::Basename qw(basename); +use Getopt::Long qw(GetOptions); +use Text::CSV_XS; + +my @LEGACY_COLUMNS = qw( +record_type timestamp_utc board_id gnss_chip firmware_exercise_name firmware_version +boot_timestamp_utc run_id fix_type fix_dimension sats_in_view sat_seen sats_used +hdop vdop pdop latitude longitude altitude_m speed_mps course_deg pps_seen +quality_class gps_count galileo_count glonass_count beidou_count navic_count +qzss_count sbas_count mean_cn0 max_cn0 age_of_fix_ms ttff_ms longest_no_fix_ms +sat_talker sat_constellation sat_prn sat_elevation_deg sat_azimuth_deg sat_snr +sat_used_in_solution +); + +my @ENHANCED_COLUMNS = qw( +record_type timestamp_utc sample_seq ms_since_run_start board_id gnss_chip +firmware_exercise_name firmware_version boot_timestamp_utc run_id +fix_type fix_dimension sats_in_view sat_seen sats_used +hdop vdop pdop latitude longitude altitude_m speed_mps course_deg pps_seen +quality_class gps_count galileo_count glonass_count beidou_count navic_count +qzss_count sbas_count mean_cn0 max_cn0 age_of_fix_ms ttff_ms longest_no_fix_ms +sat_talker sat_constellation sat_prn sat_elevation_deg sat_azimuth_deg sat_snr +sat_used_in_solution +); + +my $LEGACY_HEADER = join ',', @LEGACY_COLUMNS; +my $ENHANCED_HEADER = join ',', @ENHANCED_COLUMNS; + +my %opt = ( + dbname => 'satellite_data', + host => 'ryzdesk', + port => 5432, + schema => 'public', +); + +GetOptions( + 'dbname=s' => \$opt{dbname}, + 'host=s' => \$opt{host}, + 'port=i' => \$opt{port}, + 'user=s' => \$opt{user}, + 'password=s' => \$opt{password}, + 'schema=s' => \$opt{schema}, + 'header-line=s' => \$opt{header_line}, + 'notes=s' => \$opt{import_notes}, + 'help' => \$opt{help}, +) or die usage(); + +if ($opt{help} || !@ARGV) { + print usage(); + exit 0; +} + +my $dsn = sprintf('dbi:Pg:dbname=%s;host=%s;port=%d', $opt{dbname}, $opt{host}, $opt{port}); +my $dbh = DBI->connect( + $dsn, + $opt{user}, + $opt{password}, + { + RaiseError => 1, + AutoCommit => 1, + PrintError => 0, + pg_enable_utf8 => 1, + } +) or die DBI->errstr; + +$dbh->do("SET search_path TO $opt{schema}"); + +for my $file (@ARGV) { + import_file($dbh, $file, \%opt); +} + +$dbh->disconnect; +exit 0; +# +# ------------------------- subs ----------------------------- +# + +# +# import_file first creates an entry in logs, gets and ID, then +# parses the data rows and inserts each row. +# +sub import_file { + my ($dbh, $file, $opt) = @_; + # + # get a fixed-length hash (fingerprint) so we do not accidently + # load the same file twice. + # + my $sha256 = ""; + my $blob; + { + open my $fh, '<:raw', $file or die "Cannot open $file: $!\n"; + local $/; + $blob = <$fh>; + close $fh; + } + + $sha256 = sha256_hex($blob // ''); + my $file_size = -s $file; + + open my $in, '<:encoding(UTF-8)', $file or die "Cannot open $file: $!\n"; + + my @header_lines; + my $csv_header_line; + my @data_lines; + my $line_count = 0; + while (my $line = <$in>) { + chomp $line; + $line =~ s/\r//g; # might be there are multiple \rs! + + next if $line =~ /^\s*$/ && !@data_lines && !defined $csv_header_line && !@header_lines; + $line_count++; + print "B Processing $line_count\n"; + if ($line =~ /^#/) { + push @header_lines, $line; + next; + } + # record_type is the first entry in the column heading + if (!defined $csv_header_line && $line =~ /^record_type,/) { + $csv_header_line = $line; + next; + } + + push @data_lines, $line; + } + close $in; + + die "No CSV data rows found in $file\n" if !@data_lines; + + if (!defined $csv_header_line) { + if (defined $opt->{header_line}) { + $csv_header_line = $opt->{header_line}; + } + else { + my $count = count_csv_fields($data_lines[0]); + if ($count == scalar(@ENHANCED_COLUMNS)) { + $csv_header_line = $ENHANCED_HEADER; + } + elsif ($count == scalar(@LEGACY_COLUMNS)) { + $csv_header_line = $LEGACY_HEADER; + } + else { + die sprintf( + "Unable to infer header for %s: first data row has %d fields, expected %d (legacy) or %d (enhanced).\n", + $file, + $count, + scalar(@LEGACY_COLUMNS), + scalar(@ENHANCED_COLUMNS), + ); + } + } + } + + my @columns = parse_header_columns($csv_header_line); + + my %allowed = map { $_ => 1 } qw( + record_type timestamp_utc board_id gnss_chip firmware_exercise_name firmware_version + boot_timestamp_utc run_id sample_seq ms_since_run_start fix_type fix_dimension + sats_in_view sat_seen sats_used hdop vdop pdop latitude longitude altitude_m + speed_mps course_deg pps_seen quality_class gps_count galileo_count glonass_count + beidou_count navic_count qzss_count sbas_count mean_cn0 max_cn0 age_of_fix_ms + ttff_ms longest_no_fix_ms sat_talker sat_constellation sat_prn sat_elevation_deg + sat_azimuth_deg sat_snr sat_used_in_solution + ); + + my $col_count = 0; + for my $col (@columns) { + $col_count++; + die "Unexpected column at column \# $col_count \"$col\" in $file\nHeader line: $csv_header_line\n" + if !$allowed{$col}; + } + + my $raw_header_text = join("\n", @header_lines); + $raw_header_text .= "\n" if length $raw_header_text; + + my $insert_log_sql = <<'SQL'; +INSERT INTO logs ( + source_filename, + source_path, + file_sha256, + file_size_bytes, + raw_header_text, + csv_header_line, + import_notes +) VALUES (?, ?, ?, ?, ?, ?, ?) +RETURNING log_id +SQL + + my ($log_id) = $dbh->selectrow_array( + $insert_log_sql, + undef, + basename($file), + $file, + $sha256, + $file_size, + $raw_header_text, + $csv_header_line, + $opt->{import_notes}, + ); + + my @insert_columns = ( + 'log_id', 'source_row_number', @columns, 'raw_csv_line' + ); + + my $placeholders = join ', ', ('?') x @insert_columns; + my $insert_sql = sprintf( + 'INSERT INTO log_data (%s) VALUES (%s)', + join(', ', @insert_columns), + $placeholders, + ); + + my $csv = Text::CSV_XS->new({ + binary => 1, + auto_diag => 1, + allow_loose_quotes => 1, + allow_loose_escapes => 1, + blank_is_undef => 0, + }); + + my $sth = $dbh->prepare($insert_sql); + + my ($row_count, $sample_count, $satellite_count) = (0, 0, 0); + my ($first_ts, $last_ts, $board_id, $gnss_chip, $fw_name, $fw_ver, $boot_ts, $run_id); + + $dbh->begin_work; + $line_count = 0; # reset + for my $i (0 .. $#data_lines) { + my $line = $data_lines[$i]; + next if $line =~ /^\s*$/; # empty lines + $line_count++; + $csv->parse($line) or die "CSV parse failed in $file line @{[$i+1]}: " . $csv->error_diag . "\n"; + my @fields = $csv->fields; + # + # check for empty rows (possibly introduced during repair editing in LibreOffice Calc + # + my $all_empty = 1; + for my $field (@fields) { + if (defined $field && $field ne '') { + $all_empty = 0; + last; + } + } + if ($all_empty){ + warn "Found empty row at $line_count and skipping"; + next; + } + + if (@fields != @columns) { + die sprintf( + "Column mismatch in %s data row %d: got %d fields, expected %d\nLine: %s\n", + $file, $i + 1, scalar(@fields), scalar(@columns), $line + ); + } + + my %row; + @row{@columns} = @fields; + + normalize_row(\%row); + + my $record_type = $row{record_type} // ''; + $sample_count++ if $record_type eq 'sample'; + $satellite_count++ if $record_type eq 'satellite'; + $row_count++; + + $first_ts //= $row{timestamp_utc}; + $last_ts = $row{timestamp_utc} if defined $row{timestamp_utc}; + $board_id //= $row{board_id}; + $gnss_chip //= $row{gnss_chip}; + $fw_name //= $row{firmware_exercise_name}; + $fw_ver //= $row{firmware_version}; + $boot_ts //= $row{boot_timestamp_utc}; + $run_id //= $row{run_id}; + + my @values = ( + $log_id, + $i + 1, + (map { $row{$_} } @columns), + $line, + ); + eval { + $sth->execute(@values) or die "Line: $line_count ".$DBD::errstr; + }; + if ($@){ + print "[DEBUG ".__LINE__." ] i:$i\n"; + die "Killed as error was found: $@"; + } + } + #die "halted before commit, but after all rows processed"; + $dbh->commit; + print "After commit of row data, about to update logs entry.\n"; + my $update_sql = <<'SQL'; +UPDATE logs + SET board_id = ?, + gnss_chip = ?, + firmware_exercise_name = ?, + firmware_version = ?, + boot_timestamp_utc = ?, + run_id = ?, + first_timestamp_utc = ?, + last_timestamp_utc = ?, + row_count = ?, + sample_count = ?, + satellite_count = ? + WHERE log_id = ? +SQL + + $dbh->do( + $update_sql, + undef, + $board_id, + $gnss_chip, + $fw_name, + $fw_ver, + $boot_ts, + $run_id, + $first_ts, + $last_ts, + $row_count, + $sample_count, + $satellite_count, + $log_id, + ); + + print "Imported $file => log_id=$log_id rows=$row_count samples=$sample_count satellites=$satellite_count\n"; +} + +sub parse_header_columns { + my ($line) = @_; + #print "DEBUG [".__LINE__."] header line = $line\n"; + my $csv = Text::CSV_XS->new({ binary => 1, auto_diag => 1 }); + $csv->parse($line) or die "Cannot parse header line: " . $csv->error_diag . "\n"; + my @cols = $csv->fields; + #print "DEBUG [".__LINE__."] columns found: ".@cols."\n"; + s/^\s+|\s+$//g for @cols; + return @cols; +} + +sub count_csv_fields { + my ($line) = @_; + my $csv = Text::CSV_XS->new({ binary => 1, auto_diag => 1 }); + $csv->parse($line) or die "Cannot parse first data row while inferring header: " . $csv->error_diag . "\n"; + my @fields = $csv->fields; + return scalar @fields; +} + +sub normalize_row { + my ($row) = @_; + + for my $key (keys %{$row}) { + next if !defined $row->{$key}; + $row->{$key} =~ s/^\s+//; + $row->{$key} =~ s/\s+$//; + $row->{$key} = undef if $row->{$key} eq ''; + } + + for my $bool_key (qw(pps_seen sat_used_in_solution)) { + next if !exists $row->{$bool_key}; + next if !defined $row->{$bool_key}; + if ($row->{$bool_key} =~ /^(?:1|true|t|yes|y)$/i) { + $row->{$bool_key} = 'true'; + } + elsif ($row->{$bool_key} =~ /^(?:0|false|f|no|n)$/i) { + $row->{$bool_key} = 'false'; + } + } +} + +sub usage { + return <<'USAGE'; +Usage: + perl import_satellite_logs.pl [options] file1.csv [file2.csv ...] + +Options: + --dbname NAME Database name. Default: satellite_data + --host HOST PostgreSQL host. Default: localhost + --port PORT PostgreSQL port. Default: 5432 + --user USER PostgreSQL user name + --password PASS PostgreSQL password + --schema NAME Schema name. Default: public + --header-line LINE Override the CSV header line when the file lacks one + --notes TEXT Optional import note stored in logs.import_notes + --help Show this help + +Examples: + createdb satellite_data + psql -d satellite_data -f satellite_data_schema.sql + + perl import_satellite_logs.pl \ + --dbname satellite_data \ + --host localhost \ + --user jlpoole \ + /path/to/20260406_175441_GUY.csv +USAGE +} diff --git a/exercises/18_GPS_Field_QA/scripts/set_build_epoch.py b/exercises/18_GPS_Field_QA/scripts/set_build_epoch.py new file mode 100644 index 0000000..b3f9cd9 --- /dev/null +++ b/exercises/18_GPS_Field_QA/scripts/set_build_epoch.py @@ -0,0 +1,13 @@ +import time +Import("env") + +epoch = int(time.time()) +utc_tag = time.strftime("%Y%m%d_%H%M%S_z", time.gmtime(epoch)) + +env.Append( + CPPDEFINES=[ + ("FW_BUILD_EPOCH", str(epoch)), + ("FW_BUILD_UTC", '\"%s\"' % utc_tag), + ] +) + diff --git a/exercises/18_GPS_Field_QA/sql/satellite_data_schema.sql b/exercises/18_GPS_Field_QA/sql/satellite_data_schema.sql new file mode 100644 index 0000000..789a955 --- /dev/null +++ b/exercises/18_GPS_Field_QA/sql/satellite_data_schema.sql @@ -0,0 +1,270 @@ +-- 20260406 ChatGPT +-- $Header$ +-- +-- Example: +-- createdb satellite_data +-- psql -d satellite_data -f satellite_data_schema.sql +-- +-- Purpose: +-- Schema for importing GNSS field QA CSV logs generated by T-Beam units. +-- A log file is recorded in table logs, and each CSV row is stored in +-- table log_data with a foreign-key reference back to logs. +-- +-- Notes: +-- This revision adds support for the enhanced logger fields: +-- * sample_seq +-- * ms_since_run_start +-- The importer can still load older files that do not contain those fields. + +BEGIN; + +CREATE TABLE IF NOT EXISTS logs ( + log_id bigserial PRIMARY KEY, + source_filename text NOT NULL, + source_path text, + file_sha256 text, + file_size_bytes bigint, + raw_header_text text, + csv_header_line text NOT NULL, + imported_at timestamptz NOT NULL DEFAULT now(), + import_notes text, + + board_id text, + gnss_chip text, + firmware_exercise_name text, + firmware_version text, + boot_timestamp_utc timestamptz, + run_id text, + first_timestamp_utc timestamptz, + last_timestamp_utc timestamptz, + + row_count integer NOT NULL DEFAULT 0, + sample_count integer NOT NULL DEFAULT 0, + satellite_count integer NOT NULL DEFAULT 0, + + CONSTRAINT logs_source_filename_key UNIQUE (source_filename, file_sha256) +); + +COMMENT ON TABLE logs IS +'One row per imported CSV log file. Stores file provenance, hash, raw hash-prefixed header text, CSV header line, and summary counts.'; + +COMMENT ON COLUMN logs.log_id IS +'Primary key for this imported file.'; +COMMENT ON COLUMN logs.source_filename IS +'Filename of the imported CSV file, e.g. 20260406_175441_GUY.csv.'; +COMMENT ON COLUMN logs.source_path IS +'Path used at import time. Useful for provenance when files are staged from different directories.'; +COMMENT ON COLUMN logs.file_sha256 IS +'SHA-256 digest of the source file. Helps prevent duplicate imports and supports provenance audits.'; +COMMENT ON COLUMN logs.file_size_bytes IS +'Size of the source file in bytes.'; +COMMENT ON COLUMN logs.raw_header_text IS +'All leading hash-prefixed lines from the file, preserved verbatim as a text block.'; +COMMENT ON COLUMN logs.csv_header_line IS +'Effective CSV column header line used by the importer, either from the file or from importer fallback logic.'; +COMMENT ON COLUMN logs.imported_at IS +'Timestamp when the file was imported into PostgreSQL.'; +COMMENT ON COLUMN logs.import_notes IS +'Optional free-form notes supplied at import time.'; +COMMENT ON COLUMN logs.board_id IS +'Board identifier observed in the file, e.g. GUY, AMY, or CY.'; +COMMENT ON COLUMN logs.gnss_chip IS +'GNSS receiver chip or module name, e.g. MAX-M10S or L76K.'; +COMMENT ON COLUMN logs.firmware_exercise_name IS +'Firmware exercise or logger program name that produced the file.'; +COMMENT ON COLUMN logs.firmware_version IS +'Firmware build or version string written by the device.'; +COMMENT ON COLUMN logs.boot_timestamp_utc IS +'UTC timestamp representing when the device booted, as reported by the firmware.'; +COMMENT ON COLUMN logs.run_id IS +'Run identifier shared by all rows from one logger session.'; +COMMENT ON COLUMN logs.first_timestamp_utc IS +'First UTC sample timestamp found in this file.'; +COMMENT ON COLUMN logs.last_timestamp_utc IS +'Last UTC sample timestamp found in this file.'; +COMMENT ON COLUMN logs.row_count IS +'Total number of imported CSV data rows in the file.'; +COMMENT ON COLUMN logs.sample_count IS +'Number of imported rows where record_type = sample.'; +COMMENT ON COLUMN logs.satellite_count IS +'Number of imported rows where record_type = satellite.'; + +CREATE TABLE IF NOT EXISTS log_data ( + log_data_id bigserial PRIMARY KEY, + log_id bigint NOT NULL REFERENCES logs(log_id) ON DELETE CASCADE, + source_row_number integer NOT NULL, + + record_type text NOT NULL, + timestamp_utc timestamptz, + board_id text, + gnss_chip text, + firmware_exercise_name text, + firmware_version text, + boot_timestamp_utc timestamptz, + run_id text, + sample_seq bigint, + ms_since_run_start bigint, + + fix_type text, + fix_dimension integer, + sats_in_view integer, + sat_seen integer, + sats_used integer, + hdop numeric(8,3), + vdop numeric(8,3), + pdop numeric(8,3), + latitude double precision, + longitude double precision, + altitude_m numeric(12,3), + speed_mps numeric(12,3), + course_deg numeric(12,3), + pps_seen boolean, + quality_class text, + + gps_count integer, + galileo_count integer, + glonass_count integer, + beidou_count integer, + navic_count integer, + qzss_count integer, + sbas_count integer, + + mean_cn0 numeric(8,3), + max_cn0 numeric(8,3), + age_of_fix_ms bigint, + ttff_ms bigint, + longest_no_fix_ms bigint, + + sat_talker text, + sat_constellation text, + sat_prn integer, + sat_elevation_deg numeric(8,3), + sat_azimuth_deg numeric(8,3), + sat_snr numeric(8,3), + sat_used_in_solution boolean, + + raw_csv_line text, + + CONSTRAINT log_data_record_type_chk CHECK (record_type IN ('sample', 'satellite')) +); + +COMMENT ON TABLE log_data IS +'One row per CSV data row from a GNSS logger file. Stores both sample rows and per-satellite rows.'; + +COMMENT ON COLUMN log_data.log_data_id IS +'Primary key for one imported CSV data row.'; +COMMENT ON COLUMN log_data.log_id IS +'Foreign key to logs.log_id, linking this row back to the source file.'; +COMMENT ON COLUMN log_data.source_row_number IS +'1-based row number within the CSV data section, excluding preserved hash-prefixed header lines.'; +COMMENT ON COLUMN log_data.record_type IS +'Logical row type. sample = one epoch summary row. satellite = one satellite snapshot tied to a sample epoch.'; +COMMENT ON COLUMN log_data.timestamp_utc IS +'UTC time for the sample epoch, as reported by the GNSS receiver.'; +COMMENT ON COLUMN log_data.board_id IS +'Board identifier such as GUY, AMY, or CY.'; +COMMENT ON COLUMN log_data.gnss_chip IS +'GNSS module name, for example MAX-M10S or L76K.'; +COMMENT ON COLUMN log_data.firmware_exercise_name IS +'Name of the firmware exercise or logger mode that generated the row.'; +COMMENT ON COLUMN log_data.firmware_version IS +'Firmware build or version string embedded in the row.'; +COMMENT ON COLUMN log_data.boot_timestamp_utc IS +'UTC timestamp representing when the device booted, according to firmware.'; +COMMENT ON COLUMN log_data.run_id IS +'Run identifier shared across one logger session.'; +COMMENT ON COLUMN log_data.sample_seq IS +'Sequential sample number within a run. Starts at 1 when a new log begins. Satellite rows inherit the parent sample sequence value.'; +COMMENT ON COLUMN log_data.ms_since_run_start IS +'Monotonic milliseconds elapsed since the log file was opened. Useful for jitter, gap, and SD-write-impact analysis.'; +COMMENT ON COLUMN log_data.fix_type IS +'Fix quality label such as NO_FIX, 2D, 3D, or DGPS depending on what the firmware emits.'; +COMMENT ON COLUMN log_data.fix_dimension IS +'Numeric dimension of the position fix, typically 0, 2, or 3.'; +COMMENT ON COLUMN log_data.sats_in_view IS +'Count of satellites reportedly in view at this epoch according to the receiver summary.'; +COMMENT ON COLUMN log_data.sat_seen IS +'Count of satellites actually observed or emitted by the logger for this epoch. This may differ from sats_in_view depending on firmware logic.'; +COMMENT ON COLUMN log_data.sats_used IS +'Count of satellites used in the navigation solution at this epoch.'; +COMMENT ON COLUMN log_data.hdop IS +'Horizontal Dilution of Precision. Lower values generally indicate better horizontal geometry.'; +COMMENT ON COLUMN log_data.vdop IS +'Vertical Dilution of Precision. Lower values generally indicate better vertical geometry.'; +COMMENT ON COLUMN log_data.pdop IS +'Position Dilution of Precision. Combined geometry indicator for the position solution.'; +COMMENT ON COLUMN log_data.latitude IS +'Latitude in decimal degrees.'; +COMMENT ON COLUMN log_data.longitude IS +'Longitude in decimal degrees.'; +COMMENT ON COLUMN log_data.altitude_m IS +'Altitude in meters, generally above mean sea level according to receiver output.'; +COMMENT ON COLUMN log_data.speed_mps IS +'Receiver-reported speed over ground in meters per second.'; +COMMENT ON COLUMN log_data.course_deg IS +'Receiver-reported course over ground in degrees.'; +COMMENT ON COLUMN log_data.pps_seen IS +'Boolean indicating whether a PPS pulse was seen by the firmware during this epoch.'; +COMMENT ON COLUMN log_data.quality_class IS +'Human-friendly firmware quality label such as POOR, FAIR, GOOD, or similar.'; +COMMENT ON COLUMN log_data.gps_count IS +'Count of GPS satellites observed at this epoch.'; +COMMENT ON COLUMN log_data.galileo_count IS +'Count of Galileo satellites observed at this epoch.'; +COMMENT ON COLUMN log_data.glonass_count IS +'Count of GLONASS satellites observed at this epoch.'; +COMMENT ON COLUMN log_data.beidou_count IS +'Count of BeiDou satellites observed at this epoch.'; +COMMENT ON COLUMN log_data.navic_count IS +'Count of NavIC satellites observed at this epoch.'; +COMMENT ON COLUMN log_data.qzss_count IS +'Count of QZSS satellites observed at this epoch.'; +COMMENT ON COLUMN log_data.sbas_count IS +'Count of SBAS satellites observed at this epoch.'; +COMMENT ON COLUMN log_data.mean_cn0 IS +'Mean carrier-to-noise density estimate across observed satellites. Higher values generally indicate stronger signals.'; +COMMENT ON COLUMN log_data.max_cn0 IS +'Maximum carrier-to-noise density estimate among observed satellites for this epoch.'; +COMMENT ON COLUMN log_data.age_of_fix_ms IS +'Age of the current fix in milliseconds, as reported by the firmware or receiver API.'; +COMMENT ON COLUMN log_data.ttff_ms IS +'Time to first fix in milliseconds for the run or current acquisition state.'; +COMMENT ON COLUMN log_data.longest_no_fix_ms IS +'Longest contiguous no-fix interval observed so far in the run, in milliseconds.'; +COMMENT ON COLUMN log_data.sat_talker IS +'Talker or source prefix associated with the satellite row, such as GP, GA, GL, GB, or GN.'; +COMMENT ON COLUMN log_data.sat_constellation IS +'Constellation name for the satellite row, such as GPS, Galileo, GLONASS, BeiDou, NavIC, QZSS, or SBAS.'; +COMMENT ON COLUMN log_data.sat_prn IS +'PRN or SVID number identifying the satellite within its constellation.'; +COMMENT ON COLUMN log_data.sat_elevation_deg IS +'Satellite elevation angle above the horizon in degrees.'; +COMMENT ON COLUMN log_data.sat_azimuth_deg IS +'Satellite azimuth in degrees clockwise from true north, according to receiver output.'; +COMMENT ON COLUMN log_data.sat_snr IS +'Signal-to-noise style quality measure for this satellite row. Depending on firmware, this may be SNR or CN0-like output.'; +COMMENT ON COLUMN log_data.sat_used_in_solution IS +'Boolean indicating whether this specific satellite was used in the navigation solution.'; +COMMENT ON COLUMN log_data.raw_csv_line IS +'Original CSV line preserved verbatim for audit and recovery purposes.'; + +CREATE INDEX IF NOT EXISTS log_data_log_id_idx + ON log_data(log_id); + +CREATE INDEX IF NOT EXISTS log_data_run_id_idx + ON log_data(run_id); + +CREATE INDEX IF NOT EXISTS log_data_timestamp_idx + ON log_data(timestamp_utc); + +CREATE INDEX IF NOT EXISTS log_data_record_type_idx + ON log_data(record_type); + +CREATE INDEX IF NOT EXISTS log_data_board_run_seq_idx + ON log_data(board_id, run_id, sample_seq, record_type); + +CREATE INDEX IF NOT EXISTS log_data_satellite_lookup_idx + ON log_data(sat_constellation, sat_prn, timestamp_utc) + WHERE record_type = 'satellite'; + +COMMIT; diff --git a/exercises/18_GPS_Field_QA/src/main.cpp b/exercises/18_GPS_Field_QA/src/main.cpp new file mode 100644 index 0000000..3b515d8 --- /dev/null +++ b/exercises/18_GPS_Field_QA/src/main.cpp @@ -0,0 +1,757 @@ +// 20260406 ChatGPT +// Exercise 18_GPS_Field_QA + +#include +#include +#include +#include +#include +#include +#include +#include + +#include "ClockDiscipline.h" +#include "Config.h" +#include "DisplayManager.h" +#include "GnssManager.h" +#include "RunStats.h" +#include "StartupSdManager.h" +#include "StorageManager.h" +#include "tbeam_supreme_adapter.h" + +using namespace field_qa; + +namespace { + +XPowersLibInterface* g_pmu = nullptr; +DisplayManager g_display; +GnssManager g_gnss; +ClockDiscipline g_clock; +StorageManager g_storage; +RunStats g_stats; +StartupSdManager g_sd(Serial); +WebServer g_server(80); + +char g_runId[48] = {0}; +char g_bootTimestampUtc[32] = {0}; +char g_serialLine[160] = {0}; +char g_apSsid[32] = {0}; +size_t g_serialLineLen = 0; + +bool g_clockDisciplined = false; +bool g_loggingEnabled = false; +bool g_periodicSerialEnabled = false; +bool g_storageReady = false; +bool g_storageMounted = false; +bool g_webReady = false; +size_t g_logFileCount = 0; +uint32_t g_sampleSeq = 0; +uint32_t g_runStartMs = 0; +bool g_buttonPrevPressed = false; +bool g_buttonConfirmActive = false; +bool g_buttonHoldHandled = false; +uint32_t g_buttonPressedMs = 0; +uint32_t g_buttonConfirmDeadlineMs = 0; +uint32_t g_buttonStopMessageUntilMs = 0; + +uint32_t g_lastSampleMs = 0; +uint32_t g_lastFlushMs = 0; +uint32_t g_lastDisplayMs = 0; +uint32_t g_lastStatusMs = 0; +uint32_t g_lastDisciplineAttemptMs = 0; +volatile uint32_t g_ppsEdgeCount = 0; + +static constexpr uint32_t kButtonHoldPromptMs = 1500; +static constexpr uint32_t kButtonConfirmWindowMs = 3000; +static constexpr uint32_t kButtonStopMessageMs = 4000; + +void IRAM_ATTR onPpsEdge() { + ++g_ppsEdgeCount; +} + +String htmlEscape(const String& in) { + String out; + out.reserve(in.length() + 16); + for (size_t i = 0; i < in.length(); ++i) { + const char c = in[i]; + if (c == '&') out += "&"; + else if (c == '<') out += "<"; + else if (c == '>') out += ">"; + else if (c == '"') out += """; + else out += c; + } + return out; +} + +String urlEncode(const String& in) { + String out; + char hex[4]; + for (size_t i = 0; i < in.length(); ++i) { + const unsigned char c = (unsigned char)in[i]; + if (isalnum(c) || c == '-' || c == '_' || c == '.' || c == '/' || c == '~') { + out += (char)c; + } else { + snprintf(hex, sizeof(hex), "%%%02X", c); + out += hex; + } + } + return out; +} + +bool waitForNextPps(void*, uint32_t timeoutMs) { + const uint32_t startEdges = g_ppsEdgeCount; + const uint32_t startMs = millis(); + while ((uint32_t)(millis() - startMs) < timeoutMs) { + g_gnss.poll(); + g_sd.update(); + if (g_ppsEdgeCount != startEdges) { + return true; + } + delay(2); + } + return false; +} + +void setRunIdentityFromClock(const ClockDateTime& dt) { + ClockDiscipline::formatIsoUtc(dt, g_bootTimestampUtc, sizeof(g_bootTimestampUtc)); + ClockDiscipline::makeRunId(dt, kBoardId, g_runId, sizeof(g_runId)); +} + +void printProvenance() { + Serial.printf("exercise=%s\n", kExerciseName); + Serial.printf("version=%s\n", kFirmwareVersion); + Serial.printf("board_id=%s\n", kBoardId); + Serial.printf("gnss_chip=%s\n", kGnssChip); + Serial.printf("detected_chip=%s\n", g_gnss.detectedChipName()); + Serial.printf("storage=%s\n", kStorageName); + Serial.printf("sample_period_ms=%lu\n", (unsigned long)kSamplePeriodMs); + Serial.printf("log_period_ms=%lu\n", (unsigned long)kLogFlushPeriodMs); + Serial.printf("run_id=%s\n", g_runId[0] ? g_runId : "PENDING_CLOCK"); + Serial.printf("clock_disciplined=%s\n", g_clockDisciplined ? "yes" : "no"); + Serial.printf("web_ssid=%s\n", g_apSsid); + Serial.printf("web_url=http://192.168.%u.1/\n", (unsigned)kLogApIpOctet); +} + +void printSummary() { + Serial.println("summary:"); + Serial.printf("build=%s\n", kFirmwareVersion); + Serial.printf("run_id=%s\n", g_runId[0] ? g_runId : "PENDING_CLOCK"); + Serial.printf("clock_disciplined=%s\n", g_clockDisciplined ? "yes" : "no"); + Serial.printf("boot_timestamp_utc=%s\n", g_bootTimestampUtc[0] ? g_bootTimestampUtc : "UNKNOWN"); + Serial.printf("elapsed_ms=%lu\n", (unsigned long)g_stats.elapsedMs(millis())); + Serial.printf("ttff_ms=%lu\n", (unsigned long)g_stats.ttffMs()); + Serial.printf("longest_no_fix_ms=%lu\n", (unsigned long)g_stats.longestNoFixMs()); + Serial.printf("storage_ready=%s\n", g_storageReady ? "yes" : "no"); + Serial.printf("storage_mounted=%s\n", g_storageMounted ? "yes" : "no"); + Serial.printf("storage_error=%s\n", g_storage.lastError()); + Serial.printf("storage_log_dir=%s\n", kLogDir); + Serial.printf("log_file=%s\n", g_storage.currentPath()); + Serial.printf("storage_file_open=%s\n", g_storage.fileOpen() ? "yes" : "no"); + Serial.printf("storage_total_bytes=%u\n", g_storageMounted ? (unsigned)SD.totalBytes() : 0U); + Serial.printf("storage_used_bytes=%u\n", g_storageMounted ? (unsigned)SD.usedBytes() : 0U); + Serial.printf("storage_buffered_bytes=%u\n", (unsigned)g_storage.bufferedBytes()); + Serial.printf("storage_log_count=%u\n", (unsigned)g_logFileCount); + Serial.printf("web_ready=%s\n", g_webReady ? "yes" : "no"); + Serial.printf("web_url=http://192.168.%u.1/\n", (unsigned)kLogApIpOctet); +} + +void printStatusLine(const GnssSample& sample) { + char ts[24]; + if (sample.validTime) { + snprintf(ts, + sizeof(ts), + "%04u%02u%02u_%02u%02u%02uZ", + (unsigned)sample.year, + (unsigned)sample.month, + (unsigned)sample.day, + (unsigned)sample.hour, + (unsigned)sample.minute, + (unsigned)sample.second); + } else { + strlcpy(ts, "NO_UTC", sizeof(ts)); + } + Serial.printf("%s board=%s fix=%s used=%d view=%d hdop=%s lat=%s lon=%s alt=%s q=%s clock=%s log=%s\n", + ts, + kBoardId, + fixTypeToString(sample.fixType), + sample.satsUsed < 0 ? 0 : sample.satsUsed, + sample.satsInView < 0 ? 0 : sample.satsInView, + sample.validHdop ? String(sample.hdop, 1).c_str() : "", + sample.validLocation ? String(sample.latitude, 5).c_str() : "", + sample.validLocation ? String(sample.longitude, 5).c_str() : "", + sample.validAltitude ? String(sample.altitudeM, 1).c_str() : "", + qualityClassForSample(sample), + g_clockDisciplined ? "disciplined" : "waiting", + g_loggingEnabled ? "on" : "off"); +} + +bool ensureStorageReady() { + if (!g_clockDisciplined || !g_storageMounted || g_storageReady || g_runId[0] == '\0') { + return g_storageReady; + } + + g_logFileCount = g_storage.logFileCount(); + if (g_logFileCount > kMaxLogFilesBeforePause) { + Serial.printf("INFO: auto logging paused, log count %u exceeds limit %u\n", + (unsigned)g_logFileCount, + (unsigned)kMaxLogFilesBeforePause); + return false; + } + + g_storageReady = g_storage.startLog(g_runId, g_bootTimestampUtc); + if (!g_storageReady) { + Serial.printf("ERROR: log start failed: %s\n", g_storage.lastError()); + return false; + } + + g_sampleSeq = 0; + g_runStartMs = millis(); + g_loggingEnabled = true; + g_logFileCount = g_storage.logFileCount(); + Serial.printf("logging started: %s\n", g_storage.currentPath()); + return true; +} + +void stopLoggingCleanly(const char* reason) { + if (!g_loggingEnabled && !g_storageReady) { + return; + } + g_storage.flush(); + g_storage.close(); + g_storageReady = false; + g_loggingEnabled = false; + if (reason && reason[0] != '\0') { + Serial.printf("logging stopped: %s\n", reason); + } else { + Serial.println("logging stopped"); + } + g_buttonStopMessageUntilMs = millis() + kButtonStopMessageMs; +} + +bool rescanSdCard() { + g_storage.flush(); + g_storage.close(); + g_storageReady = false; + g_loggingEnabled = false; + const bool mounted = g_sd.forceRemount(); + g_storageMounted = g_sd.isMounted(); + if (mounted) { + g_sd.printCardInfo(); + (void)ensureStorageReady(); + } + return mounted; +} + +void pollStopButton() { + const uint32_t now = millis(); + const bool pressed = (digitalRead(BUTTON_PIN) == LOW); + + if (g_buttonConfirmActive && (int32_t)(now - g_buttonConfirmDeadlineMs) >= 0) { + g_buttonConfirmActive = false; + } + + if (pressed && !g_buttonPrevPressed) { + g_buttonPressedMs = now; + g_buttonHoldHandled = false; + if (g_buttonConfirmActive && g_loggingEnabled) { + stopLoggingCleanly("button confirm"); + g_buttonConfirmActive = false; + } + } else if (pressed && !g_buttonHoldHandled && !g_buttonConfirmActive && g_loggingEnabled && + (uint32_t)(now - g_buttonPressedMs) >= kButtonHoldPromptMs) { + g_buttonHoldHandled = true; + g_buttonConfirmActive = true; + g_buttonConfirmDeadlineMs = now + kButtonConfirmWindowMs; + g_display.showBoot("Stop recording?", "Press again in 3s"); + } + + if (!pressed && g_buttonPrevPressed) { + g_buttonHoldHandled = false; + } + + g_buttonPrevPressed = pressed; +} + +void handleSdStateTransitions() { + g_sd.update(); + if (g_sd.consumeMountedEvent()) { + g_storageMounted = true; + Serial.println("SD mounted"); + g_sd.printCardInfo(); + (void)ensureStorageReady(); + } + if (g_sd.consumeRemovedEvent()) { + Serial.println("SD removed"); + g_storageMounted = false; + g_storage.close(); + g_storageReady = false; + g_loggingEnabled = false; + } + g_storageMounted = g_sd.isMounted(); +} + +void attemptClockDiscipline(const GnssSample& sample) { + if (g_clockDisciplined || !sample.validTime) { + return; + } + if ((uint32_t)(millis() - g_lastDisciplineAttemptMs) < kClockDisciplineRetryMs) { + return; + } + g_lastDisciplineAttemptMs = millis(); + + ClockDateTime disciplinedUtc{}; + bool hadPriorRtc = false; + int64_t driftSeconds = 0; + if (!g_clock.disciplineFromGnss(sample, + waitForNextPps, + nullptr, + disciplinedUtc, + hadPriorRtc, + driftSeconds)) { + Serial.println("clock discipline pending: waiting for fresh GPS time and PPS"); + return; + } + + setRunIdentityFromClock(disciplinedUtc); + g_clockDisciplined = true; + Serial.printf("RTC disciplined to GPS: %s", g_bootTimestampUtc); + if (hadPriorRtc) { + Serial.printf(" drift=%+llds", (long long)driftSeconds); + } + Serial.println(); + g_display.showBoot("Clock disciplined", g_runId); + (void)ensureStorageReady(); +} + +void sampleAndMaybeLog() { + GnssSample sample = g_gnss.makeSample(); + g_stats.updateFromSample(sample, millis()); + sample.ttffMs = g_stats.ttffMs(); + sample.longestNoFixMs = g_stats.longestNoFixMs(); + + attemptClockDiscipline(sample); + + if (g_loggingEnabled && g_storageReady) { + const uint32_t sampleSeq = ++g_sampleSeq; + const uint32_t msSinceRunStart = millis() - g_runStartMs; + SatelliteInfo sats[kMaxSatellites]; + const size_t satCount = g_gnss.copySatellites(sats, kMaxSatellites); + g_storage.appendSampleCsv(sample, sampleSeq, msSinceRunStart, g_runId, g_bootTimestampUtc); + g_storage.appendSatelliteCsv(sample, sampleSeq, msSinceRunStart, sats, satCount, g_runId, g_bootTimestampUtc); + } + + if (g_periodicSerialEnabled && (uint32_t)(millis() - g_lastStatusMs) >= kStatusPeriodMs) { + g_lastStatusMs = millis(); + printStatusLine(sample); + } + if ((uint32_t)(millis() - g_lastDisplayMs) >= kDisplayPeriodMs) { + g_lastDisplayMs = millis(); + if (g_clockDisciplined) { + if (g_buttonConfirmActive) { + g_display.showBoot("Stop recording?", "Press again in 3s"); + } else if ((uint32_t)(millis() - g_buttonStopMessageUntilMs) < kButtonStopMessageMs) { + g_display.showBoot("Halted", "Safe to power off"); + } else { + g_display.showSample(sample, g_stats, g_loggingEnabled); + } + } else { + g_display.showBoot("Waiting for GPS UTC", sample.validTime ? "Awaiting PPS" : "No valid time yet"); + } + } +} + +void buildFileTreeHtml(String& html, const char* path) { + File dir = SD.open(path, FILE_READ); + if (!dir || !dir.isDirectory()) { + dir.close(); + html += "
  • directory unavailable
  • "; + return; + } + + File entry = dir.openNextFile(); + while (entry) { + const String name = entry.name(); + String leaf = name; + const int slash = leaf.lastIndexOf('/'); + if (slash >= 0) { + leaf.remove(0, slash + 1); + } + + if (leaf.startsWith(".")) { + entry.close(); + entry = dir.openNextFile(); + continue; + } + + if (entry.isDirectory()) { + String parent = path ? String(path) : String("/"); + if (parent.isEmpty()) { + parent = "/"; + } + if (!parent.endsWith("/")) { + parent += "/"; + } + const String childPath = parent + leaf; + html += "
  • "; + html += htmlEscape(leaf); + html += "/
      "; + entry.close(); + buildFileTreeHtml(html, childPath.c_str()); + html += "
  • "; + } else { + String parent = path ? String(path) : String("/"); + if (parent.isEmpty()) { + parent = "/"; + } + if (!parent.endsWith("/")) { + parent += "/"; + } + const String childPath = parent + leaf; + html += "
  • "; + html += htmlEscape(leaf); + html += " ("; + html += String((unsigned)entry.size()); + html += " bytes) "; + html += "download "; + html += "erase
  • "; + entry.close(); + } + entry = dir.openNextFile(); + } + dir.close(); +} + +bool normalizeWebPath(const String& input, String& out) { + return g_storage.normalizePath(input.c_str(), out); +} + +void handleWebIndex() { + g_storage.flush(); + String html; + html.reserve(8192); + html += "GPSQA "; + html += kBoardId; + html += ""; + html += "

    GPSQA "; + html += kBoardId; + html += "

    "; + html += "Clock disciplined: "; + html += g_clockDisciplined ? "yes" : "no"; + html += "
    Run ID: "; + html += htmlEscape(g_runId[0] ? String(g_runId) : String("PENDING_CLOCK")); + html += "
    Boot UTC: "; + html += htmlEscape(g_bootTimestampUtc[0] ? String(g_bootTimestampUtc) : String("UNKNOWN")); + html += "
    Storage mounted: "; + html += g_storageMounted ? "yes" : "no"; + html += "
    Storage ready: "; + html += g_storageReady ? "yes" : "no"; + html += "
    Storage error: "; + html += htmlEscape(String(g_storage.lastError())); + html += "
    Current log: "; + html += htmlEscape(String(g_storage.currentPath())); + html += "

    "; + html += "

    status "; + html += "flush "; + html += "start "; + html += "stop "; + html += "sd_rescan "; + html += "erase_logs

    "; + html += "

    SD Tree

      "; + + if (!g_storageMounted) { + html += "
    • SD not mounted
    • "; + } else { + buildFileTreeHtml(html, "/"); + } + + html += "

    Web commands also accept query forms like "; + html += "/cmd?erase=/logs/20260406_093912_CY.csv

    "; + html += ""; + g_server.send(200, "text/html; charset=utf-8", html); +} + +void handleWebDownload() { + g_storage.flush(); + String pathArg = g_server.hasArg("path") ? g_server.arg("path") : g_server.arg("name"); + String fullPath; + if (!normalizeWebPath(pathArg, fullPath)) { + g_server.send(400, "text/plain", "invalid path"); + return; + } + + File file = SD.open(fullPath.c_str(), FILE_READ); + if (!file) { + g_server.send(404, "text/plain", "not found"); + return; + } + + String downloadName = file.name(); + const int slash = downloadName.lastIndexOf('/'); + if (slash >= 0) { + downloadName.remove(0, slash + 1); + } + g_server.sendHeader("Content-Disposition", String("attachment; filename=\"") + downloadName + "\""); + g_server.setContentLength(file.size()); + g_server.send(200, "application/octet-stream", ""); + WiFiClient client = g_server.client(); + uint8_t buffer[512]; + while (file.available()) { + const size_t readBytes = file.read(buffer, sizeof(buffer)); + if (readBytes == 0) { + break; + } + client.write(buffer, readBytes); + } + file.close(); +} + +void handleWebCommand() { + String response; + + if (g_server.hasArg("erase")) { + g_storage.flush(); + const String path = g_server.arg("erase"); + if (g_storage.eraseFile(path.c_str())) { + g_storageReady = g_storage.ready(); + if (!g_storageReady) { + g_loggingEnabled = false; + } + g_logFileCount = g_storage.logFileCount(); + response = String("erased ") + path; + } else { + response = String("erase failed: ") + g_storage.lastError(); + } + } else if (g_server.hasArg("erase_logs")) { + g_storage.flush(); + g_storage.eraseLogs(Serial); + g_storageReady = g_storage.ready(); + if (!g_storageReady) { + g_loggingEnabled = false; + } + g_logFileCount = g_storage.logFileCount(); + response = "logs erased"; + } else if (g_server.hasArg("flush")) { + g_storage.flush(); + response = "buffer flushed"; + } else if (g_server.hasArg("stop")) { + stopLoggingCleanly("web stop"); + response = "logging stopped"; + } else if (g_server.hasArg("sd_rescan")) { + response = rescanSdCard() ? "sd mounted" : "sd rescan failed"; + } else if (g_server.hasArg("start")) { + if (!g_clockDisciplined) { + response = "clock not disciplined yet"; + } else if (!g_storageMounted) { + response = "sd not mounted"; + } else { + g_storageReady = false; + response = ensureStorageReady() ? String("logging started: ") + g_storage.currentPath() + : String("start failed: ") + g_storage.lastError(); + } + } else if (g_server.hasArg("status")) { + response.reserve(256); + response += "clock_disciplined="; + response += g_clockDisciplined ? "yes" : "no"; + response += "\nrun_id="; + response += g_runId[0] ? g_runId : "PENDING_CLOCK"; + response += "\nlog_file="; + response += g_storage.currentPath(); + response += "\nstorage_mounted="; + response += g_storageMounted ? "yes" : "no"; + response += "\nstorage_ready="; + response += g_storageReady ? "yes" : "no"; + response += "\nsd_state="; + response += g_storageMounted ? "mounted" : "absent"; + } else { + response = "commands: status flush start stop sd_rescan erase= erase_logs=1"; + } + + g_server.send(200, "text/plain; charset=utf-8", response); +} + +void startWebServer() { + snprintf(g_apSsid, sizeof(g_apSsid), "%s%s", kLogApPrefix, kBoardId); + WiFi.mode(WIFI_AP); + IPAddress ip(192, 168, kLogApIpOctet, 1); + IPAddress gw(192, 168, kLogApIpOctet, 1); + IPAddress nm(255, 255, 255, 0); + WiFi.softAPConfig(ip, gw, nm); + if (strlen(kLogApPassword) > 0) { + WiFi.softAP(g_apSsid, kLogApPassword); + } else { + WiFi.softAP(g_apSsid, nullptr); + } + + g_server.on("/", HTTP_GET, handleWebIndex); + g_server.on("/download", HTTP_GET, handleWebDownload); + g_server.on("/cmd", HTTP_GET, handleWebCommand); + g_server.begin(); + g_webReady = true; +} + +void handleCommand(const char* line) { + if (!line || line[0] == '\0') { + return; + } + Serial.printf("-->%s\n", line); + + if (strcasecmp(line, "status") == 0) { + GnssSample sample = g_gnss.makeSample(); + sample.longestNoFixMs = g_stats.longestNoFixMs(); + sample.ttffMs = g_stats.ttffMs(); + printStatusLine(sample); + } else if (strcasecmp(line, "summary") == 0) { + printSummary(); + } else if (strcasecmp(line, "verbose") == 0) { + g_periodicSerialEnabled = true; + Serial.println("periodic status enabled"); + } else if (strcasecmp(line, "quiet") == 0) { + g_periodicSerialEnabled = false; + Serial.println("periodic status disabled"); + } else if (strcasecmp(line, "start") == 0) { + if (!g_clockDisciplined) { + Serial.println("clock not disciplined yet"); + } else if (!g_storageMounted) { + Serial.println("sd not mounted"); + } else { + g_storageReady = false; + if (ensureStorageReady()) { + Serial.println("logging started"); + } else { + Serial.printf("logging start failed: %s\n", g_storage.lastError()); + } + } + } else if (strcasecmp(line, "stop") == 0) { + stopLoggingCleanly("serial stop"); + } else if (strcasecmp(line, "sd_rescan") == 0) { + Serial.println(rescanSdCard() ? "sd mounted" : "sd rescan failed"); + } else if (strcasecmp(line, "flush") == 0) { + g_storage.flush(); + Serial.println("log buffer flushed"); + } else if (strcasecmp(line, "ls") == 0) { + g_storage.listFiles(Serial); + } else if (strncasecmp(line, "cat ", 4) == 0) { + g_storage.catFile(Serial, line + 4); + } else if (strncasecmp(line, "erase ", 6) == 0) { + if (g_storage.eraseFile(line + 6)) { + g_storageReady = g_storage.ready(); + if (!g_storageReady) { + g_loggingEnabled = false; + } + g_logFileCount = g_storage.logFileCount(); + Serial.println("file erased"); + } else { + Serial.printf("erase failed: %s\n", g_storage.lastError()); + } + } else if (strcasecmp(line, "erase_logs") == 0) { + g_storage.eraseLogs(Serial); + g_storageReady = g_storage.ready(); + if (!g_storageReady) { + g_loggingEnabled = false; + } + g_logFileCount = g_storage.logFileCount(); + } else if (strcasecmp(line, "discipline") == 0) { + g_clockDisciplined = false; + g_lastDisciplineAttemptMs = 0; + Serial.println("clock discipline requested"); + } else { + Serial.println("commands: status quiet verbose flush start stop sd_rescan summary ls cat erase erase_logs discipline"); + } +} + +void pollSerialConsole() { + while (Serial.available() > 0) { + const int c = Serial.read(); + if (c < 0) { + continue; + } + if (c == '\r' || c == '\n') { + if (g_serialLineLen > 0) { + g_serialLine[g_serialLineLen] = '\0'; + handleCommand(g_serialLine); + g_serialLineLen = 0; + } + continue; + } + if (g_serialLineLen + 1 < sizeof(g_serialLine)) { + g_serialLine[g_serialLineLen++] = (char)c; + } else { + g_serialLineLen = 0; + } + } +} + +} // namespace + +void setup() { + Serial.begin(115200); + delay(kSerialDelayMs); + + Serial.println(); + Serial.println("=================================================="); + Serial.println("Exercise 18: GPS Field QA"); + Serial.println("=================================================="); + + if (!tbeam_supreme::initPmuForPeripherals(g_pmu, &Serial)) { + Serial.println("WARNING: PMU init failed"); + } + + g_display.begin(); + pinMode(BUTTON_PIN, INPUT_PULLUP); + g_display.showBoot("Booting...", kBoardId); + g_stats.begin(millis()); + g_gnss.begin(); + (void)g_gnss.probeAtStartup(Serial); + startWebServer(); + + SdWatcherConfig sdCfg; + if (!g_sd.begin(sdCfg)) { + Serial.println("WARNING: SD watcher init failed"); + } + g_storageMounted = g_sd.isMounted(); + if (g_storageMounted) { + g_sd.printCardInfo(); + } + +#ifdef GPS_1PPS_PIN + attachInterrupt(digitalPinToInterrupt(GPS_1PPS_PIN), onPpsEdge, RISING); +#endif + + ClockDateTime rtcNow{}; + int64_t rtcEpoch = 0; + if (g_clock.readValidRtc(rtcNow, &rtcEpoch)) { + char rtcIso[32]; + ClockDiscipline::formatIsoUtc(rtcNow, rtcIso, sizeof(rtcIso)); + Serial.printf("RTC present at boot: %s\n", rtcIso); + } else { + Serial.println("RTC invalid at boot"); + } + + printProvenance(); + g_display.showBoot("Waiting for GPS UTC", "No log before RTC set"); + + g_lastSampleMs = millis(); + g_lastFlushMs = millis(); +} + +void loop() { + pollSerialConsole(); + pollStopButton(); + g_gnss.poll(); + handleSdStateTransitions(); + g_server.handleClient(); + + const uint32_t now = millis(); + if ((uint32_t)(now - g_lastSampleMs) >= kSamplePeriodMs) { + g_lastSampleMs = now; + sampleAndMaybeLog(); + } + if (g_storageReady && (uint32_t)(now - g_lastFlushMs) >= kLogFlushPeriodMs) { + g_lastFlushMs = now; + g_storage.flush(); + } +} diff --git a/exercises/README.md b/exercises/README.md new file mode 100644 index 0000000..8cca821 --- /dev/null +++ b/exercises/README.md @@ -0,0 +1,162 @@ +## Buttons +There are three buttons +![T-Beam SUPREME](../img/20260212_182225_Thu.png) + +![](../img/20260212_182300_Thu.png) + +Listed in order from Antenna to USB port: +1) RESET +2) POWER +3) BOOT + ![](../img/20260212_184521_Thu.png) + +## RESET +The RESET button only needs to be depressed for less than 1 second. Doing so will cause the unit to reboot. If you have a "screen" monitor on the USB device, it will be disconnected. A good way to capture boot posting messages from within the unit is to have your command in a console ready to execute by depressing the RETURN key and you depress RETURN immediately after you depress the unit's RESET button, possibly allowing 1/2 second to allow kernel to recognize the new USB device, i.e. /dev/ttyACM0 +## POWER +If your unit is powered OFF, simply depress the POWER button will start the unit. If the unit is powered ON, then depressing the POWER button and holding it down for 6 second will cause the unit to power down. The OLED display will go dark when the unit has successfully been powered down. + +## BOOT +A) TODO: what happens when unit is powered ON and you depress BOOT? +B) TODO: what happens when unit is powered OFF and you depress BOOT? +C) To upload new binary image: depress the BOOT button and hold down, then while holding down the BOOT button disconnect the USB line and then reconnect the USB line and then lift up on the BOOT button. This sequence will cause the unit to await an upload of an image over USB to be stored in its FLASH memory. You can place the unit in this mode and then several minutes later perform the upload from your console, the unit will duly await an upload session. See further how to flash a new image in the section "Flashing Binary". Note: although the uploader may state it is trying to reset after successful installation of the new image, it seems that software attempt does not work and you have to manually depress the unit's RESET button to force a RESET. Remember, if you want to see the boot's posting, you should have your terminal ready to run "screen" so you capture the initial postings after you click RESET. +# Button Protocol For Uploading New Image +Pushing BOOT when the unit is currently running will not do anything. You have to shut the unit down first. So: +1) Depress POWER button for 6 seconds +2) Depress BOOT button and while depressed, click RESET button +3) Lift up BOOT button +The unit is now waiting for the serial console so commence an upload. You are not time restricted, so you can go through the above 3 steps and then 5 minutes later proceed with an upload in the command console. + +# Exercises +These are progressve tests you can run to confirm how to access the unit's functionality and validate your workbench. Each exercise has it's own README.md + +Exercise 00: 00_usb_radio_check + +Exercise 01: ASCII ping-pong over LoRa (serial only). Using two units, each sends a message to the other and you monitor both units through two screen consoles. + +Exercise 02: Add OLED display + +Exercise 03: Add SD logging + +Exercise 04: Replace ASCII payload with microR packets + +Exercise 05: SD provisioning with identity.bin, peer list, beacon + +Exercise 06: RTC check (PCF8563) read/set and persistence validation + +Exercise 07: SD startup watcher library harness with hot-insert detection + +Exercise 08: SystemStartup package scaffold (shared begin/update API) + +Each exercise is self-contained: + + its own platformio.ini + its own src/main.cpp + its own README.md with exact commands + +To clean: + + pio run -t clean + +Nuclear option: + + rm -rf .pio + +Rebuild: + + pio run -e node_a + + pio run -e node_b + +## Upload existing firmware (will not recompile if unchanged) +### Upload node A (set your port) +pio run -e node_a -t upload --upload-port /dev/ttyACM0 + +### Upload node B +pio run -e node_b -t upload --upload-port /dev/ttyACM1 + +To monitor both: + + screen /dev/ttyACM0 115200 + screen /dev/ttyACM1 115200 + +Attach antennas (transmitting without antenna will cause power feedback and likely destroy your circuits) +Keep boards at least ~30 cm apart so you don’t desense the receivers. + +shared board pin headers in one place: + shared/ + boards/ + tbeam-s3-core_pins.h + +# Features +## SD Card +You can read and write to SD cards that are FAT formatted. (Can they be Linux formatted?) +## Real Time Clock ("RTC") +## Microphone + + +# Creating Automatic Recognition by udev + +## Find the unique ID. +This assumes that your device is being assigned to ACM0 when you connect it with your workstation. + +jlpoole@jp ~ $ udevadm info -a -n /dev/ttyACM0 | nl| grep serial + 67 ATTRS{product}=="USB JTAG/serial debug unit" + 72 ATTRS{serial}=="48:CA:43:5A:8C:74" + 282 ATTRS{serial}=="0000:00:1a.7" +jlpoole@jp ~ $ + +The 6 token value delimited by colons is the unique ID. + +## Adding a UDEV rule +Create a udev rule modeled after the ones below: + + /etc/udev/rules.d/99-ttyt-tbeam.rules + +Sample content for devices that will be named AMY-GUY + +# 99-ttyt-tbeam.rules +# LilyGO T-Beam SUPREME (ESP32-S3 USB JTAG/serial debug unit) +# Create stable symlinks: /dev/ttytAMY, /dev/ttytBOB, ... + +SUBSYSTEM=="tty", ATTRS{idVendor}=="303a", ATTRS{idProduct}=="1001", ATTRS{serial}=="48:CA:43:5B:BF:68", MODE:="0660", GROUP:="dialout",SYMLINK+="ttytAMY" +SUBSYSTEM=="tty", ATTRS{idVendor}=="303a", ATTRS{idProduct}=="1001", ATTRS{serial}=="48:CA:43:5A:93:DC", MODE:="0660", GROUP:="dialout",SYMLINK+="ttytBOB" +SUBSYSTEM=="tty", ATTRS{idVendor}=="303a", ATTRS{idProduct}=="1001", ATTRS{serial}=="48:CA:43:5A:91:44", MODE:="0660", GROUP:="dialout",SYMLINK+="ttytCY" +SUBSYSTEM=="tty", ATTRS{idVendor}=="303a", ATTRS{idProduct}=="1001", ATTRS{serial}=="48:CA:43:5A:93:A0", MODE:="0660", GROUP:="dialout",SYMLINK+="ttytDAN" +SUBSYSTEM=="tty", ATTRS{idVendor}=="303a", ATTRS{idProduct}=="1001", ATTRS{serial}=="48:CA:43:5A:90:D0", MODE:="0660", GROUP:="dialout",SYMLINK+="ttytED" + +Then: + + sudo udevadm control --reload-rules + sudo udevadm trigger --subsystem-match=tty + +Sanity check command when a device is plugged in: + + udevadm info -a -n /dev/ttyACM0 | less + +## Example Walk-through +Here's a complete walk-thru + +jlpoole@jp ~ $ date; udevadm info -a -n /dev/ttyACM0 | nl| grep serial +Fri Apr 3 10:54:25 PDT 2026 + 67 ATTRS{product}=="USB JTAG/serial debug unit" + 72 ATTRS{serial}=="48:CA:43:5A:40:E0" + 282 ATTRS{serial}=="0000:00:1a.7" +jlpoole@jp ~ $ # GUY=48:CA:43:5A:40:E0 +jlpoole@jp ~ $ sudo nano /etc/udev/rules.d/99-ttyt-tbeam.rules +jlpoole@jp ~ $ sudo udevadm control --reload-rules +jlpoole@jp ~ $ sudo udevadm trigger --subsystem-match=tty +jlpoole@jp ~ $ ls /dev/ttyt* +/dev/ttytGUY +jlpoole@jp ~ $ + + + + + +Xtras: +To customize your system so when the unit is attached, it will have the same name, e.g. /dev/ttytBOB: + +You'll need the id, use this when the unit is plugged in: + pio device list |grep -A3 + + diff --git a/external/ArxContainer b/external/ArxContainer new file mode 160000 index 0000000..d6affcd --- /dev/null +++ b/external/ArxContainer @@ -0,0 +1 @@ +Subproject commit d6affcd0bc83219b863c20abf7c269214db8db2a diff --git a/external/ArxTypeTraits b/external/ArxTypeTraits new file mode 160000 index 0000000..702de9c --- /dev/null +++ b/external/ArxTypeTraits @@ -0,0 +1 @@ +Subproject commit 702de9cc59c7e047cdc169ae3547718b289d2c02 diff --git a/external/DebugLog b/external/DebugLog new file mode 160000 index 0000000..b581f7d --- /dev/null +++ b/external/DebugLog @@ -0,0 +1 @@ +Subproject commit b581f7dde6c276c5df684e2328f406d9754d2f46 diff --git a/external/microReticulum_Firmware b/external/microReticulum_Firmware new file mode 160000 index 0000000..5dc607f --- /dev/null +++ b/external/microReticulum_Firmware @@ -0,0 +1 @@ +Subproject commit 5dc607fc7227c46ccb19244e456782fbb7775eae diff --git a/firmware/fieldtest_beacon/platformio.ini b/firmware/fieldtest_beacon/platformio.ini new file mode 100644 index 0000000..1716089 --- /dev/null +++ b/firmware/fieldtest_beacon/platformio.ini @@ -0,0 +1,27 @@ +; platformio.ini +; 20260212 ChatGPT +; $Id$ +; $HeadURL$ + +[platformio] +default_envs = tbeam_supreme + +[env:tbeam_supreme] +platform = espressif32 +framework = arduino +board = esp32-s3-devkitc-1 ; <-- change to your exact board if needed +monitor_speed = 115200 + +; Pull in microReticulum from your repo tree +build_flags = + -I ../../external/microReticulum/src + -I ../../external/microReticulum_Firmware + -D BOARD_MODEL=BOARD_TBEAM_S_V1 + -D FIELDTEST_BEACON=1 + +lib_deps = + bblanchon/ArduinoJson@~7.4.2 + hideakitai/MsgPack@~0.4.2 + rweather/Crypto@^0.4.0 + ; SD stack usually comes with Arduino core + ; Add your LoRa radio library here (RadioLib, SX126x-Arduino, etc.) diff --git a/firmware/fieldtest_beacon/src/main.cpp b/firmware/fieldtest_beacon/src/main.cpp new file mode 100644 index 0000000..27d38ec --- /dev/null +++ b/firmware/fieldtest_beacon/src/main.cpp @@ -0,0 +1,117 @@ +// firmware/fieldtest_beacon/src/main.cpp +// 20260212 ChatGPT +// $Id$ +// $HeadURL$ + +#include +#include +#include +#include "Boards.h" + +// Include microReticulum headers from your external tree +#include "Identity.h" +#include "Reticulum.h" +#include "Destination.h" +#include "Transport.h" + +// ---------- User-tunable ---------- +static const uint32_t BEACON_INTERVAL_MS = 15000; +static const char* PATH_IDENTITY_BIN = "/provisioning/identity.bin"; +static const char* PATH_LABEL_TXT = "/provisioning/label.txt"; +static const char* LOG_PATH = "/logs/fieldtest.log"; + +// ---------- Globals ---------- +static uint32_t g_iter = 0; +static uint32_t g_next_tx = 0; +static File g_log; + +// Source board pin mapping from microReticulum_Firmware board definitions. +static const int SD_CS_PIN = SD_CS; + +// Simple line logger (append-only) +static void log_line(const String& line) { + if (!g_log) return; + g_log.println(line); + g_log.flush(); +} + +// Read whole file into a buffer +static bool read_file(const char* path, std::vector& out) { + File f = SD.open(path, FILE_READ); + if (!f) return false; + size_t n = f.size(); + out.resize(n); + if (n > 0) f.read(out.data(), n); + f.close(); + return true; +} + +static String read_text_file(const char* path) { + File f = SD.open(path, FILE_READ); + if (!f) return String(""); + String s = f.readString(); + f.close(); + s.trim(); + return s; +} + +void setup() { + Serial.begin(115200); + delay(250); + + // ---- SD init ---- + if (!SD.begin(SD_CS_PIN)) { + Serial.println("SD init failed"); + return; + } + + g_log = SD.open(LOG_PATH, FILE_APPEND); + if (!g_log) { + Serial.println("Failed to open log file"); + return; + } + + const String label = read_text_file(PATH_LABEL_TXT); + log_line("BOOT\tlabel=" + label); + + std::vector id_bytes; + if (!read_file(PATH_IDENTITY_BIN, id_bytes)) { + log_line(String("ERROR\tmissing_identity\tpath=") + PATH_IDENTITY_BIN); + return; + } + log_line(String("IDENTITY_OK\tbytes=") + id_bytes.size()); + + // ---- Load Identity into microReticulum ---- + // TODO: adjust to the actual API in your microReticulum version + // Example intent: + // Identity ident = Identity::fromBytes(id_bytes.data(), id_bytes.size()); + // Reticulum rns; + // rns.setIdentity(ident); + // rns.begin(...); + // + // Also: initialize your LoRa Interface and attach it to Reticulum here. + + log_line("RNS_INIT_OK"); + + g_next_tx = millis() + 3000; // wait a moment before first TX +} + +void loop() { + // TODO: run microReticulum polling / tick function, and radio interface polling + + const uint32_t now = millis(); + if ((int32_t)(now - g_next_tx) >= 0) { + g_next_tx = now + BEACON_INTERVAL_MS; + + // Build payload: timestamp + iterator (GPS later) + const uint32_t epoch_guess = (uint32_t) (time(nullptr)); // placeholder; GPS later + const String payload = String("t=") + epoch_guess + " i=" + g_iter++; + + // TODO: send to peer(s) (Destination derived from peers list) + // bool ok = destination.send(payload_bytes, len); + + log_line(String("TX\t") + payload); + } + + delay(5); +} diff --git a/img/20260212_182225_Thu.png b/img/20260212_182225_Thu.png new file mode 100644 index 0000000..fd32f48 Binary files /dev/null and b/img/20260212_182225_Thu.png differ diff --git a/img/20260212_182300_Thu.png b/img/20260212_182300_Thu.png new file mode 100644 index 0000000..1f587fc Binary files /dev/null and b/img/20260212_182300_Thu.png differ diff --git a/img/20260212_184521_Thu.png b/img/20260212_184521_Thu.png new file mode 100644 index 0000000..a3d9565 Binary files /dev/null and b/img/20260212_184521_Thu.png differ diff --git a/notes_Feb_18_2026.txt b/notes_Feb_18_2026.txt new file mode 100644 index 0000000..dc1119c --- /dev/null +++ b/notes_Feb_18_2026.txt @@ -0,0 +1,95 @@ + + + + +A: + pio run -e node_a -t upload --upload-port /dev/ttyACM0 +then: + date; screen /dev/ttyACM0 115200 + +B: + pio run -e node_b -t upload --upload-port /dev/ttyACM1 +then: + date; screen /dev/ttyACM1 115200 + + + +tbeam: +Console 1: + cd /usr/local/src/sx1302_hal/packet_forwarder + sudo ./lora_pkt_fwd -c global_conf.reticulum_915000000_sf8_bw125.json + +Console 2: + cd /usr/local/src/sx1302_hal/util_net_downlink + ./net_downlink -P 1730 -l uplinks_$(date +%Y%m%d_%H%M%S).csv + + +Example capture: + INFO: Received pkt from mote: 65732042 (fcnt=29540) + + JSON up: {"rxpk":[{"jver":1,"tmst":413765588,"chan":8,"rfch":0,"freq":915.000000,"mid":16,"stat":1,"modu":"LORA","datr":"SF7BW125","codr":"4/5","rssis":-16,"lsnr":10.8,"foff":-45,"rssi":-15,"size":29,"data":"IEIgc2VuZHMgZ3JlZXRpbmdzLiBpdGVyPTE1MDQ="}]} + INFO: [up] PUSH_ACK received in 31 ms + + INFO: Received pkt from mote: 65732041 (fcnt=29540) + + JSON up: {"rxpk":[{"jver":1,"tmst":414742074,"chan":8,"rfch":0,"freq":915.000000,"mid":16,"stat":1,"modu":"LORA","datr":"SF7BW125","codr":"4/5","rssis":-16,"lsnr":10.2,"foff":-297,"rssi":-15,"size":29,"data":"IEEgc2VuZHMgZ3JlZXRpbmdzLiBpdGVyPTE0OTg="}]} + INFO: [up] PUSH_ACK received in 31 ms + + INFO: Received pkt from mote: 65732042 (fcnt=29540) + + JSON up: {"rxpk":[{"jver":1,"tmst":415766626,"chan":8,"rfch":0,"freq":915.000000,"mid":16,"stat":1,"modu":"LORA","datr":"SF7BW125","codr":"4/5","rssis":-16,"lsnr":11.0,"foff":-53,"rssi":-15,"size":29,"data":"IEIgc2VuZHMgZ3JlZXRpbmdzLiBpdGVyPTE1MDU="}]} + INFO: [up] PUSH_ACK received in 31 ms + + INFO: Received pkt from mote: 65732041 (fcnt=29540) + + JSON up: {"rxpk":[{"jver":1,"tmst":416744088,"chan":8,"rfch":0,"freq":915.000000,"mid":16,"stat":1,"modu":"LORA","datr":"SF7BW125","codr":"4/5","rssis":-16,"lsnr":10.2,"foff":-289,"rssi":-15,"size":29,"data":"IEEgc2VuZHMgZ3JlZXRpbmdzLiBpdGVyPTE0OTk="}]} + INFO: [up] PUSH_ACK received in 31 ms + INFO: [down] PULL_ACK received in 31 ms + +tbeam /usr/local/src/sx1302_hal/util_net_downlink # tail uplinks_20260218_155659.csv +445782393,,8,0,915.000000,16,1,LORA,7,125,4/5,-15.0,-16.0,10.8,29,20422073656e6473206772656574696e67732e20697465723d31353230 +446764659,,8,0,915.000000,16,1,LORA,7,125,4/5,-15.0,-16.0,10.5,29,20412073656e6473206772656574696e67732e20697465723d31353134 + + +==== With all 5 units ====== +Amy: +set the tab: + echo -ne "\033]30;Amy\007" + pio run -e amy -t upload --upload-port /dev/ttyACM0 + + date; screen /dev/ttyACM0 115200 + +Bob: + echo -ne "\033]30;Bob\007" + pio run -e bob -t upload --upload-port /dev/ttyACM1 + + date; screen /dev/ttyACM1 115200 + +Cy: + source ~/rnsenv/bin/activate + echo -ne "\033]30;Cy\007" + pio run -e cy -t upload --upload-port /dev/ttyACM2 + + date; screen /dev/ttyACM2 115200 + +Dan: + source ~/rnsenv/bin/activate + echo -ne "\033]30;Dan\007" + pio run -e cy -t upload --upload-port /dev/ttyACM3 + + date; screen /dev/ttyACM3 115200 + +Ed: + source ~/rnsenv/bin/activate + echo -ne "\033]30;Ed\007" + pio run -e cy -t upload --upload-port /dev/ttyACM4 + date; screen /dev/ttyACM4 115200 + + +Logging: + + cd /usr/local/src/sx1302_hal/util_net_downlink + ./net_downlink -P 1730 -l uplinks_$(date +%Y%m%d_%H%M%S).csv + + ls -lath /usr/local/src/sx1302_hal/util_net_downlink |head -n 3 + \ No newline at end of file diff --git a/shared/boards/tbeam-s3-core/pins_arduino.h b/shared/boards/tbeam-s3-core/pins_arduino.h new file mode 100644 index 0000000..e66b69e --- /dev/null +++ b/shared/boards/tbeam-s3-core/pins_arduino.h @@ -0,0 +1,42 @@ +#ifndef Pins_Arduino_h +#define Pins_Arduino_h + +#include + +#define USB_VID 0x303a +#define USB_PID 0x1001 + +// Now declared in .platformio/packages/framework-arduinoespressif32/cores/esp32/Arduino.h +// #define NUM_ANALOG_INPUTS 20 +// #define EXTERNAL_NUM_INTERRUPTS 46 +// #define NUM_DIGITAL_PINS 48 +// #define analogInputToDigitalPin(p) (((p) < 20) ? (analogChannelToDigitalPin(p)) : -1) +// #define digitalPinToInterrupt(p) (((p) < 48) ? (p) : -1) +// #define digitalPinHasPWM(p) (p < 46) + +static const uint8_t TX = 43; +static const uint8_t RX = 44; + +// The default Wire will be mapped to PMU and RTC +static const uint8_t SDA = 42; +static const uint8_t SCL = 41; + +// Default SPI will be mapped to Radio +static const uint8_t SS = 10; +static const uint8_t MOSI = 11; +static const uint8_t MISO = 13; +static const uint8_t SCK = 12; + +// Another SPI bus shares SD card and QMI8653 inertial measurement sensor +#define SPI_MOSI (35) +#define SPI_SCK (36) +#define SPI_MISO (37) +#define SPI_CS (47) +#define IMU_CS (34) + +#define SDCARD_CS SPI_CS +#define IMU_INT (33) +// #define PMU_IRQ (40) +#define RTC_INT (14) + +#endif /* Pins_Arduino_h */ \ No newline at end of file diff --git a/shared/boards/tbeam-s3-core/platformio.ini b/shared/boards/tbeam-s3-core/platformio.ini new file mode 100644 index 0000000..512cf32 --- /dev/null +++ b/shared/boards/tbeam-s3-core/platformio.ini @@ -0,0 +1,26 @@ +; The 1.0 release of the LilyGo TBEAM-S3-Core board +[env:tbeam-s3-core] +custom_meshtastic_hw_model = 12 +custom_meshtastic_hw_model_slug = LILYGO_TBEAM_S3_CORE +custom_meshtastic_architecture = esp32-s3 +custom_meshtastic_actively_supported = true +custom_meshtastic_support_level = 1 +custom_meshtastic_display_name = LILYGO T-Beam Supreme +custom_meshtastic_images = tbeam-s3-core.svg +custom_meshtastic_tags = LilyGo +custom_meshtastic_requires_dfu = true +custom_meshtastic_partition_scheme = 8MB + +extends = esp32s3_base +board = tbeam-s3-core +board_build.partitions = default_8MB.csv +board_check = true + +lib_deps = + ${esp32s3_base.lib_deps} + # renovate: datasource=custom.pio depName=SensorLib packageName=lewisxhe/library/SensorLib + lewisxhe/SensorLib@0.3.4 + +build_flags = + ${esp32s3_base.build_flags} + -I variants/esp32s3/tbeam-s3-core diff --git a/shared/boards/tbeam-s3-core/provenance.txt b/shared/boards/tbeam-s3-core/provenance.txt new file mode 100644 index 0000000..b3a8a4f --- /dev/null +++ b/shared/boards/tbeam-s3-core/provenance.txt @@ -0,0 +1,6 @@ +2/12/2026 copied from meshastic/firmware/variants/esp32s3/tbeam-s3-core +since that appeared in the build tree of a successfully operating binary: +jlpoole@jp /usr/local/src/meshtastic/firmware $ cd /usr/local/src/meshtastic/firmware +ls -1 .pio/build 2>/dev/null | egrep -i 'tbeam|t-beam|s3' | sort +tbeam-s3-core +jlpoole@jp /usr/local/src/meshtastic/firmware $ diff --git a/shared/boards/tbeam-s3-core/rfswitch.h b/shared/boards/tbeam-s3-core/rfswitch.h new file mode 100644 index 0000000..19080ce --- /dev/null +++ b/shared/boards/tbeam-s3-core/rfswitch.h @@ -0,0 +1,11 @@ +#include "RadioLib.h" + +static const uint32_t rfswitch_dio_pins[] = {RADIOLIB_LR11X0_DIO5, RADIOLIB_LR11X0_DIO6, RADIOLIB_NC, RADIOLIB_NC, RADIOLIB_NC}; + +static const Module::RfSwitchMode_t rfswitch_table[] = { + // mode DIO5 DIO6 + {LR11x0::MODE_STBY, {LOW, LOW}}, {LR11x0::MODE_RX, {HIGH, LOW}}, + {LR11x0::MODE_TX, {LOW, HIGH}}, {LR11x0::MODE_TX_HP, {LOW, HIGH}}, + {LR11x0::MODE_TX_HF, {LOW, LOW}}, {LR11x0::MODE_GNSS, {LOW, LOW}}, + {LR11x0::MODE_WIFI, {LOW, LOW}}, END_OF_MODE_TABLE, +}; \ No newline at end of file diff --git a/shared/boards/tbeam-s3-core/variant.h b/shared/boards/tbeam-s3-core/variant.h new file mode 100644 index 0000000..9ce4aad --- /dev/null +++ b/shared/boards/tbeam-s3-core/variant.h @@ -0,0 +1,79 @@ +// #define BUTTON_NEED_PULLUP // if set we need to turn on the internal CPU pullup during sleep + +#define I2C_SDA1 42 // Used for PMU management and PCF8563 +#define I2C_SCL1 41 // Used for PMU management and PCF8563 + +#define I2C_SDA 17 // For QMC6310 sensors and screens +#define I2C_SCL 18 // For QMC6310 sensors and screens + +#define BUTTON_PIN 0 // The middle button GPIO on the T-Beam S3 +// #define EXT_NOTIFY_OUT 13 // Default pin to use for Ext Notify Module. + +#define LED_STATE_ON 0 // State when LED is lit + +// TTGO uses a common pinout for their SX1262 vs RF95 modules - both can be enabled and we will probe at runtime for RF95 and if +// not found then probe for SX1262 +#define USE_SX1262 +#define USE_SX1268 +#define USE_LR1121 + +#define LORA_DIO0 -1 // a No connect on the SX1262 module +#define LORA_RESET 5 +#define LORA_DIO1 1 // SX1262 IRQ +#define LORA_DIO2 4 // SX1262 BUSY +#define LORA_DIO3 // Not connected on PCB, but internally on the TTGO SX1262, if DIO3 is high the TXCO is enabled + +#ifdef USE_SX1262 +#define SX126X_CS 10 // FIXME - we really should define LORA_CS instead +#define SX126X_DIO1 LORA_DIO1 +#define SX126X_BUSY LORA_DIO2 +#define SX126X_RESET LORA_RESET +// Not really an E22 but TTGO seems to be trying to clone that +#define SX126X_DIO2_AS_RF_SWITCH +#define SX126X_DIO3_TCXO_VOLTAGE 1.8 +// Internally the TTGO module hooks the SX1262-DIO2 in to control the TX/RX switch (which is the default for the sx1262interface +// code) +#endif + +// LR1121 +#ifdef USE_LR1121 +#define LR1121_IRQ_PIN 1 +#define LR1121_NRESET_PIN LORA_RESET +#define LR1121_BUSY_PIN 4 +#define LR1121_SPI_NSS_PIN 10 +#define LR1121_SPI_SCK_PIN 12 +#define LR1121_SPI_MOSI_PIN 11 +#define LR1121_SPI_MISO_PIN 13 +#define LR11X0_DIO3_TCXO_VOLTAGE 3.0 +#define LR11X0_DIO_AS_RF_SWITCH +#endif + +// Leave undefined to disable our PMU IRQ handler. DO NOT ENABLE THIS because the pmuirq can cause sperious interrupts +// and waking from light sleep +// #define PMU_IRQ 40 +#define HAS_AXP2101 + +// PCF8563 RTC Module +#define PCF8563_RTC 0x51 + +// Specify the PMU as Wire1. In the t-beam-s3 core, PCF8563 and PMU share the bus +#define PMU_USE_WIRE1 +#define RTC_USE_WIRE1 + +#define LORA_SCK 12 +#define LORA_MISO 13 +#define LORA_MOSI 11 +#define LORA_CS 10 + +#define GPS_RX_PIN 9 +#define GPS_TX_PIN 8 +#define GPS_WAKEUP_PIN 7 +#define GPS_1PPS_PIN 6 + +#define HAS_SDCARD // Have SPI interface SD card slot +#define SDCARD_USE_SPI1 + +// has 32768 Hz crystal +#define HAS_32768HZ 1 + +#define USE_SH1106 \ No newline at end of file diff --git a/shared/boards/tbeam_supreme_adapter.h b/shared/boards/tbeam_supreme_adapter.h new file mode 100644 index 0000000..132f025 --- /dev/null +++ b/shared/boards/tbeam_supreme_adapter.h @@ -0,0 +1,71 @@ +#pragma once + +#include +#include +#include +#include "Boards.h" + +namespace tbeam_supreme { + +inline int i2cSda() { return I2C_SDA; } +inline int i2cScl() { return I2C_SCL; } +inline int sdSck() { return SD_CLK; } +inline int sdMiso() { return SD_MISO; } +inline int sdMosi() { return SD_MOSI; } +inline int sdCs() { return SD_CS; } +inline int imuCs() { return IMU_CS; } + +inline bool initPmuForPeripherals(XPowersLibInterface*& pmu, Print* out = nullptr) { + if (BOARD_MODEL != BOARD_TBEAM_S_V1) { + if (out) out->println("PMU adapter: BOARD_MODEL is not T-Beam Supreme"); + return false; + } + + Wire1.begin(i2cSda(), i2cScl()); + + if (!pmu) { + pmu = new XPowersAXP2101(Wire1); + } + + if (!pmu->init()) { + if (out) out->println("PMU adapter: AXP2101 init failed"); + return false; + } + + // Match microReticulum_Firmware tbeam supreme rail setup. + pmu->setPowerChannelVoltage(XPOWERS_ALDO4, 3300); + pmu->enablePowerOutput(XPOWERS_ALDO4); + pmu->setPowerChannelVoltage(XPOWERS_ALDO3, 3300); + pmu->enablePowerOutput(XPOWERS_ALDO3); + pmu->setPowerChannelVoltage(XPOWERS_DCDC3, 3300); + pmu->enablePowerOutput(XPOWERS_DCDC3); + pmu->setPowerChannelVoltage(XPOWERS_ALDO2, 3300); + pmu->enablePowerOutput(XPOWERS_ALDO2); + pmu->setPowerChannelVoltage(XPOWERS_ALDO1, 3300); + pmu->enablePowerOutput(XPOWERS_ALDO1); + pmu->setPowerChannelVoltage(XPOWERS_BLDO1, 3300); + pmu->enablePowerOutput(XPOWERS_BLDO1); + + pmu->disablePowerOutput(XPOWERS_DCDC2); + pmu->disablePowerOutput(XPOWERS_DCDC5); + pmu->disablePowerOutput(XPOWERS_DLDO1); + pmu->disablePowerOutput(XPOWERS_DLDO2); + pmu->disablePowerOutput(XPOWERS_VBACKUP); + + pmu->setChargeTargetVoltage(XPOWERS_AXP2101_CHG_VOL_4V2); + pmu->setChargerConstantCurr(XPOWERS_AXP2101_CHG_CUR_500MA); + pmu->disableIRQ(XPOWERS_AXP2101_ALL_IRQ); + pmu->clearIrqStatus(); + pmu->disableTSPinMeasure(); + pmu->enableVbusVoltageMeasure(); + pmu->enableBattVoltageMeasure(); + + if (out) { + out->printf("PMU adapter: AXP2101 ready, BLDO1(SD)=%s\r\n", + pmu->isPowerChannelEnable(XPOWERS_BLDO1) ? "ON" : "OFF"); + } + + return pmu->isPowerChannelEnable(XPOWERS_BLDO1); +} + +} // namespace tbeam_supreme diff --git a/tools/99-ttyt-tbeam.rules b/tools/99-ttyt-tbeam.rules index 5fb5185..9e00a48 100644 --- a/tools/99-ttyt-tbeam.rules +++ b/tools/99-ttyt-tbeam.rules @@ -2,8 +2,6 @@ # LilyGO T-Beam SUPREME (ESP32-S3 USB JTAG/serial debug unit) # Stable symlinks for grep: /dev/ttytAMY, /dev/ttytBOB, ... # -# As root, save to /etc/udev/rules.d/99-ttyt-tbeam.rules -# # Created 2//19/26 with ChatGTP after tallying units one-by-one # SUBSYSTEM=="tty", ATTRS{idVendor}=="303a", ATTRS{idProduct}=="1001", ATTRS{serial}=="48:CA:43:5B:BF:68", MODE:="0660", GROUP:="dialout", SYMLINK+="ttytAMY" diff --git a/tools/CMakeLists.txt b/tools/CMakeLists.txt new file mode 100644 index 0000000..a4af285 --- /dev/null +++ b/tools/CMakeLists.txt @@ -0,0 +1 @@ +add_subdirectory(keygen) diff --git a/tools/constantTFCard/hw_debug/1st_run.log b/tools/constantTFCard/hw_debug/1st_run.log new file mode 100644 index 0000000..6e01b1f --- /dev/null +++ b/tools/constantTFCard/hw_debug/1st_run.log @@ -0,0 +1,190 @@ +John's interaction with the events below: + + 14149 Pressed down + [ 17543] let up + [ 30325] Pressed down + 35143 let up + 39993 killed. + +Compiled and uploaded: Wed Apr 1 15:07:20 PDT 2026 + +[ 12793][E][sd_diskio.cpp:199] sdCommand(): Card Failed! cmd: 0x29 +[ 12799][E][sd_diskio.cpp:806] sdcard_mount(): f_mount failed: (3) The physical drive cannot work +sample=3 state=BEGIN_FAIL rail=ON vbus=5.18 batt=0.00 pins=1/0/1/0 probeH(ff=8 z=0 o=0 FF FF FF FF) probeF(ff=8 z=0 o=0 FF FF FF FF) mount=none@0 type=NONE size=0MB root=FAIL +[ 13265][E][sd_diskio.cpp:199] sdCommand(): Card Failed! cmd: 0x37 +[ 13271][E][sd_diskio.cpp:199] sdCommand(): Card Failed! cmd: 0x29 +[ 13277][E][sd_diskio.cpp:806] sdcard_mount(): f_mount failed: (3) The physical drive cannot work +[ 13701][E][sd_diskio.cpp:199] sdCommand(): Card Failed! cmd: 0x37 +[ 13707][E][sd_diskio.cpp:199] sdCommand(): Card Failed! cmd: 0x29 +[ 13713][E][sd_diskio.cpp:806] sdcard_mount(): f_mount failed: (3) The physical drive cannot work +[ 14137][E][sd_diskio.cpp:199] sdCommand(): Card Failed! cmd: 0x37 +[ 14143][E][sd_diskio.cpp:199] sdCommand(): Card Failed! cmd: 0x29 +[ 14149][E][sd_diskio.cpp:806] sdcard_mount(): f_mount failed: (3) The physical drive cannot work +sample=4 state=MOUNT_OK rail=ON vbus=5.19 batt=0.00 pins=1/0/1/0 probeH(ff=8 z=0 o=0 FF FF FF FF) probeF(ff=8 z=0 o=0 FF FF FF FF) mount=HSPI@10000000 type=SDHC size=14910MB root=OK +sample=5 state=MOUNT_OK rail=ON vbus=5.18 batt=0.00 pins=1/0/1/0 probeH(ff=8 z=0 o=0 FF FF FF FF) probeF(ff=8 z=0 o=0 FF FF FF FF) mount=HSPI@400000 type=SDHC size=14910MB root=OK +sample=6 state=MOUNT_OK rail=ON vbus=5.18 batt=0.00 pins=1/0/1/0 probeH(ff=8 z=0 o=0 FF FF FF FF) probeF(ff=8 z=0 o=0 FF FF FF FF) mount=HSPI@400000 type=SDHC size=14910MB root=OK +sample=7 state=MOUNT_OK rail=ON vbus=5.18 batt=0.00 pins=1/0/1/0 probeH(ff=8 z=0 o=0 FF FF FF FF) probeF(ff=8 z=0 o=0 FF FF FF FF) mount=HSPI@400000 type=SDHC size=14910MB root=OK +sample=8 state=MOUNT_OK rail=ON vbus=5.18 batt=0.00 pins=1/0/1/0 probeH(ff=8 z=0 o=0 FF FF FF FF) probeF(ff=8 z=0 o=0 FF FF FF FF) mount=HSPI@400000 type=SDHC size=14910MB root=OK +sample=9 state=MOUNT_OK rail=ON vbus=5.18 batt=0.00 pins=1/0/1/0 probeH(ff=8 z=0 o=0 FF FF FF FF) probeF(ff=8 z=0 o=0 FF FF FF FF) mount=HSPI@400000 type=SDHC size=14910MB root=OK +sample=10 state=MOUNT_OK rail=ON vbus=5.18 batt=0.00 pins=1/0/1/0 probeH(ff=8 z=0 o=0 FF FF FF FF) probeF(ff=8 z=0 o=0 FF FF FF FF) mount=HSPI@400000 type=SDHC size=14910MB root=OK +sample=11 state=MOUNT_OK rail=ON vbus=5.18 batt=0.00 pins=1/0/1/0 probeH(ff=8 z=0 o=0 FF FF FF FF) probeF(ff=8 z=0 o=0 FF FF FF FF) mount=HSPI@400000 type=SDHC size=14910MB root=OK +sample=12 state=MOUNT_OK rail=ON vbus=5.18 batt=0.00 pins=1/0/1/0 probeH(ff=8 z=0 o=0 FF FF FF FF) probeF(ff=8 z=0 o=0 FF FF FF FF) mount=HSPI@400000 type=SDHC size=14910MB root=OK +sample=13 state=MOUNT_OK rail=ON vbus=5.18 batt=0.00 pins=1/0/1/0 probeH(ff=8 z=0 o=0 FF FF FF FF) probeF(ff=8 z=0 o=0 FF FF FF FF) mount=HSPI@400000 type=SDHC size=14910MB root=OK +sample=14 state=MOUNT_OK rail=ON vbus=5.18 batt=0.00 pins=1/0/1/0 probeH(ff=8 z=0 o=0 FF FF FF FF) probeF(ff=8 z=0 o=0 FF FF FF FF) mount=HSPI@400000 type=SDHC size=14910MB root=OK +sample=15 state=MOUNT_OK rail=ON vbus=5.18 batt=0.00 pins=1/0/1/0 probeH(ff=8 z=0 o=0 FF FF FF FF) probeF(ff=8 z=0 o=0 FF FF FF FF) mount=HSPI@400000 type=SDHC size=14910MB root=OK +sample=16 state=MOUNT_OK rail=ON vbus=5.18 batt=0.00 pins=1/0/1/0 probeH(ff=8 z=0 o=0 FF FF FF FF) probeF(ff=8 z=0 o=0 FF FF FF FF) mount=HSPI@400000 type=SDHC size=14910MB root=OK +[ 17543][E][sd_diskio.cpp:199] sdCommand(): Card Failed! cmd: 0xsample=17 state=MOUNT_OK rail=ON vbus=5.18 batt=0.00 pins=1/0/1/0 probeH(ff=8 z=0 o=0 FF FF FF FF) probeF(ff=8 z=0 o=0 FF FF FF FF) mount=HSPI@400000 type=SDHC size=14910MB root=OK +37 +[ 17549][E][sd_diskio.cpp:199] sdCommand(): Card Failed! cmd: 0x29 +[ 17555][E][sd_diskio.cpp:806] sdcard_mount(): f_mount failed: (3) The physical drive cannot work +[ 17979][E][sd_diskio.cpp:199] sdCommand(): Card Failed! cmd: 0x37 +[ 17985][E][sd_diskio.cpp:199] sdCommand(): Card Failed! cmd: 0x29 +[ 17991][E][sd_diskio.cpp:806] sdcard_mount(): f_mount failed: (3) The physical drive cannot work +[ 18415][E][sd_diskio.cpp:199] sdCommand(): Card Failed! cmd: 0x37 +[ 18421][E][sd_diskio.cpp:199] sdCommand(): Card Failed! cmd: 0x29 +[ 18427][E][sd_diskio.cpp:806] sdcard_mount(): f_mount failed: (3) The physical drive cannot work +[ 18851][E][sd_diskio.cpp:199] sdCommand(): Card Failed! cmd: 0x37 +[ 18857][E][sd_diskio.cpp:199] sdCommand(): Card Failed! cmd: 0x29 +[ 18863][E][sd_diskio.cpp:806] sdcard_mount(): f_mount failed: (3) The physical drive cannot work +[ 19287][E][sd_diskio.cpp:199] sdCommand(): Card Failed! cmd: 0x37 +[ 19293][E][sd_diskio.cpp:199] sdCommand(): Card Failed! cmd: 0x29 +[ 19299][E][sd_diskio.cpp:806] sdcard_mount(): f_mount failed: (3) The physical drive cannot work +[ 19723][E][sd_diskio.cpp:199] sdCommand(): Card Failed! cmd: 0x37 +[ 19729][E][sd_diskio.cpp:199] sdCommand(): Card Failed! cmd: 0x29 +[ 19735][E][sd_diskio.cpp:806] sdcard_mount(): f_mount failed: (3) The physical drive cannot work +[ 20159][E][sd_diskio.cpp:199] sdCommand(): Card Failed! cmd: 0x37 +[ 20165][E][sd_diskio.cpp:199] sdCommand(): Card Failed! cmd: 0x29 +[ 20171][E][sd_diskio.cpp:806] sdcard_mount(): f_mount failed: (3) The physical drive cannot work +[ 20595][E][sd_diskio.cpp:199] sdCommand(): Card Failed! cmd: 0x37 +[ 20601][E][sd_diskio.cpp:199] sdCommand(): Card Failed! cmd: 0x29 +[ 20607][E][sd_diskio.cpp:806] sdcard_mount(): f_mount failed: (3) The physical drive cannot work +sample=18 state=BEGIN_FAIL rail=ON vbus=5.18 batt=0.00 pins=1/0/1/0 probeH(ff=8 z=0 o=0 FF FF FF FF) probeF(ff=8 z=0 o=0 FF FF FF FF) mount=none@0 type=NONE size=0MB root=FAIL +[ 21073][E][sd_diskio.cpp:199] sdCommand(): Card Failed! cmd: 0x37 +[ 21079][E][sd_diskio.cpp:199] sdCommand(): Card Failed! cmd: 0x29 +[ 21085][E][sd_diskio.cpp:806] sdcard_mount(): f_mount failed: (3) The physical drive cannot work +[ 21509][E][sd_diskio.cpp:199] sdCommand(): Card Failed! cmd: 0x37 +[ 21515][E][sd_diskio.cpp:199] sdCommand(): Card Failed! cmd: 0x29 +[ 21521][E][sd_diskio.cpp:806] sdcard_mount(): f_mount failed: (3) The physical drive cannot work +[ 21945][E][sd_diskio.cpp:199] sdCommand(): Card Failed! cmd: 0x37 +[ 21951][E][sd_diskio.cpp:199] sdCommand(): Card Failed! cmd: 0x29 +[ 21957][E][sd_diskio.cpp:806] sdcard_mount(): f_mount failed: (3) The physical drive cannot work +[ 22381][E][sd_diskio.cpp:199] sdCommand(): Card Failed! cmd: 0x37 +[ 22387][E][sd_diskio.cpp:199] sdCommand(): Card Failed! cmd: 0x29 +[ 22393][E][sd_diskio.cpp:806] sdcard_mount(): f_mount failed: (3) The physical drive cannot work +[ 22817][E][sd_diskio.cpp:199] sdCommand(): Card Failed! cmd: 0x37 +[ 22823][E][sd_diskio.cpp:199] sdCommand(): Card Failed! cmd: 0x29 +[ 22829][E][sd_diskio.cpp:806] sdcard_mount(): f_mount failed: (3) The physical drive cannot work +[ 23253][E][sd_diskio.cpp:199] sdCommand(): Card Failed! cmd: 0x37 +[ 23259][E][sd_diskio.cpp:199] sdCommand(): Card Failed! cmd: 0x29 +[ 23265][E][sd_diskio.cpp:806] sdcard_mount(): f_mount failed: (3) The physical drive cannot work +[ 23689][E][sd_diskio.cpp:199] sdCommand(): Card Failed! cmd: 0x37 +[ 23695][E][sd_diskio.cpp:199] sdCommand(): Card Failed! cmd: 0x29 +[ 23701][E][sd_diskio.cpp:806] sdcard_mount(): f_mount failed: (3) The physical drive cannot work +[ 24125][E][sd_diskio.cpp:199] sdCommand(): Card Failed! cmd: 0x37 +[ 24131][E][sd_diskio.cpp:199] sdCommand(): Card Failed! cmd: 0x29 +[ 24137][E][sd_diskio.cpp:806] sdcard_mount(): f_mount failed: (3) The physical drive cannot work +[ 24603][E][sd_diskio.cpp:199] sdCommand(): Card Failed! cmd: 0xsample=19 state=BEGIN_FAIL rail=ON vbus=5.19 batt=0.00 pins=1/0/1/0 probeH(ff=8 z=0 o=0 FF FF FF FF) probeF(ff=8 z=0 o=0 FF FF FF FF) mount=none@0 type=NONE size=0MB root=FAIL +37 +[ 24609][E][sd_diskio.cpp:199] sdCommand(): Card Failed! cmd: 0x29 +[ 24615][E][sd_diskio.cpp:806] sdcard_mount(): f_mount failed: (3) The physical drive cannot work +[ 25039][E][sd_diskio.cpp:199] sdCommand(): Card Failed! cmd: 0x37 +[ 25045][E][sd_diskio.cpp:199] sdCommand(): Card Failed! cmd: 0x29 +[ 25051][E][sd_diskio.cpp:806] sdcard_mount(): f_mount failed: (3) The physical drive cannot work +[ 25475][E][sd_diskio.cpp:199] sdCommand(): Card Failed! cmd: 0x37 +[ 25481][E][sd_diskio.cpp:199] sdCommand(): Card Failed! cmd: 0x29 +[ 25487][E][sd_diskio.cpp:806] sdcard_mount(): f_mount failed: (3) The physical drive cannot work +[ 25911][E][sd_diskio.cpp:199] sdCommand(): Card Failed! cmd: 0x37 +[ 25917][E][sd_diskio.cpp:199] sdCommand(): Card Failed! cmd: 0x29 +[ 25923][E][sd_diskio.cpp:806] sdcard_mount(): f_mount failed: (3) The physical drive cannot work +[ 26347][E][sd_diskio.cpp:199] sdCommand(): Card Failed! cmd: 0x37 +[ 26353][E][sd_diskio.cpp:199] sdCommand(): Card Failed! cmd: 0x29 +[ 26359][E][sd_diskio.cpp:806] sdcard_mount(): f_mount failed: (3) The physical drive cannot work +[ 26783][E][sd_diskio.cpp:199] sdCommand(): Card Failed! cmd: 0x37 +[ 26789][E][sd_diskio.cpp:199] sdCommand(): Card Failed! cmd: 0x29 +[ 26795][E][sd_diskio.cpp:806] sdcard_mount(): f_mount failed: (3) The physical drive cannot work +[ 27219][E][sd_diskio.cpp:199] sdCommand(): Card Failed! cmd: 0x37 +[ 27225][E][sd_diskio.cpp:199] sdCommand(): Card Failed! cmd: 0x29 +[ 27231][E][sd_diskio.cpp:806] sdcard_mount(): f_mount failed: (3) The physical drive cannot work +[ 27655][E][sd_diskio.cpp:199] sdCommand(): Card Failed! cmd: 0x37 +[ 27661][E][sd_diskio.cpp:199] sdCommand(): Card Failed! cmd: 0x29 +[ 27667][E][sd_diskio.cpp:806] sdcard_mount(): f_mount failed: (3) The physical drive cannot work +sample=20 state=BEGIN_FAIL rail=ON vbus=5.19 batt=0.00 pins=1/0/1/0 probeH(ff=8 z=0 o=0 FF FF FF FF) probeF(ff=8 z=0 o=0 FF FF FF FF) mount=none@0 type=NONE size=0MB root=FAIL +[ 28133][E][sd_diskio.cpp:199] sdCommand(): Card Failed! cmd: 0x37 +[ 28139][E][sd_diskio.cpp:199] sdCommand(): Card Failed! cmd: 0x29 +[ 28145][E][sd_diskio.cpp:806] sdcard_mount(): f_mount failed: (3) The physical drive cannot work +[ 28569][E][sd_diskio.cpp:199] sdCommand(): Card Failed! cmd: 0x37 +[ 28575][E][sd_diskio.cpp:199] sdCommand(): Card Failed! cmd: 0x29 +[ 28581][E][sd_diskio.cpp:806] sdcard_mount(): f_mount failed: (3) The physical drive cannot work +[ 29005][E][sd_diskio.cpp:199] sdCommand(): Card Failed! cmd: 0x37 +[ 29011][E][sd_diskio.cpp:199] sdCommand(): Card Failed! cmd: 0x29 +[ 29017][E][sd_diskio.cpp:806] sdcard_mount(): f_mount failed: (3) The physical drive cannot work +[ 29441][E][sd_diskio.cpp:199] sdCommand(): Card Failed! cmd: 0x37 +[ 29447][E][sd_diskio.cpp:199] sdCommand(): Card Failed! cmd: 0x29 +[ 29453][E][sd_diskio.cpp:806] sdcard_mount(): f_mount failed: (3) The physical drive cannot work +[ 29877][E][sd_diskio.cpp:199] sdCommand(): Card Failed! cmd: 0x37 +[ 29883][E][sd_diskio.cpp:199] sdCommand(): Card Failed! cmd: 0x29 +[ 29889][E][sd_diskio.cpp:806] sdcard_mount(): f_mount failed: (3) The physical drive cannot work +[ 30313][E][sd_diskio.cpp:199] sdCommand(): Card Failed! cmd: 0x37 +[ 30319][E][sd_diskio.cpp:199] sdCommand(): Card Failed! cmd: 0x29 +[ 30325][E][sd_diskio.cpp:806] sdcard_mount(): f_mount failed: (3) The physical drive cannot work +sample=21 state=MOUNT_OK rail=ON vbus=5.19 batt=0.00 pins=1/0/1/0 probeH(ff=8 z=0 o=0 FF FF FF FF) probeF(ff=8 z=0 o=0 FF FF FF FF) mount=FSPI@4000000 type=SDHC size=14910MB root=OK +sample=22 state=MOUNT_OK rail=ON vbus=5.18 batt=0.00 pins=1/0/1/0 probeH(ff=8 z=0 o=0 FF FF FF FF) probeF(ff=8 z=0 o=0 FF FF FF FF) mount=HSPI@400000 type=SDHC size=14910MB root=OK +sample=23 state=MOUNT_OK rail=ON vbus=5.18 batt=0.00 pins=1/0/1/0 probeH(ff=8 z=0 o=0 FF FF FF FF) probeF(ff=8 z=0 o=0 FF FF FF FF) mount=HSPI@400000 type=SDHC size=14910MB root=OK +sample=24 state=MOUNT_OK rail=ON vbus=5.18 batt=0.00 pins=1/0/1/0 probeH(ff=8 z=0 o=0 FF FF FF FF) probeF(ff=8 z=0 o=0 FF FF FF FF) mount=HSPI@400000 type=SDHC size=14910MB root=OK +sample=25 state=MOUNT_OK rail=ON vbus=5.18 batt=0.00 pins=1/0/1/0 probeH(ff=8 z=0 o=0 FF FF FF FF) probeF(ff=8 z=0 o=0 FF FF FF FF) mount=HSPI@400000 type=SDHC size=14910MB root=OK +sample=26 state=MOUNT_OK rail=ON vbus=5.18 batt=0.00 pins=1/0/1/0 probeH(ff=8 z=0 o=0 FF FF FF FF) probeF(ff=8 z=0 o=0 FF FF FF FF) mount=HSPI@400000 type=SDHC size=14910MB root=OK +sample=27 state=MOUNT_OK rail=ON vbus=5.18 batt=0.00 pins=1/0/1/0 probeH(ff=8 z=0 o=0 FF FF FF FF) probeF(ff=8 z=0 o=0 FF FF FF FF) mount=HSPI@400000 type=SDHC size=14910MB root=OK +sample=28 state=MOUNT_OK rail=ON vbus=5.18 batt=0.00 pins=1/0/1/0 probeH(ff=8 z=0 o=0 FF FF FF FF) probeF(ff=8 z=0 o=0 FF FF FF FF) mount=HSPI@400000 type=SDHC size=14910MB root=OK +sample=29 state=MOUNT_OK rail=ON vbus=5.18 batt=0.00 pins=1/0/1/0 probeH(ff=8 z=0 o=0 FF FF FF FF) probeF(ff=8 z=0 o=0 FF FF FF FF) mount=HSPI@400000 type=SDHC size=14910MB root=OK +sample=30 state=MOUNT_OK rail=ON vbus=5.18 batt=0.00 pins=1/0/1/0 probeH(ff=8 z=0 o=0 FF FF FF FF) probeF(ff=8 z=0 o=0 FF FF FF FF) mount=HSPI@400000 type=SDHC size=14910MB root=OK +sample=31 state=MOUNT_OK rail=ON vbus=5.18 batt=0.00 pins=1/0/1/0 probeH(ff=8 z=0 o=0 FF FF FF FF) probeF(ff=8 z=0 o=0 FF FF FF FF) mount=HSPI@400000 type=SDHC size=14910MB root=OK +sample=32 state=MOUNT_OK rail=ON vbus=5.18 batt=0.00 pins=1/0/1/0 probeH(ff=8 z=0 o=0 FF FF FF FF) probeF(ff=8 z=0 o=0 FF FF FF FF) mount=HSPI@400000 type=SDHC size=14910MB root=OK +sample=33 state=MOUNT_OK rail=ON vbus=5.18 batt=0.00 pins=1/0/1/0 probeH(ff=8 z=0 o=0 FF FF FF FF) probeF(ff=8 z=0 o=0 FF FF FF FF) mount=HSPI@400000 type=SDHC size=14910MB root=OK +sample=34 state=MOUNT_OK rail=ON vbus=5.18 batt=0.00 pins=1/0/1/0 probeH(ff=8 z=0 o=0 FF FF FF FF) probeF(ff=8 z=0 o=0 FF FF FF FF) mount=HSPI@400000 type=SDHC size=14910MB root=OK +sample=35 state=MOUNT_OK rail=ON vbus=5.18 batt=0.00 pins=1/0/1/0 probeH(ff=8 z=0 o=0 FF FF FF FF) probeF(ff=8 z=0 o=0 FF FF FF FF) mount=HSPI@400000 type=SDHC size=14910MB root=OK +sample=36 state=MOUNT_OK rail=ON vbus=5.18 batt=0.00 pins=1/0/1/0 probeH(ff=8 z=0 o=0 FF FF FF FF) probeF(ff=8 z=0 o=0 FF FF FF FF) mount=HSPI@400000 type=SDHC size=14910MB root=OK +sample=37 state=MOUNT_OK rail=ON vbus=5.18 batt=0.00 pins=1/0/1/0 probeH(ff=8 z=0 o=0 FF FF FF FF) probeF(ff=8 z=0 o=0 FF FF FF FF) mount=HSPI@400000 type=SDHC size=14910MB root=OK +sample=38 state=MOUNT_OK rail=ON vbus=5.18 batt=0.00 pins=1/0/1/0 probeH(ff=8 z=0 o=0 FF FF FF FF) probeF(ff=8 z=0 o=0 FF FF FF FF) mount=HSPI@400000 type=SDHC size=14910MB root=OK +sample=39 state=MOUNT_OK rail=ON vbus=5.18 batt=0.00 pins=1/0/1/0 probeH(ff=8 z=0 o=0 FF FF FF FF) probeF(ff=8 z=0 o=0 FF FF FF FF) mount=HSPI@400000 type=SDHC size=14910MB root=OK +sample=40 state=MOUNT_OK rail=ON vbus=5.18 batt=0.00 pins=1/0/1/0 probeH(ff=8 z=0 o=0 FF FF FF FF) probeF(ff=8 z=0 o=0 FF FF FF FF) mount=HSPI@400000 type=SDHC size=14910MB root=OK +[ 35143][E][sd_diskio.cpp:199] sdCommand(): Card Failed! cmd: 0xsample=41 state=MOUNT_OK rail=ON vbus=5.18 batt=0.00 pins=1/0/1/0 probeH(ff=8 z=0 o=0 FF FF FF FF) probeF(ff=8 z=0 o=0 FF FF FF FF) mount=HSPI@400000 type=SDHC size=14910MB root=OK +37 +[ 35149][E][sd_diskio.cpp:199] sdCommand(): Card Failed! cmd: 0x29 +[ 35155][E][sd_diskio.cpp:806] sdcard_mount(): f_mount failed: (3) The physical drive cannot work +[ 35579][E][sd_diskio.cpp:199] sdCommand(): Card Failed! cmd: 0x37 +[ 35585][E][sd_diskio.cpp:199] sdCommand(): Card Failed! cmd: 0x29 +[ 35591][E][sd_diskio.cpp:806] sdcard_mount(): f_mount failed: (3) The physical drive cannot work +[ 36015][E][sd_diskio.cpp:199] sdCommand(): Card Failed! cmd: 0x37 +[ 36021][E][sd_diskio.cpp:199] sdCommand(): Card Failed! cmd: 0x29 +[ 36027][E][sd_diskio.cpp:806] sdcard_mount(): f_mount failed: (3) The physical drive cannot work +[ 36451][E][sd_diskio.cpp:199] sdCommand(): Card Failed! cmd: 0x37 +[ 36457][E][sd_diskio.cpp:199] sdCommand(): Card Failed! cmd: 0x29 +[ 36463][E][sd_diskio.cpp:806] sdcard_mount(): f_mount failed: (3) The physical drive cannot work +[ 36887][E][sd_diskio.cpp:199] sdCommand(): Card Failed! cmd: 0x37 +[ 36893][E][sd_diskio.cpp:199] sdCommand(): Card Failed! cmd: 0x29 +[ 36899][E][sd_diskio.cpp:806] sdcard_mount(): f_mount failed: (3) The physical drive cannot work +[ 37323][E][sd_diskio.cpp:199] sdCommand(): Card Failed! cmd: 0x37 +[ 37329][E][sd_diskio.cpp:199] sdCommand(): Card Failed! cmd: 0x29 +[ 37335][E][sd_diskio.cpp:806] sdcard_mount(): f_mount failed: (3) The physical drive cannot work +[ 37759][E][sd_diskio.cpp:199] sdCommand(): Card Failed! cmd: 0x37 +[ 37765][E][sd_diskio.cpp:199] sdCommand(): Card Failed! cmd: 0x29 +[ 37771][E][sd_diskio.cpp:806] sdcard_mount(): f_mount failed: (3) The physical drive cannot work +[ 38195][E][sd_diskio.cpp:199] sdCommand(): Card Failed! cmd: 0x37 +[ 38201][E][sd_diskio.cpp:199] sdCommand(): Card Failed! cmd: 0x29 +[ 38207][E][sd_diskio.cpp:806] sdcard_mount(): f_mount failed: (3) The physical drive cannot work +sample=42 state=BEGIN_FAIL rail=ON vbus=5.18 batt=0.00 pins=1/0/1/0 probeH(ff=8 z=0 o=0 FF FF FF FF) probeF(ff=8 z=0 o=0 FF FF FF FF) mount=none@0 type=NONE size=0MB root=FAIL +[ 38673][E][sd_diskio.cpp:199] sdCommand(): Card Failed! cmd: 0x37 +[ 38679][E][sd_diskio.cpp:199] sdCommand(): Card Failed! cmd: 0x29 +[ 38685][E][sd_diskio.cpp:806] sdcard_mount(): f_mount failed: (3) The physical drive cannot work +[ 39109][E][sd_diskio.cpp:199] sdCommand(): Card Failed! cmd: 0x37 +[ 39115][E][sd_diskio.cpp:199] sdCommand(): Card Failed! cmd: 0x29 +[ 39121][E][sd_diskio.cpp:806] sdcard_mount(): f_mount failed: (3) The physical drive cannot work +[ 39545][E][sd_diskio.cpp:199] sdCommand(): Card Failed! cmd: 0x37 +[ 39551][E][sd_diskio.cpp:199] sdCommand(): Card Failed! cmd: 0x29 +[ 39557][E][sd_diskio.cpp:806] sdcard_mount(): f_mount failed: (3) The physical drive cannot work +[ 39981][E][sd_diskio.cpp:199] sdCommand(): Card Failed! cmd: 0x37 +[ 39987][E][sd_diskio.cpp:199] sdCommand(): Card Failed! cmd: 0x29 +[ 39993][E][sd_diskio.cpp:806] sdcard_mount(): f_mount failed: (3) The physical drive cannot work +(rnsenv) jlpoole@jp /usr/local/src/microreticulum/microReticulumTbeam $ date +Wed Apr 1 15:08:35 PDT 2026 +(rnsenv) jlpoole@jp /usr/local/src/microreticulum/microReticulumTbeam $ \ No newline at end of file diff --git a/tools/constantTFCard/hw_debug/amy_vs_bob_identifications.txt b/tools/constantTFCard/hw_debug/amy_vs_bob_identifications.txt new file mode 100644 index 0000000..8fd2999 --- /dev/null +++ b/tools/constantTFCard/hw_debug/amy_vs_bob_identifications.txt @@ -0,0 +1,44 @@ +April 1, 2026 ~ 15:34 + +From AMY (broken SD Card): + + [ 15549][E][sd_diskio.cpp:806] sdcard_mount(): f_mount failed: (13) There is no valid FAT volume + + STARTUP REPLAY + uptime_ms=15438 + BOARD IDENTITY + chip_model=ESP32-S3 + chip_revision=0 + chip_cores=2 + sdk_version=v4.4.7-dirty + cpu_mhz=240 + flash_size=8388608 + flash_speed=80000000 + flash_mode=QIO + efuse_mac=68BF5B43CA48 + chip_id=5BBF68 + reset_reason=UNKNOWN + arduino_board=Espressif ESP32-S3-DevKitC-1-N8 (8 MB QD, No PSRAM) + sample=34 state=BEGIN_FAIL rail=ON vbus=5.18 batt=0.00 pins=1/0/1/0 probeH(ff=8 z=0 o=0 FF FF FF FF) probeF(ff=8 z=0 o=0 FF FF FF FF) mount=none@0 type=NONE size=0MB root=FAIL + [ 15618][E][sd_diskio.cpp:806] sdcard_mount(): f_mount failed: (13) There is no valid FAT volume + +from BOB: + + sample=30 state=MOUNT_OK rail=ON vbus=5.03 batt=3.86 pins=1/0/1/0 probeH(ff=8 z=0 o=0 FF FF FF FF) probeF(ff=8 z=0 o=0 FF FF FF FF) mount=HSPI@400000 type=SDHC size=14910MB root=OK + + STARTUP REPLAY + uptime_ms=13167 + BOARD IDENTITY + chip_model=ESP32-S3 + chip_revision=0 + chip_cores=2 + sdk_version=v4.4.7-dirty + cpu_mhz=240 + flash_size=8388608 + flash_speed=80000000 + flash_mode=QIO + efuse_mac=DC935A43CA48 + chip_id=5A93DC + reset_reason=UNKNOWN + arduino_board=Espressif ESP32-S3-DevKitC-1-N8 (8 MB QD, No PSRAM) + sample=31 state=MOUNT_OK rail=ON vbus=5.03 batt=3.86 pins=1/0/1/0 probeH(ff=8 z=0 o=0 FF FF FF FF) probeF(ff=8 z=0 o=0 FF FF FF FF) mount=HSPI@400000 type=SDHC size=14910MB root=OK diff --git a/tools/constantTFCard/hw_debug/platformio.ini b/tools/constantTFCard/hw_debug/platformio.ini new file mode 100644 index 0000000..de55c86 --- /dev/null +++ b/tools/constantTFCard/hw_debug/platformio.ini @@ -0,0 +1,31 @@ +; 20260401 Codex +; constantTFCard hardware debug monitor + +[platformio] +default_envs = amy + +[env] +platform = espressif32 +framework = arduino +board = esp32-s3-devkitc-1 +monitor_speed = 115200 +lib_deps = + lewisxhe/XPowersLib@0.3.3 + Wire + olikraus/U8g2@^2.36.4 + +build_flags = + -I ../../../shared/boards + -I ../../../external/microReticulum_Firmware + -D BOARD_MODEL=BOARD_TBEAM_S_V1 + -D OLED_SDA=17 + -D OLED_SCL=18 + -D OLED_ADDR=0x3C + -D ARDUINO_USB_MODE=1 + -D ARDUINO_USB_CDC_ON_BOOT=1 + +[env:amy] +extends = env +build_flags = + ${env.build_flags} + -D NODE_LABEL=\"AMY\" diff --git a/tools/constantTFCard/hw_debug/src/main.cpp b/tools/constantTFCard/hw_debug/src/main.cpp new file mode 100644 index 0000000..23a7e0e --- /dev/null +++ b/tools/constantTFCard/hw_debug/src/main.cpp @@ -0,0 +1,443 @@ +// 20260401 Codex + +#include +#include +#include +#include +#include +#include +#include + +#include "tbeam_supreme_adapter.h" + +#ifndef NODE_LABEL +#define NODE_LABEL "NODE" +#endif + +#ifndef OLED_SDA +#define OLED_SDA 17 +#endif + +#ifndef OLED_SCL +#define OLED_SCL 18 +#endif + +static const uint32_t kSerialDelayMs = 1500; +static const uint32_t kPollIntervalMs = 200; +static const uint32_t kStartupQuietMs = 5000; +static const uint32_t kStartupReplayWindowMs = 20000; +static const uint32_t kStartupReplayPeriodMs = 2000; + +static const uint32_t kFreqs[] = { + 400000, + 1000000, + 4000000, + 10000000 +}; + +struct PinSnapshot { + int cs = -1; + int sck = -1; + int miso = -1; + int mosi = -1; +}; + +struct ProbeSummary { + uint8_t ffCount = 0; + uint8_t zeroCount = 0; + uint8_t otherCount = 0; + uint8_t firstBytes[8] = {0}; +}; + +enum class DebugState : uint8_t { + PMU_FAIL = 0, + RAIL_OFF, + BUS_FLOAT, + BUS_LOW, + BUS_CHATTER, + SD_BEGIN_FAIL, + CARD_NONE, + FS_FAIL, + MOUNT_OK +}; + +struct DebugSnapshot { + DebugState state = DebugState::PMU_FAIL; + bool pmuOk = false; + bool railOn = false; + float vbusV = -1.0f; + float battV = -1.0f; + PinSnapshot pins{}; + ProbeSummary probeH{}; + ProbeSummary probeF{}; + const char* mountBus = "none"; + uint32_t mountHz = 0; + uint8_t cardType = CARD_NONE; + uint64_t cardSizeMB = 0; + bool rootOk = false; +}; + +static XPowersLibInterface* g_pmu = nullptr; +static U8G2_SH1106_128X64_NONAME_F_HW_I2C g_oled(U8G2_R0, U8X8_PIN_NONE); +static SPIClass g_spiH(HSPI); +static SPIClass g_spiF(FSPI); +static uint32_t g_sampleCount = 0; +static uint32_t g_bootMs = 0; +static uint32_t g_lastStartupReplayMs = 0; + +static const char* resetReasonToString(esp_reset_reason_t reason) { + switch (reason) { + case ESP_RST_UNKNOWN: return "UNKNOWN"; + case ESP_RST_POWERON: return "POWERON"; + case ESP_RST_EXT: return "EXT"; + case ESP_RST_SW: return "SW"; + case ESP_RST_PANIC: return "PANIC"; + case ESP_RST_INT_WDT: return "INT_WDT"; + case ESP_RST_TASK_WDT: return "TASK_WDT"; + case ESP_RST_WDT: return "WDT"; + case ESP_RST_DEEPSLEEP: return "DEEPSLEEP"; + case ESP_RST_BROWNOUT: return "BROWNOUT"; + case ESP_RST_SDIO: return "SDIO"; + default: return "OTHER"; + } +} + +static const char* flashModeToString(FlashMode_t mode) { + switch (mode) { + case FM_QIO: return "QIO"; + case FM_QOUT: return "QOUT"; + case FM_DIO: return "DIO"; + case FM_DOUT: return "DOUT"; + case FM_FAST_READ: return "FAST"; + case FM_SLOW_READ: return "SLOW"; + default: return "UNKNOWN"; + } +} + +static void printBoardIdentity() { + uint64_t mac = ESP.getEfuseMac(); + uint32_t chipId = 0; + for (int i = 0; i < 17; i += 8) { + chipId |= ((mac >> (40 - i)) & 0xFF) << i; + } + + Serial.println("BOARD IDENTITY"); + Serial.printf("chip_model=%s\r\n", ESP.getChipModel()); + Serial.printf("chip_revision=%u\r\n", (unsigned)ESP.getChipRevision()); + Serial.printf("chip_cores=%u\r\n", (unsigned)ESP.getChipCores()); + Serial.printf("sdk_version=%s\r\n", ESP.getSdkVersion()); + Serial.printf("cpu_mhz=%u\r\n", (unsigned)ESP.getCpuFreqMHz()); + Serial.printf("flash_size=%u\r\n", (unsigned)ESP.getFlashChipSize()); + Serial.printf("flash_speed=%u\r\n", (unsigned)ESP.getFlashChipSpeed()); + Serial.printf("flash_mode=%s\r\n", flashModeToString(ESP.getFlashChipMode())); + Serial.printf("efuse_mac=%012llX\r\n", mac); + Serial.printf("chip_id=%06lX\r\n", (unsigned long)chipId); + Serial.printf("reset_reason=%s\r\n", resetReasonToString(esp_reset_reason())); + Serial.printf("arduino_board=%s\r\n", ARDUINO_BOARD); +} + +static void printStartupBanner() { + Serial.println(); + Serial.println("STARTUP REPLAY"); + Serial.printf("uptime_ms=%lu\r\n", (unsigned long)(millis() - g_bootMs)); + printBoardIdentity(); +} + +static void forceSpiDeselected() { + pinMode(tbeam_supreme::sdCs(), OUTPUT); + digitalWrite(tbeam_supreme::sdCs(), HIGH); + pinMode(tbeam_supreme::imuCs(), OUTPUT); + digitalWrite(tbeam_supreme::imuCs(), HIGH); +} + +static PinSnapshot readPins() { + PinSnapshot s; + s.cs = gpio_get_level((gpio_num_t)tbeam_supreme::sdCs()); + s.sck = gpio_get_level((gpio_num_t)tbeam_supreme::sdSck()); + s.miso = gpio_get_level((gpio_num_t)tbeam_supreme::sdMiso()); + s.mosi = gpio_get_level((gpio_num_t)tbeam_supreme::sdMosi()); + return s; +} + +static void readPmu(DebugSnapshot& snap) { + snap.pmuOk = (g_pmu != nullptr); + if (!g_pmu) { + return; + } + + snap.railOn = g_pmu->isPowerChannelEnable(XPOWERS_BLDO1); + snap.vbusV = g_pmu->getVbusVoltage() / 1000.0f; + snap.battV = g_pmu->getBattVoltage() / 1000.0f; +} + +static ProbeSummary runIdleProbe(SPIClass& bus) { + ProbeSummary out; + + SD.end(); + bus.end(); + delay(2); + forceSpiDeselected(); + + bus.begin( + tbeam_supreme::sdSck(), + tbeam_supreme::sdMiso(), + tbeam_supreme::sdMosi(), + tbeam_supreme::sdCs() + ); + + digitalWrite(tbeam_supreme::sdCs(), HIGH); + delay(1); + for (int i = 0; i < 8; ++i) { + uint8_t b = bus.transfer(0xFF); + out.firstBytes[i] = b; + if (b == 0xFF) out.ffCount++; + else if (b == 0x00) out.zeroCount++; + else out.otherCount++; + } + + return out; +} + +static const char* cardTypeToString(uint8_t type) { + switch (type) { + case CARD_MMC: return "MMC"; + case CARD_SD: return "SDSC"; + case CARD_SDHC: return "SDHC"; + default: return "NONE"; + } +} + +static bool tryMount(SPIClass& bus, + const char* busName, + uint32_t hz, + DebugSnapshot& snap) { + SD.end(); + bus.end(); + delay(2); + forceSpiDeselected(); + + bus.begin( + tbeam_supreme::sdSck(), + tbeam_supreme::sdMiso(), + tbeam_supreme::sdMosi(), + tbeam_supreme::sdCs() + ); + + digitalWrite(tbeam_supreme::sdCs(), HIGH); + delay(1); + for (int i = 0; i < 10; ++i) { + bus.transfer(0xFF); + } + + if (!SD.begin(tbeam_supreme::sdCs(), bus, hz)) { + snap.state = DebugState::SD_BEGIN_FAIL; + return false; + } + + snap.cardType = SD.cardType(); + snap.mountBus = busName; + snap.mountHz = hz; + if (snap.cardType == CARD_NONE) { + SD.end(); + snap.state = DebugState::CARD_NONE; + return false; + } + + snap.cardSizeMB = SD.cardSize() / (1024ULL * 1024ULL); + + File root = SD.open("/", FILE_READ); + snap.rootOk = (bool)root; + if (root) { + root.close(); + } + SD.end(); + + snap.state = snap.rootOk ? DebugState::MOUNT_OK : DebugState::FS_FAIL; + return snap.rootOk; +} + +static DebugState classifyProbe(const ProbeSummary& probe) { + if (probe.ffCount == 8) return DebugState::BUS_FLOAT; + if (probe.zeroCount == 8) return DebugState::BUS_LOW; + return DebugState::BUS_CHATTER; +} + +static DebugSnapshot captureSnapshot() { + DebugSnapshot snap; + readPmu(snap); + snap.pins = readPins(); + + if (!snap.pmuOk) { + snap.state = DebugState::PMU_FAIL; + return snap; + } + + if (!snap.railOn) { + snap.state = DebugState::RAIL_OFF; + return snap; + } + + snap.probeH = runIdleProbe(g_spiH); + snap.probeF = runIdleProbe(g_spiF); + snap.state = classifyProbe(snap.probeH); + + for (size_t i = 0; i < (sizeof(kFreqs) / sizeof(kFreqs[0])); ++i) { + if (tryMount(g_spiH, "HSPI", kFreqs[i], snap)) { + return snap; + } + } + + for (size_t i = 0; i < (sizeof(kFreqs) / sizeof(kFreqs[0])); ++i) { + if (tryMount(g_spiF, "FSPI", kFreqs[i], snap)) { + return snap; + } + } + + return snap; +} + +static const char* stateToString(DebugState state) { + switch (state) { + case DebugState::PMU_FAIL: return "PMU_FAIL"; + case DebugState::RAIL_OFF: return "RAIL_OFF"; + case DebugState::BUS_FLOAT: return "NO_RESP"; + case DebugState::BUS_LOW: return "BUS_LOW"; + case DebugState::BUS_CHATTER: return "BUS_CHAT"; + case DebugState::SD_BEGIN_FAIL: return "BEGIN_FAIL"; + case DebugState::CARD_NONE: return "CARD_NONE"; + case DebugState::FS_FAIL: return "FS_FAIL"; + case DebugState::MOUNT_OK: return "MOUNT_OK"; + default: return "UNKNOWN"; + } +} + +static void printSnapshot(const DebugSnapshot& snap) { + Serial.printf( + "sample=%lu state=%s rail=%s vbus=%.2f batt=%.2f pins=%d/%d/%d/%d " + "probeH(ff=%u z=%u o=%u %02X %02X %02X %02X) " + "probeF(ff=%u z=%u o=%u %02X %02X %02X %02X) " + "mount=%s@%lu type=%s size=%lluMB root=%s\r\n", + (unsigned long)g_sampleCount, + stateToString(snap.state), + snap.railOn ? "ON" : "OFF", + snap.vbusV, + snap.battV, + snap.pins.cs, + snap.pins.sck, + snap.pins.miso, + snap.pins.mosi, + (unsigned)snap.probeH.ffCount, + (unsigned)snap.probeH.zeroCount, + (unsigned)snap.probeH.otherCount, + snap.probeH.firstBytes[0], + snap.probeH.firstBytes[1], + snap.probeH.firstBytes[2], + snap.probeH.firstBytes[3], + (unsigned)snap.probeF.ffCount, + (unsigned)snap.probeF.zeroCount, + (unsigned)snap.probeF.otherCount, + snap.probeF.firstBytes[0], + snap.probeF.firstBytes[1], + snap.probeF.firstBytes[2], + snap.probeF.firstBytes[3], + snap.mountBus, + (unsigned long)snap.mountHz, + cardTypeToString(snap.cardType), + snap.cardSizeMB, + snap.rootOk ? "OK" : "FAIL" + ); +} + +static void showSnapshot(const DebugSnapshot& snap) { + char line1[24]; + char line2[24]; + char line3[24]; + char line4[24]; + char line5[24]; + + snprintf(line1, sizeof(line1), "%s TF HWDBG", NODE_LABEL); + snprintf(line2, sizeof(line2), "STATE %s", stateToString(snap.state)); + snprintf(line3, sizeof(line3), "H %u/%u/%u F %u/%u/%u", + (unsigned)snap.probeH.ffCount, + (unsigned)snap.probeH.zeroCount, + (unsigned)snap.probeH.otherCount, + (unsigned)snap.probeF.ffCount, + (unsigned)snap.probeF.zeroCount, + (unsigned)snap.probeF.otherCount); + snprintf(line4, sizeof(line4), "%s %luk %s", + snap.mountBus, + (unsigned long)(snap.mountHz / 1000UL), + cardTypeToString(snap.cardType)); + snprintf(line5, sizeof(line5), "P %d%d%d%d R%s %lu", + snap.pins.cs, + snap.pins.sck, + snap.pins.miso, + snap.pins.mosi, + snap.railOn ? "1" : "0", + (unsigned long)g_sampleCount); + + g_oled.clearBuffer(); + g_oled.setFont(u8g2_font_5x8_tf); + g_oled.drawUTF8(0, 12, line1); + g_oled.drawUTF8(0, 24, line2); + g_oled.drawUTF8(0, 36, line3); + g_oled.drawUTF8(0, 48, line4); + g_oled.drawUTF8(0, 60, line5); + g_oled.sendBuffer(); +} + +void setup() { + Serial.begin(115200); + g_bootMs = millis(); + delay(kSerialDelayMs); + + Wire.begin(OLED_SDA, OLED_SCL); + g_oled.begin(); + g_oled.clearDisplay(); + + tbeam_supreme::initPmuForPeripherals(g_pmu, &Serial); + forceSpiDeselected(); + + Serial.println(); + Serial.println("constantTFCard hardware debug"); + Serial.printf("Node: %s\r\n", NODE_LABEL); + Serial.printf("Poll interval: %lu ms\r\n", (unsigned long)kPollIntervalMs); + Serial.println("States: PMU_FAIL RAIL_OFF NO_RESP BUS_LOW BUS_CHAT BEGIN_FAIL CARD_NONE FS_FAIL MOUNT_OK"); + Serial.printf("Startup quiet delay: %lu ms\r\n", (unsigned long)kStartupQuietMs); + Serial.printf("Startup replay window: %lu ms\r\n", (unsigned long)kStartupReplayWindowMs); + + g_oled.clearBuffer(); + g_oled.setFont(u8g2_font_5x8_tf); + g_oled.drawUTF8(0, 12, "TF HWDBG"); + g_oled.drawUTF8(0, 24, "startup hold"); + g_oled.drawUTF8(0, 36, "attach monitor"); + g_oled.drawUTF8(0, 48, "waiting..."); + g_oled.sendBuffer(); + + delay(kStartupQuietMs); + printStartupBanner(); +} + +void loop() { + static uint32_t lastPollMs = 0; + + uint32_t now = millis(); + if (now - lastPollMs < kPollIntervalMs) { + delay(5); + return; + } + + lastPollMs = now; + g_sampleCount++; + + DebugSnapshot snap = captureSnapshot(); + + if (now - g_bootMs <= kStartupReplayWindowMs && + now - g_lastStartupReplayMs >= kStartupReplayPeriodMs) { + g_lastStartupReplayMs = now; + printStartupBanner(); + } + + printSnapshot(snap); + showSnapshot(snap); +} diff --git a/tools/constantTFCard/platformio.ini b/tools/constantTFCard/platformio.ini new file mode 100644 index 0000000..705281e --- /dev/null +++ b/tools/constantTFCard/platformio.ini @@ -0,0 +1,31 @@ +; 20260401 Codex +; constantTFCard + +[platformio] +default_envs = amy + +[env] +platform = espressif32 +framework = arduino +board = esp32-s3-devkitc-1 +monitor_speed = 115200 +lib_deps = + lewisxhe/XPowersLib@0.3.3 + Wire + olikraus/U8g2@^2.36.4 + +build_flags = + -I ../../shared/boards + -I ../../external/microReticulum_Firmware + -D BOARD_MODEL=BOARD_TBEAM_S_V1 + -D OLED_SDA=17 + -D OLED_SCL=18 + -D OLED_ADDR=0x3C + -D ARDUINO_USB_MODE=1 + -D ARDUINO_USB_CDC_ON_BOOT=1 + +[env:amy] +extends = env +build_flags = + ${env.build_flags} + -D NODE_LABEL=\"AMY\" diff --git a/tools/constantTFCard/raw_probe/.gitignore b/tools/constantTFCard/raw_probe/.gitignore new file mode 100644 index 0000000..cfce1ad --- /dev/null +++ b/tools/constantTFCard/raw_probe/.gitignore @@ -0,0 +1,2 @@ +*.log + diff --git a/tools/constantTFCard/raw_probe/platformio.ini b/tools/constantTFCard/raw_probe/platformio.ini new file mode 100644 index 0000000..889b197 --- /dev/null +++ b/tools/constantTFCard/raw_probe/platformio.ini @@ -0,0 +1,55 @@ +; 20260401 Codex +; constantTFCard raw SPI/SD probe + +[platformio] +default_envs = amy + +[env] +platform = espressif32 +framework = arduino +board = esp32-s3-devkitc-1 +monitor_speed = 115200 +lib_deps = + lewisxhe/XPowersLib@0.3.3 + Wire + olikraus/U8g2@^2.36.4 + +build_flags = + -I ../../../shared/boards + -I ../../../external/microReticulum_Firmware + -D BOARD_MODEL=BOARD_TBEAM_S_V1 + -D OLED_SDA=17 + -D OLED_SCL=18 + -D OLED_ADDR=0x3C + -D ARDUINO_USB_MODE=1 + -D ARDUINO_USB_CDC_ON_BOOT=1 + +[env:amy] +extends = env +build_flags = + ${env.build_flags} + -D NODE_LABEL=\"AMY\" + +[env:bob] +extends = env +build_flags = + ${env.build_flags} + -D NODE_LABEL=\"BOB\" + +[env:cy] +extends = env +build_flags = + ${env.build_flags} + -D NODE_LABEL=\"CY\" + +[env:dan] +extends = env +build_flags = + ${env.build_flags} + -D NODE_LABEL=\"DAN\" + +[env:ed] +extends = env +build_flags = + ${env.build_flags} + -D NODE_LABEL=\"ED\" diff --git a/tools/constantTFCard/raw_probe/src/main.cpp b/tools/constantTFCard/raw_probe/src/main.cpp new file mode 100644 index 0000000..68196c4 --- /dev/null +++ b/tools/constantTFCard/raw_probe/src/main.cpp @@ -0,0 +1,463 @@ +// 20260401 Codex + +#include +#include +#include +#include +#include +#include + +#include "tbeam_supreme_adapter.h" + +#ifndef NODE_LABEL +#define NODE_LABEL "NODE" +#endif + +#ifndef OLED_SDA +#define OLED_SDA 17 +#endif + +#ifndef OLED_SCL +#define OLED_SCL 18 +#endif + +static const uint32_t kSerialDelayMs = 1500; +static const uint32_t kPollIntervalMs = 200; +static const uint32_t kSpiHz = 400000; +static const uint32_t kReadyHeartbeatMs = 2000; + +enum class RawState : uint8_t { + PMU_FAIL = 0, + RAIL_OFF, + BUS_FLOAT, + BUS_LOW, + CMD0_TIMEOUT, + CMD0_NOT_IDLE, + CMD8_TIMEOUT, + CMD8_BAD_R1, + ACMD41_TIMEOUT, + CMD58_TIMEOUT, + READY +}; + +struct PinSnapshot { + int cs = -1; + int sck = -1; + int miso = -1; + int mosi = -1; +}; + +struct ProbeSummary { + uint8_t ffCount = 0; + uint8_t zeroCount = 0; + uint8_t otherCount = 0; + uint8_t bytes[8] = {0}; +}; + +struct RawSnapshot { + RawState state = RawState::PMU_FAIL; + bool pmuOk = false; + bool railOn = false; + PinSnapshot pins{}; + ProbeSummary idle{}; + uint8_t cmd0 = 0xFF; + uint8_t cmd8r1 = 0xFF; + uint8_t cmd8data[4] = {0xFF, 0xFF, 0xFF, 0xFF}; + uint8_t acmd41 = 0xFF; + uint8_t cmd58r1 = 0xFF; + uint8_t ocr[4] = {0xFF, 0xFF, 0xFF, 0xFF}; +}; + +static XPowersLibInterface* g_pmu = nullptr; +static U8G2_SH1106_128X64_NONAME_F_HW_I2C g_oled(U8G2_R0, U8X8_PIN_NONE); +static SPIClass g_spi(HSPI); +static uint32_t g_sampleCount = 0; +static uint32_t g_markCount = 0; +static char g_inputLine[32] = {0}; +static uint8_t g_inputLen = 0; + +static void forceSpiDeselected() { + pinMode(tbeam_supreme::sdCs(), OUTPUT); + digitalWrite(tbeam_supreme::sdCs(), HIGH); + pinMode(tbeam_supreme::imuCs(), OUTPUT); + digitalWrite(tbeam_supreme::imuCs(), HIGH); +} + +static PinSnapshot readPins() { + PinSnapshot s; + s.cs = gpio_get_level((gpio_num_t)tbeam_supreme::sdCs()); + s.sck = gpio_get_level((gpio_num_t)tbeam_supreme::sdSck()); + s.miso = gpio_get_level((gpio_num_t)tbeam_supreme::sdMiso()); + s.mosi = gpio_get_level((gpio_num_t)tbeam_supreme::sdMosi()); + return s; +} + +static void beginBus() { + SD.end(); + g_spi.end(); + delay(2); + forceSpiDeselected(); + g_spi.begin( + tbeam_supreme::sdSck(), + tbeam_supreme::sdMiso(), + tbeam_supreme::sdMosi(), + tbeam_supreme::sdCs() + ); + digitalWrite(tbeam_supreme::sdCs(), HIGH); +} + +static ProbeSummary idleProbe() { + ProbeSummary out; + beginBus(); + delay(1); + for (int i = 0; i < 8; ++i) { + uint8_t b = g_spi.transfer(0xFF); + out.bytes[i] = b; + if (b == 0xFF) out.ffCount++; + else if (b == 0x00) out.zeroCount++; + else out.otherCount++; + } + return out; +} + +static uint8_t waitR1(uint16_t tries = 16) { + for (uint16_t i = 0; i < tries; ++i) { + uint8_t r = g_spi.transfer(0xFF); + if ((r & 0x80) == 0) { + return r; + } + } + return 0xFF; +} + +static uint8_t sendCommand(uint8_t cmd, uint32_t arg, uint8_t crc) { + g_spi.transfer(0xFF); + digitalWrite(tbeam_supreme::sdCs(), LOW); + g_spi.transfer(0x40 | cmd); + g_spi.transfer((arg >> 24) & 0xFF); + g_spi.transfer((arg >> 16) & 0xFF); + g_spi.transfer((arg >> 8) & 0xFF); + g_spi.transfer(arg & 0xFF); + g_spi.transfer(crc); + uint8_t r1 = waitR1(); + return r1; +} + +static void endCommand() { + digitalWrite(tbeam_supreme::sdCs(), HIGH); + g_spi.transfer(0xFF); +} + +static const char* stateToString(RawState state) { + switch (state) { + case RawState::PMU_FAIL: return "PMU_FAIL"; + case RawState::RAIL_OFF: return "RAIL_OFF"; + case RawState::BUS_FLOAT: return "BUS_FLOAT"; + case RawState::BUS_LOW: return "BUS_LOW"; + case RawState::CMD0_TIMEOUT: return "CMD0_TO"; + case RawState::CMD0_NOT_IDLE: return "CMD0_BAD"; + case RawState::CMD8_TIMEOUT: return "CMD8_TO"; + case RawState::CMD8_BAD_R1: return "CMD8_BAD"; + case RawState::ACMD41_TIMEOUT: return "ACMD41_TO"; + case RawState::CMD58_TIMEOUT: return "CMD58_TO"; + case RawState::READY: return "READY"; + default: return "UNKNOWN"; + } +} + +static RawSnapshot captureSnapshot() { + RawSnapshot snap; + snap.pins = readPins(); + snap.pmuOk = (g_pmu != nullptr); + if (!snap.pmuOk) { + snap.state = RawState::PMU_FAIL; + return snap; + } + + snap.railOn = g_pmu->isPowerChannelEnable(XPOWERS_BLDO1); + if (!snap.railOn) { + snap.state = RawState::RAIL_OFF; + return snap; + } + + snap.idle = idleProbe(); + if (snap.idle.ffCount == 8) { + snap.state = RawState::BUS_FLOAT; + } else if (snap.idle.zeroCount == 8) { + snap.state = RawState::BUS_LOW; + } + + beginBus(); + delay(1); + for (int i = 0; i < 10; ++i) { + g_spi.transfer(0xFF); + } + + snap.cmd0 = sendCommand(0, 0, 0x95); + endCommand(); + if (snap.cmd0 == 0xFF) { + snap.state = RawState::CMD0_TIMEOUT; + return snap; + } + if (snap.cmd0 != 0x01) { + snap.state = RawState::CMD0_NOT_IDLE; + return snap; + } + + snap.cmd8r1 = sendCommand(8, 0x000001AAUL, 0x87); + if (snap.cmd8r1 == 0xFF) { + endCommand(); + snap.state = RawState::CMD8_TIMEOUT; + return snap; + } + for (int i = 0; i < 4; ++i) { + snap.cmd8data[i] = g_spi.transfer(0xFF); + } + endCommand(); + if (!(snap.cmd8r1 == 0x01 || snap.cmd8r1 == 0x05)) { + snap.state = RawState::CMD8_BAD_R1; + return snap; + } + + uint8_t ready = 0xFF; + for (int attempt = 0; attempt < 12; ++attempt) { + uint8_t r1 = sendCommand(55, 0, 0x65); + endCommand(); + if (r1 == 0xFF) { + continue; + } + ready = sendCommand(41, 0x40000000UL, 0x77); + endCommand(); + if (ready == 0x00) { + break; + } + delay(10); + } + snap.acmd41 = ready; + if (snap.acmd41 != 0x00) { + snap.state = RawState::ACMD41_TIMEOUT; + return snap; + } + + snap.cmd58r1 = sendCommand(58, 0, 0xFD); + if (snap.cmd58r1 == 0xFF) { + endCommand(); + snap.state = RawState::CMD58_TIMEOUT; + return snap; + } + for (int i = 0; i < 4; ++i) { + snap.ocr[i] = g_spi.transfer(0xFF); + } + endCommand(); + + snap.state = RawState::READY; + return snap; +} + +static void printSnapshot(const RawSnapshot& snap) { + Serial.printf( + "sample=%lu state=%s rail=%s pins=%d/%d/%d/%d " + "idle(ff=%u z=%u o=%u %02X %02X %02X %02X) " + "cmd0=%02X cmd8=%02X [%02X %02X %02X %02X] " + "acmd41=%02X cmd58=%02X [%02X %02X %02X %02X]\r\n", + (unsigned long)g_sampleCount, + stateToString(snap.state), + snap.railOn ? "ON" : "OFF", + snap.pins.cs, + snap.pins.sck, + snap.pins.miso, + snap.pins.mosi, + (unsigned)snap.idle.ffCount, + (unsigned)snap.idle.zeroCount, + (unsigned)snap.idle.otherCount, + snap.idle.bytes[0], + snap.idle.bytes[1], + snap.idle.bytes[2], + snap.idle.bytes[3], + snap.cmd0, + snap.cmd8r1, + snap.cmd8data[0], + snap.cmd8data[1], + snap.cmd8data[2], + snap.cmd8data[3], + snap.acmd41, + snap.cmd58r1, + snap.ocr[0], + snap.ocr[1], + snap.ocr[2], + snap.ocr[3] + ); +} + +static void printReadyHeartbeat() { + Serial.printf("[%10lu] READY heartbeat\r\n", (unsigned long)millis()); +} + +static bool sameSnapshot(const RawSnapshot& a, const RawSnapshot& b) { + if (a.state != b.state) return false; + if (a.railOn != b.railOn) return false; + if (a.pins.cs != b.pins.cs || a.pins.sck != b.pins.sck || + a.pins.miso != b.pins.miso || a.pins.mosi != b.pins.mosi) return false; + if (a.idle.ffCount != b.idle.ffCount || + a.idle.zeroCount != b.idle.zeroCount || + a.idle.otherCount != b.idle.otherCount) return false; + if (a.cmd0 != b.cmd0 || a.cmd8r1 != b.cmd8r1 || + a.acmd41 != b.acmd41 || a.cmd58r1 != b.cmd58r1) return false; + + for (int i = 0; i < 4; ++i) { + if (a.cmd8data[i] != b.cmd8data[i]) return false; + if (a.ocr[i] != b.ocr[i]) return false; + } + + return true; +} + +static void showSnapshot(const RawSnapshot& snap) { + char l1[24]; + char l2[24]; + char l3[24]; + char l4[24]; + char l5[24]; + + snprintf(l1, sizeof(l1), "%s RAW SD", NODE_LABEL); + snprintf(l2, sizeof(l2), "STATE %s", stateToString(snap.state)); + snprintf(l3, sizeof(l3), "CMD0 %02X C8 %02X A41 %02X", snap.cmd0, snap.cmd8r1, snap.acmd41); + snprintf(l4, sizeof(l4), "OCR %02X%02X%02X%02X", + snap.ocr[0], snap.ocr[1], snap.ocr[2], snap.ocr[3]); + snprintf(l5, sizeof(l5), "IDL %u/%u/%u #%lu", + (unsigned)snap.idle.ffCount, + (unsigned)snap.idle.zeroCount, + (unsigned)snap.idle.otherCount, + (unsigned long)g_sampleCount); + + g_oled.clearBuffer(); + g_oled.setFont(u8g2_font_5x8_tf); + g_oled.drawUTF8(0, 12, l1); + g_oled.drawUTF8(0, 24, l2); + g_oled.drawUTF8(0, 36, l3); + g_oled.drawUTF8(0, 48, l4); + g_oled.drawUTF8(0, 60, l5); + g_oled.sendBuffer(); +} + +static void handleSerialInput() { + auto handleCommand = []() { + if (g_inputLen == 0) { + Serial.println(); + return; + } + + g_inputLine[g_inputLen] = '\0'; + if (strcmp(g_inputLine, "m") == 0 || strcmp(g_inputLine, "M") == 0) { + g_markCount++; + Serial.printf("----- MARK %lu -----\r\n", (unsigned long)g_markCount); + } else if (strcmp(g_inputLine, "ls") == 0 || strcmp(g_inputLine, "LS") == 0) { + Serial.println("----- LS / -----"); + beginBus(); + for (int i = 0; i < 10; ++i) { + g_spi.transfer(0xFF); + } + + if (!SD.begin(tbeam_supreme::sdCs(), g_spi, kSpiHz)) { + Serial.println("ls: SD.begin failed"); + g_inputLen = 0; + return; + } + + File root = SD.open("/", FILE_READ); + if (!root) { + Serial.println("ls: open / failed"); + SD.end(); + g_inputLen = 0; + return; + } + + File entry = root.openNextFile(); + if (!entry) { + Serial.println("ls: root empty or unreadable"); + } + + while (entry) { + Serial.printf("%s%s %lu\r\n", + entry.name(), + entry.isDirectory() ? "/" : "", + (unsigned long)entry.size()); + entry.close(); + entry = root.openNextFile(); + } + + root.close(); + SD.end(); + Serial.println("----- END LS -----"); + } else { + Serial.printf("unknown command: %s\r\n", g_inputLine); + } + + g_inputLen = 0; + }; + + while (Serial.available() > 0) { + int ch = Serial.read(); + if (ch == '\r' || ch == '\n') { + handleCommand(); + } else if (ch == 0x08 || ch == 0x7F) { + if (g_inputLen > 0) { + g_inputLen--; + } + } else if (g_inputLen + 1 < sizeof(g_inputLine)) { + g_inputLine[g_inputLen++] = (char)ch; + } + } +} + +void setup() { + Serial.begin(115200); + delay(kSerialDelayMs); + + Wire.begin(OLED_SDA, OLED_SCL); + g_oled.begin(); + g_oled.clearDisplay(); + + tbeam_supreme::initPmuForPeripherals(g_pmu, &Serial); + forceSpiDeselected(); + + Serial.println(); + Serial.println("constantTFCard raw probe"); + Serial.printf("Node: %s\r\n", NODE_LABEL); + Serial.printf("Poll interval: %lu ms\r\n", (unsigned long)kPollIntervalMs); + Serial.println("States: PMU_FAIL RAIL_OFF BUS_FLOAT BUS_LOW CMD0_TO CMD0_BAD CMD8_TO CMD8_BAD ACMD41_TO CMD58_TO READY"); + Serial.println("Input: Enter=blank line, m=mark, ls=list root"); +} + +void loop() { + static uint32_t lastPollMs = 0; + static bool haveLastPrinted = false; + static RawSnapshot lastPrinted{}; + static uint32_t lastReadyHeartbeatMs = 0; + + handleSerialInput(); + + uint32_t now = millis(); + if (now - lastPollMs < kPollIntervalMs) { + delay(5); + return; + } + + lastPollMs = now; + g_sampleCount++; + + RawSnapshot snap = captureSnapshot(); + bool changed = (!haveLastPrinted || !sameSnapshot(snap, lastPrinted)); + if (changed) { + printSnapshot(snap); + lastPrinted = snap; + haveLastPrinted = true; + if (snap.state == RawState::READY) { + lastReadyHeartbeatMs = now; + } + } else if (snap.state == RawState::READY && now - lastReadyHeartbeatMs >= kReadyHeartbeatMs) { + printReadyHeartbeat(); + lastReadyHeartbeatMs = now; + } + showSnapshot(snap); +} diff --git a/tools/constantTFCard/src/main.cpp b/tools/constantTFCard/src/main.cpp new file mode 100644 index 0000000..3b172d9 --- /dev/null +++ b/tools/constantTFCard/src/main.cpp @@ -0,0 +1,257 @@ +// 20260401 Codex + +#include +#include +#include +#include +#include +#include + +#include "tbeam_supreme_adapter.h" + +#ifndef NODE_LABEL +#define NODE_LABEL "NODE" +#endif + +#ifndef OLED_SDA +#define OLED_SDA 17 +#endif + +#ifndef OLED_SCL +#define OLED_SCL 18 +#endif + +static const uint32_t kSerialDelayMs = 1500; +static const uint32_t kPollIntervalMs = 200; +static const uint32_t kSdFreqHz = 400000; +static const uint32_t kBootSettleMs = 2000; +static const uint32_t kSdRailOffMs = 300; +static const uint32_t kSdRailOnSettleMs = 1200; +static const uint8_t kOutVotesBeforeRailCycle = 10; +static const uint32_t kMinRailCycleGapMs = 5000; + +static const uint32_t kDelayedRetryOffsetsMs[] = { + 1000, + 3000, + 7000, + 15000 +}; + +static XPowersLibInterface* g_pmu = nullptr; +static U8G2_SH1106_128X64_NONAME_F_HW_I2C g_oled(U8G2_R0, U8X8_PIN_NONE); +static SPIClass g_sdSpi(HSPI); +static uint32_t g_bootMs = 0; +static size_t g_nextDelayedRetry = 0; +static uint32_t g_lastRailCycleMs = 0; +static uint8_t g_consecutiveOut = 0; + +static void forceSpiDeselected() { + pinMode(tbeam_supreme::sdCs(), OUTPUT); + digitalWrite(tbeam_supreme::sdCs(), HIGH); + pinMode(tbeam_supreme::imuCs(), OUTPUT); + digitalWrite(tbeam_supreme::imuCs(), HIGH); +} + +static void oledShowStatus(const char* status, uint32_t sampleCount) { + char line1[24]; + char line2[24]; + char line3[24]; + + snprintf(line1, sizeof(line1), "%s TF CARD", NODE_LABEL); + snprintf(line2, sizeof(line2), "%s", status); + snprintf(line3, sizeof(line3), "sample %lu", (unsigned long)sampleCount); + + g_oled.clearBuffer(); + g_oled.setFont(u8g2_font_6x12_tf); + g_oled.drawUTF8(0, 14, line1); + g_oled.setFont(u8g2_font_logisoso20_tf); + g_oled.drawUTF8(0, 42, status); + g_oled.setFont(u8g2_font_6x12_tf); + g_oled.drawUTF8(0, 62, line3); + g_oled.sendBuffer(); +} + +static void oledShowBootPhase(const char* line2, const char* line3) { + char line1[24]; + + snprintf(line1, sizeof(line1), "%s TF CARD", NODE_LABEL); + + g_oled.clearBuffer(); + g_oled.setFont(u8g2_font_6x12_tf); + g_oled.drawUTF8(0, 14, line1); + if (line2) g_oled.drawUTF8(0, 32, line2); + if (line3) g_oled.drawUTF8(0, 50, line3); + g_oled.sendBuffer(); +} + +static bool cardReadable() { + SD.end(); + g_sdSpi.end(); + delay(2); + + forceSpiDeselected(); + g_sdSpi.begin( + tbeam_supreme::sdSck(), + tbeam_supreme::sdMiso(), + tbeam_supreme::sdMosi(), + tbeam_supreme::sdCs() + ); + + digitalWrite(tbeam_supreme::sdCs(), HIGH); + delay(1); + for (int i = 0; i < 10; ++i) { + g_sdSpi.transfer(0xFF); + } + + if (!SD.begin(tbeam_supreme::sdCs(), g_sdSpi, kSdFreqHz)) { + return false; + } + + if (SD.cardType() == CARD_NONE) { + SD.end(); + return false; + } + + File root = SD.open("/", FILE_READ); + bool ok = (bool)root; + if (root) { + root.close(); + } + + SD.end(); + return ok; +} + +static bool cycleSdRail() { + if (!g_pmu) { + Serial.println("rail cycle skipped: no PMU"); + return false; + } + + SD.end(); + g_sdSpi.end(); + forceSpiDeselected(); + + g_pmu->disablePowerOutput(XPOWERS_BLDO1); + delay(kSdRailOffMs); + g_pmu->setPowerChannelVoltage(XPOWERS_BLDO1, 3300); + g_pmu->enablePowerOutput(XPOWERS_BLDO1); + delay(kSdRailOnSettleMs); + forceSpiDeselected(); + + g_lastRailCycleMs = millis(); + Serial.printf("rail cycle: off=%lu on_settle=%lu\r\n", + (unsigned long)kSdRailOffMs, + (unsigned long)kSdRailOnSettleMs); + return true; +} + +static void runDelayedRetry(const char* label) { + bool readable = cardReadable(); + const char* status = readable ? "card IN" : "card OUT"; + Serial.printf("delayed retry %s -> %s\r\n", label, status); + oledShowStatus(status, 0); +} + +static void handleSerialCommands() { + while (Serial.available() > 0) { + int ch = Serial.read(); + if (ch == 'r' || ch == 'R') { + Serial.println("manual command: SD rail reset"); + oledShowBootPhase("manual SD reset", "re-probing"); + if (cycleSdRail()) { + bool readable = cardReadable(); + Serial.printf("manual SD reset -> %s\r\n", readable ? "card IN" : "card OUT"); + oledShowStatus(readable ? "card IN" : "card OUT", 0); + } + } else if (ch == 'b' || ch == 'B') { + Serial.println("manual command: full reboot"); + Serial.println("restarting now..."); + oledShowBootPhase("manual reboot", "restarting"); + delay(250); + ESP.restart(); + } else if (ch == '\r' || ch == '\n') { + continue; + } else { + Serial.printf("commands: r=sd reset, b=reboot (got '%c')\r\n", (char)ch); + } + } +} + +void setup() { + Serial.begin(115200); + delay(kSerialDelayMs); + + Wire.begin(OLED_SDA, OLED_SCL); + g_oled.begin(); + g_oled.clearDisplay(); + oledShowBootPhase("BOOT", "init"); + + tbeam_supreme::initPmuForPeripherals(g_pmu, &Serial); + forceSpiDeselected(); + g_bootMs = millis(); + + Serial.println(); + Serial.println("constantTFCard"); + Serial.printf("Node: %s\r\n", NODE_LABEL); + Serial.printf("Polling every %lu ms\r\n", (unsigned long)kPollIntervalMs); + Serial.printf("Initial settle delay %lu ms\r\n", (unsigned long)kBootSettleMs); + Serial.println("Commands: r=SD rail reset, b=full reboot"); + + oledShowBootPhase("BOOT", "settling SD rail"); + delay(kBootSettleMs); + + runDelayedRetry("after_settle"); +} + +void loop() { + static uint32_t lastPollMs = 0; + static uint32_t sampleCount = 0; + + handleSerialCommands(); + + uint32_t now = millis(); + + if (g_nextDelayedRetry < (sizeof(kDelayedRetryOffsetsMs) / sizeof(kDelayedRetryOffsetsMs[0])) && + now - g_bootMs >= kDelayedRetryOffsetsMs[g_nextDelayedRetry]) { + char label[20]; + snprintf(label, sizeof(label), "t+%lus", (unsigned long)(kDelayedRetryOffsetsMs[g_nextDelayedRetry] / 1000)); + runDelayedRetry(label); + g_nextDelayedRetry++; + } + + if (now - lastPollMs < kPollIntervalMs) { + delay(5); + return; + } + + lastPollMs = now; + sampleCount++; + + bool readable = cardReadable(); + if (readable) { + g_consecutiveOut = 0; + } else if (g_consecutiveOut < 255) { + g_consecutiveOut++; + } + + if (!readable && + g_consecutiveOut >= kOutVotesBeforeRailCycle && + now - g_lastRailCycleMs >= kMinRailCycleGapMs) { + Serial.printf("persistent OUT: %u polls, forcing SD rail cycle\r\n", + (unsigned)g_consecutiveOut); + oledShowBootPhase("OUT -> rail reset", "re-probing SD"); + + if (cycleSdRail()) { + bool retryReadable = cardReadable(); + readable = retryReadable; + g_consecutiveOut = retryReadable ? 0 : kOutVotesBeforeRailCycle; + Serial.printf("after rail cycle -> %s\r\n", retryReadable ? "card IN" : "card OUT"); + } + } + + const char* status = readable ? "card IN" : "card OUT"; + + Serial.println(status); + oledShowStatus(status, sampleCount); +} diff --git a/tools/keygen/CMakeLists.txt b/tools/keygen/CMakeLists.txt new file mode 100644 index 0000000..03b1797 --- /dev/null +++ b/tools/keygen/CMakeLists.txt @@ -0,0 +1,24 @@ +add_executable(rns-provision main.cpp) + +# If microReticulum exports an include directory / target, this may be unnecessary. +# Keep this here as a pragmatic fallback: +target_include_directories(rns-provision PRIVATE + ${CMAKE_SOURCE_DIR}/external/microReticulum/src +) + +# ArduinoJson is pulled by microReticulum headers; add it to this tool's include path. +target_include_directories(rns-provision PRIVATE + ${CMAKE_SOURCE_DIR}/external/microReticulum/src + + # ArduinoJson headers: + # - ArduinoJson.h lives at the repo root + # - ArduinoJson/... lives under src/ + ${CMAKE_BINARY_DIR}/_deps/arduinojson-src + ${CMAKE_BINARY_DIR}/_deps/arduinojson-src/src +) + +# Link against the microReticulum library target. +# If this target name is wrong in your submodule, change it here. +target_link_libraries(rns-provision PRIVATE ReticulumStatic) + +install(TARGETS rns-provision RUNTIME DESTINATION bin) diff --git a/tools/keygen/main.cpp b/tools/keygen/main.cpp new file mode 100644 index 0000000..18cc6d0 --- /dev/null +++ b/tools/keygen/main.cpp @@ -0,0 +1,376 @@ +// rns-provision - generate microReticulum identity keypairs + provisioning bundles +// +// Examples: +// ./rns-provision --quantity 6 --format tsv +// ./rns-provision -q 6 -f json --public +// ./rns-provision -q 6 --outdir provisioning/20260212_1030 --bundle both --public +// +// Notes: +// - Writes sensitive material (private keys). Protect your output directories. +// - identity.bin is raw private key bytes as returned by microReticulum Identity. +// +// $Header$ +// $Id$ + +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#if __has_include() + #include + namespace fs = std::filesystem; +#else + #error "This tool requires (C++17)." +#endif + +#ifndef _WIN32 + #include +#endif + +static constexpr const char* TOOL_NAME = "rns-provision"; +static constexpr const char* TOOL_VERSION = "0.1.0"; + +static void usage(const char* argv0, bool full = true) { + std::cerr << TOOL_NAME << " " << TOOL_VERSION << "\n\n"; + + std::cerr + << "Usage:\n" + << " " << argv0 << " --quantity N [options]\n\n"; + + if (!full) return; + + std::cerr + << "Options:\n" + << " -q, --quantity N Number of identities to generate (required)\n" + << " -f, --format FMT Stdout format: tsv (default) or json\n" + << " --public Include public_key in stdout and identity.json\n" + << " --outdir DIR Write provisioning bundle to DIR\n" + << " --bundle MODE none|json|bin|both (default: both if --outdir)\n" + << " --prefix NAME Device directory prefix (default: device)\n" + << " -h, --help Show this help message and exit\n" + << "\n" + << "Examples:\n" + << " " << argv0 << " -q 6\n" + << " " << argv0 << " -q 6 --outdir provisioning/20260212_1030 --bundle both\n" + << "\n"; +} + +static bool is_flag(const std::string& a, const char* s) { return a == s; } + +static std::string utc_now_iso8601() { + using namespace std::chrono; + auto now = system_clock::now(); + std::time_t t = system_clock::to_time_t(now); + std::tm tm{}; +#ifdef _WIN32 + gmtime_s(&tm, &t); +#else + gmtime_r(&t, &tm); +#endif + std::ostringstream oss; + oss << std::put_time(&tm, "%Y-%m-%dT%H:%M:%SZ"); + return oss.str(); +} + +static std::string json_escape(const std::string& s) { + std::ostringstream o; + for (unsigned char c : s) { + switch (c) { + case '\"': o << "\\\""; break; + case '\\': o << "\\\\"; break; + case '\b': o << "\\b"; break; + case '\f': o << "\\f"; break; + case '\n': o << "\\n"; break; + case '\r': o << "\\r"; break; + case '\t': o << "\\t"; break; + default: + if (c < 0x20) { + o << "\\u" << std::hex << std::setw(4) << std::setfill('0') << int(c); + } else { + o << c; + } + } + } + return o.str(); +} + +static std::string zpad_int(int value, int width) { + std::ostringstream oss; + oss << std::setw(width) << std::setfill('0') << value; + return oss.str(); +} + +static void ensure_dir(const fs::path& p) { + std::error_code ec; + if (fs::exists(p, ec)) { + if (!fs::is_directory(p, ec)) { + throw std::runtime_error("Path exists but is not a directory: " + p.string()); + } + return; + } + if (!fs::create_directories(p, ec)) { + throw std::runtime_error("Failed to create directory: " + p.string() + " (" + ec.message() + ")"); + } +} + +static void write_text_file(const fs::path& p, const std::string& data) { + std::ofstream out(p, std::ios::out | std::ios::trunc); + if (!out) throw std::runtime_error("Failed to open for write: " + p.string()); + out << data; + out.close(); + if (!out) throw std::runtime_error("Failed writing file: " + p.string()); + +#ifndef _WIN32 + // Best-effort: 0600 for sensitive text files (private keys) + ::chmod(p.c_str(), 0600); +#endif +} + +static void write_binary_file(const fs::path& p, const std::vector& bytes) { + std::ofstream out(p, std::ios::out | std::ios::binary | std::ios::trunc); + if (!out) throw std::runtime_error("Failed to open for write: " + p.string()); + out.write(reinterpret_cast(bytes.data()), static_cast(bytes.size())); + out.close(); + if (!out) throw std::runtime_error("Failed writing file: " + p.string()); + +#ifndef _WIN32 + ::chmod(p.c_str(), 0600); +#endif +} + +enum class BundleMode { NONE, JSON, BIN, BOTH }; + +static BundleMode parse_bundle_mode(const std::string& s) { + if (s == "none") return BundleMode::NONE; + if (s == "json") return BundleMode::JSON; + if (s == "bin") return BundleMode::BIN; + if (s == "both") return BundleMode::BOTH; + throw std::runtime_error("Invalid --bundle value (must be none|json|bin|both)"); +} + +// Convert microReticulum Bytes-ish type to std::vector. +// This assumes the returned object supports .size() and operator[]. +// If your microReticulum Bytes type differs, we’ll adjust here. +template +static std::vector to_u8vec(const BytesT& b) { + std::vector v; + v.reserve(static_cast(b.size())); + for (size_t i = 0; i < static_cast(b.size()); i++) { + v.push_back(static_cast(b[i])); + } + return v; +} + +int main(int argc, char** argv) { + try { + int quantity = -1; + std::string format = "tsv"; + bool include_public = false; + + bool do_outdir = false; + fs::path outdir; + std::string prefix = "device"; + BundleMode bundle_mode = BundleMode::NONE; + bool bundle_mode_explicit = false; + + for (int i = 1; i < argc; i++) { + std::string a(argv[i]); + + if (is_flag(a, "-h") || is_flag(a, "--help")) { + usage(argv[0], true); + return 0; + } else if (is_flag(a, "-q") || is_flag(a, "--quantity")) { + if (i + 1 >= argc) throw std::runtime_error("Missing value for --quantity"); + quantity = std::stoi(argv[++i]); + } else if (is_flag(a, "-f") || is_flag(a, "--format")) { + if (i + 1 >= argc) throw std::runtime_error("Missing value for --format"); + format = argv[++i]; + } else if (is_flag(a, "--public")) { + include_public = true; + } else if (is_flag(a, "--outdir")) { + if (i + 1 >= argc) throw std::runtime_error("Missing value for --outdir"); + outdir = fs::path(argv[++i]); + do_outdir = true; + } else if (is_flag(a, "--bundle")) { + if (i + 1 >= argc) throw std::runtime_error("Missing value for --bundle"); + bundle_mode = parse_bundle_mode(argv[++i]); + bundle_mode_explicit = true; + } else if (is_flag(a, "--prefix")) { + if (i + 1 >= argc) throw std::runtime_error("Missing value for --prefix"); + prefix = argv[++i]; + } else if (is_flag(a, "--version")) { + std::cout << TOOL_NAME << " " << TOOL_VERSION << "\n"; + return 0; + + } else { + throw std::runtime_error("Unknown argument: " + a); + } + } + + if (quantity <= 0) { + usage(argv[0], true); + return 2; + } + if (!(format == "tsv" || format == "json")) { + throw std::runtime_error("Invalid --format (must be tsv or json)"); + } + + // Default bundle behavior: if user set --outdir but not --bundle, write both. + if (do_outdir && !bundle_mode_explicit) { + bundle_mode = BundleMode::BOTH; + } + + if (do_outdir) { + ensure_dir(outdir); + } + + struct DeviceRec { + int n; + std::string device_name; + std::string id_hex; + std::string priv_hex; + std::string pub_hex; + std::vector priv_bin; + fs::path device_dir; + }; + + std::vector devices; + devices.reserve(static_cast(quantity)); + + for (int n = 1; n <= quantity; n++) { + RNS::Identity ident(true); + + DeviceRec rec; + rec.n = n; + rec.device_name = prefix + "_" + zpad_int(n, 3); + + // These methods matched your earlier build. If microReticulum changes, we adjust here. + rec.id_hex = ident.hash().toHex(); + rec.priv_hex = ident.get_private_key().toHex(); + if (include_public) rec.pub_hex = ident.get_public_key().toHex(); + + // Write binary blob for the private key + rec.priv_bin = to_u8vec(ident.get_private_key()); + + if (do_outdir) { + rec.device_dir = outdir / rec.device_name; + ensure_dir(rec.device_dir); + } + + devices.push_back(std::move(rec)); + } + + // ---- stdout ---- + if (format == "tsv") { + std::cout << "n\tdevice\tid_hex\tprivate_key_hex"; + if (include_public) std::cout << "\tpublic_key_hex"; + std::cout << "\n"; + for (const auto& d : devices) { + std::cout << d.n << "\t" << d.device_name << "\t" << d.id_hex << "\t" << d.priv_hex; + if (include_public) std::cout << "\t" << d.pub_hex; + std::cout << "\n"; + } + } else { + std::cout << "[\n"; + for (size_t i = 0; i < devices.size(); i++) { + const auto& d = devices[i]; + std::cout << " {\n"; + std::cout << " \"n\": " << d.n << ",\n"; + std::cout << " \"device\": \"" << json_escape(d.device_name) << "\",\n"; + std::cout << " \"id\": \"" << d.id_hex << "\",\n"; + std::cout << " \"private_key\": \"" << d.priv_hex << "\""; + if (include_public) { + std::cout << ",\n \"public_key\": \"" << d.pub_hex << "\"\n"; + } else { + std::cout << "\n"; + } + std::cout << " }" << (i + 1 == devices.size() ? "\n" : ",\n"); + } + std::cout << "]\n"; + } + + // ---- bundle output ---- + if (do_outdir && bundle_mode != BundleMode::NONE) { + const std::string created_utc = utc_now_iso8601(); + + // manifest.json + { + std::ostringstream m; + m << "{\n"; + m << " \"schema_version\": 1,\n"; + m << " \"created_utc\": \"" << created_utc << "\",\n"; + m << " \"quantity\": " << quantity << ",\n"; + m << " \"prefix\": \"" << json_escape(prefix) << "\",\n"; + m << " \"devices\": [\n"; + for (size_t i = 0; i < devices.size(); i++) { + const auto& d = devices[i]; + m << " {\n"; + m << " \"n\": " << d.n << ",\n"; + m << " \"device\": \"" << json_escape(d.device_name) << "\",\n"; + m << " \"id\": \"" << d.id_hex << "\""; + if (bundle_mode == BundleMode::JSON || bundle_mode == BundleMode::BOTH) { + m << ",\n \"identity_json\": \"" << json_escape((d.device_name + "/identity.json")) << "\""; + } + if (bundle_mode == BundleMode::BIN || bundle_mode == BundleMode::BOTH) { + m << ",\n \"identity_bin\": \"" << json_escape((d.device_name + "/identity.bin")) << "\""; + } + m << "\n }" << (i + 1 == devices.size() ? "\n" : ",\n"); + } + m << " ]\n"; + m << "}\n"; + write_text_file(outdir / "manifest.json", m.str()); + } + + for (const auto& d : devices) { + // label.txt + { + std::ostringstream l; + l << d.device_name << "\n"; + l << "id: " << d.id_hex << "\n"; + write_text_file(d.device_dir / "label.txt", l.str()); + } + + // identity.json + if (bundle_mode == BundleMode::JSON || bundle_mode == BundleMode::BOTH) { + std::ostringstream j; + j << "{\n"; + j << " \"schema_version\": 1,\n"; + j << " \"created_utc\": \"" << created_utc << "\",\n"; + j << " \"device\": \"" << json_escape(d.device_name) << "\",\n"; + j << " \"id\": \"" << d.id_hex << "\",\n"; + j << " \"private_key\": \"" << d.priv_hex << "\""; + if (include_public) { + j << ",\n \"public_key\": \"" << d.pub_hex << "\"\n"; + } else { + j << "\n"; + } + j << "}\n"; + write_text_file(d.device_dir / "identity.json", j.str()); + } + + // identity.bin + if (bundle_mode == BundleMode::BIN || bundle_mode == BundleMode::BOTH) { + write_binary_file(d.device_dir / "identity.bin", d.priv_bin); + } + } + + std::cerr << "Wrote provisioning bundle to: " << outdir.string() << "\n"; + } + + return 0; + } catch (const std::exception& e) { + std::cerr << "Error: " << e.what() << "\n"; + return 1; + } +} diff --git a/tools/livetrack/README.md b/tools/livetrack/README.md new file mode 100644 index 0000000..bd8785f --- /dev/null +++ b/tools/livetrack/README.md @@ -0,0 +1,17 @@ + +fwd_positions_udp.pl is run on the tbeam server monitoring the logger output. Example: + perl fwd_positions_udp.pl --file /usr/local/src/sx1302_hal/util_net_downlink/uplinks_20260219_123102.csv --host ryzdesk --port 1777 + +udp2ws.pl is run on ryzdesk to receiving incoming packets and ready them for consumption bye the HTML page + +The chain is: +1) the semtech 1303 listening captures transmissions and then sends them via UDP +2) the logger listens and logs the UDP to a csv +3) fwd_positions_udp.pl monitors the csv and then send via another UDP data to ryzdesk +4) udp2wr.pl runs on ryzdesk and receives the tbeam's data and makes it available to the HTML page's JavaScript + +TODO: tie into my own map server (has high zoom capability) + +Just writing this now to capture what happened last night as I may not get back to +this for a couple of weeks given the upcoming Voron project. + diff --git a/tools/livetrack/fieldtest_map.html b/tools/livetrack/fieldtest_map.html new file mode 100644 index 0000000..fbd48b6 --- /dev/null +++ b/tools/livetrack/fieldtest_map.html @@ -0,0 +1,157 @@ + + + + + Fieldtest Positions + + + + + + + + +
    + WS: connecting
    + Last: +
    +
    + + + + diff --git a/tools/livetrack/fwd_positions_udp.pl b/tools/livetrack/fwd_positions_udp.pl new file mode 100644 index 0000000..0f3a67b --- /dev/null +++ b/tools/livetrack/fwd_positions_udp.pl @@ -0,0 +1,55 @@ +#!/usr/bin/env perl +# 20260219 ChatGPT +# $Id$ +# $HeadURL$ + +# EXAMPLE: +# perl fwd_positions_udp.pl \ +# --file /usr/local/src/sx1302_hal/util_net_downlink/uplinks_20260219_123102.csv \ +# --host ryzdesk --port 1777 + +use strict; +use warnings; +use IO::Socket::INET; +use Getopt::Long qw(GetOptions); + +my ($file, $host, $port) = ("", "ryzdesk", 1777); +GetOptions( + "file=s" => \$file, + "host=s" => \$host, + "port=i" => \$port, +) or die "bad args\n"; + +die "--file required\n" if !$file; + +my $sock = IO::Socket::INET->new( + PeerAddr => $host, + PeerPort => $port, + Proto => "udp", +) or die "udp socket: $!\n"; + +£ Use tail -F so rotations keep working +open(my $fh, "-|", "tail", "-F", $file) or die "tail: $!\n"; + +$| = 1; + +while (my $line = <$fh>) { + chomp $line; + + £ Your CSV has the hex payload in field 16 (1-based) => index 15 (0-based) + my @f = split(/,/, $line, -1); + next if @f < 16; + + my $hex = $f[15] // ""; + $hex =~ s/[^0-9A-Fa-f]//g; + next if $hex eq ""; + + my $msg = pack("H*", $hex); £ yields: "C,44.936454,-123.021923,65.40" + $msg =~ s/\r?\n$//; + + £ sanity filter: must look like: X,lat,lon,alt + next unless $msg =~ /^[A-Z],[+-]?\d+\.\d+,[+-]?\d+\.\d+,[+-]?\d+(\.\d+)?$/; + + $sock->send($msg."\n"); + print "$msg\n"; £ local visibility +} diff --git a/tools/livetrack/udp2ws.pl b/tools/livetrack/udp2ws.pl new file mode 100644 index 0000000..f585254 --- /dev/null +++ b/tools/livetrack/udp2ws.pl @@ -0,0 +1,60 @@ +#!/usr/bin/env perl +# 20260219 ChatGPT +# $Id$ +# $HeadURL$ + +use strict; +use warnings; + +use Mojolicious::Lite -signatures; +use Mojo::IOLoop; +use IO::Socket::INET; + +my $udp_port = 1777; + +# Track websocket clients +my %clients; + +websocket '/stream' => sub ($c) { + my $id = sprintf("%x", rand(0xffffffff)); + $clients{$id} = $c->tx; + + $c->on(finish => sub ($c, $code, $reason) { + delete $clients{$id}; + }); +}; + +# UDP socket (non-blocking) +my $udp = IO::Socket::INET->new( + LocalPort => $udp_port, + Proto => 'udp', +) or die "udp bind $udp_port: $!\n"; + +$udp->blocking(0); + +# Register UDP fd with Mojo reactor +my $loop = Mojo::IOLoop->singleton; + +#Mojo::IOLoop->reactor->io($udp => sub ($reactor, $writable) { +$loop->reactor->io($udp => sub ($reactor, $writable) { + my $datagram = ''; + my $peer = $udp->recv($datagram, 2048); + return unless defined $peer && length $datagram; + + $datagram =~ s/\r?\n$//; + + for my $id (keys %clients) { + my $tx = $clients{$id}; + next unless $tx && !$tx->is_finished; + $tx->send($datagram); + } +}); + +#Mojo::IOLoop->reactor->watch($udp, 1, 0); +$loop->reactor->watch($udp, 1, 0); + +get '/' => sub ($c) { + $c->render(text => "udp2ws up (udp:$udp_port ws:/stream)\n"); +}; + +app->start;