From d38c646553ff38bf9f881c9723bdddfb116c2ffc Mon Sep 17 00:00:00 2001 From: John Poole Date: Sun, 24 May 2026 15:40:50 -0700 Subject: [PATCH 1/9] Untested, after completion of first round --- .../Codex_1_initial_prompt.md | 136 ++++ exercises/26_Bluetooth_discover/README.md | 74 ++ .../26_Bluetooth_discover/platformio.ini | 109 +++ exercises/26_Bluetooth_discover/src/main.cpp | 656 ++++++++++++++++++ 4 files changed, 975 insertions(+) create mode 100644 exercises/26_Bluetooth_discover/Codex_1_initial_prompt.md create mode 100644 exercises/26_Bluetooth_discover/README.md create mode 100644 exercises/26_Bluetooth_discover/platformio.ini create mode 100644 exercises/26_Bluetooth_discover/src/main.cpp diff --git a/exercises/26_Bluetooth_discover/Codex_1_initial_prompt.md b/exercises/26_Bluetooth_discover/Codex_1_initial_prompt.md new file mode 100644 index 0000000..afdb67c --- /dev/null +++ b/exercises/26_Bluetooth_discover/Codex_1_initial_prompt.md @@ -0,0 +1,136 @@ +# Prompt [Initial] +Create a new plain-Bluetooth field survey exercise for my LilyGO T-Beam SUPREME ESP32-S3 boards. + +Repository context: +- This is my existing microReticulumTbeam / T-Beam SUPREME PlatformIO project. +- Follow the style of the existing exercises. +- Do not modify working Reticulum BLE-interface exercises. +- This new exercise is intentionally NOT Reticulum. +- This exercise is only a BLE neighborhood sounder / RSSI gauge to help me place T-Beams before later Reticulum mesh testing. +- The target board is LilyGO T-Beam SUPREME, ESP32-S3FN8, SH1106 128x64 OLED, Bluetooth LE capable. +- Use the existing board support found under /usr/local/src/microreticulum/microreticulumTbeam/lig, OLED initialization, PMU setup, time discipline, GPS and utilities already present in the repo where possible. The unit should require a disciplined clock within 24 hours of the last time the clock was disciplined, and a the ability to obtain coordinates. All units, except AMY, can write to their SD card; AMY's card reader is defective. +- If useful, inspect nearby staged Meshtastic code, /usr/local/src/meshtastic, only for board/display initialization patterns, not for protocol design. + +Goal: +Build a firmware image that each T-Beam can run. Each unit continuously advertises a small BLE beacon identifying itself, while also scanning for other beacons from my known T-Beam units. My units are: AMY, BOB, CY, DAN, ED, FLO & GUY and identifications using those names should be used to distinguish them form other Bluetooth advertisements. The OLED should display only my units, with RSSI and age/last-seen information, so I can walk around my property and determine where a node can hear zero, one, or several other nodes. I have several neighbors who have Bluetooth advertisements including some other devices I control: all those should be ignored. + +Functional requirements: +1. Each device shall have a compile-time NODE_NAME, e.g. AMY, BOB, CY, DAN, ED, FLO, GUY. +2. Each device shall advertise using BLE advertising only. No pairing requirement. No phone app requirement. No GATT connection requirement unless absolutely necessary; I do not think GATT connections will be necessary for this discovery only exercise. +3. The advertised payload must include a project-specific identifier so scanners can ignore neighbors' Bluetooth devices. +4. The advertised payload must include: + - magic/project prefix, e.g. "TBMSND" + - protocol version, e.g. 1 + - NODE_NAME + - optional monotonically increasing sequence number + - optional short uptime counter +5. The scanner shall filter advertisements strictly: + - accept only advertisements containing the project prefix "TBMSND" + - accept only NODE_NAME values from a fixed allow-list: AMY, BOB, CY, DAN, ED, FLO, GUY + - ignore everything else +6. For each known unit heard, maintain: + - node name + - last RSSI + - rolling average RSSI, preferably over the last 5 samples + - number of advertisements seen + - seconds since last seen + - optional sequence number if present +7. OLED display: + - top line: this unit name and mode, e.g. "AMY BLE SOUNDER" + - subsequent lines: heard nodes sorted by strongest rolling RSSI first + - each line compact, e.g. "BOB -71 avg-68 2s" + - show "none heard" when no allowed unit has been seen recently + - show stale entries differently or drop them after a configurable timeout, e.g. 20 seconds +8. Serial output: + - emit timestamp-ish millis, this NODE_NAME, seen NODE_NAME, RSSI, avg RSSI, age, count, sequence + - make the output easy to tee into logs + - include a boot banner with compile-time settings + - serial output will only be used during validation of the builds, since the units will be mobile for testing, serial output then is irrelevant +9. Button behavior: + - BOOT button may cycle display modes if easy: + a. strongest nodes + b. all known nodes with heard/not-heard status + c. diagnostic raw count + - Do not make button handling complicated in the first version. +10. Keep LoRa disabled in this exercise. +11. We are not using microReticulum in this explorer exercise. +12. Avoid heap-heavy behavior. This should run indefinitely. +13. Use NimBLE-Arduino if already available or easy to add; otherwise use the ESP32 Arduino BLE library. Prefer NimBLE if it reduces memory use and supports concurrent advertising/scanning cleanly. +14. The ESP32-S3 is BLE-only; do not use Classic Bluetooth APIs. +15. Document any known limitations, especially: + - RSSI is not a precise distance measurement. + - body position and antenna orientation matter. + - BLE advertisements are not guaranteed delivery. + - scan/advertise duty cycle choices affect detection latency and power. +16. Provide build and upload commands at the top of the source file as comments, matching my usual style. +17. Include a comment header near the top with: + - today's date + - ChatGPT/Codex generated + - Subversion keywords: $Id$ and $HeadURL$ +18. Add logging on the SD card using YYYYMMDD_HHMISS_[UNIT NAME]_ble_seach.log. Logging should include date + time, both epoch high precision and regular human readable (not high precision), GPS coordinates of current receiving unit, the advertisement received, the RSSI strength of the received transmission. +19. Include web interface such as in Exercise 18, or later -- I cannot remember for sure, that allows downloading of the log files and deletion of the log files. We had a system where each unit had a unique IP for it, use that. + +Implementation details: +- Use directory: exercises/26_Bluetooth_discover. +- Add a README.md explaining the test procedure. +- Add source code for the exercise. +- You can copy the platformio.ini into the exercises/26_Bluetooth_discover folder from either exercise 18... or 306... +- Provide a clear way to compile different node images by passing NODE_NAME at build time, for just as we did on exercises 306 and 18. +- If C/C++ quoting through PlatformIO is awkward, implement a simple node_config.h file with NODE_NAME and document that I should change it per build. + +Suggested field procedure to document: +1. Flash all units with this sounder image, each with a unique NODE_NAME. +2. Place one unit at the intended origin, prefereable center to all other which will be in a start topology +3. Carry another unit and watch the OLED. +4. Find a position where it hears only the intended neighbor with weak/moderate RSSI. +5. Place it there. +6. Repeat outward to create a chain. +7. Record the topology manually before switching to the Reticulum BLE-interface binary. + +Acceptance test: +- With AMY and BOB on the bench, AMY displays BOB with RSSI and age, and BOB displays AMY. +- With a third random Bluetooth device nearby, it does not appear on the OLED or in accepted serial records. +- If BOB is powered off, AMY shows BOB aging out after the stale timeout. +- Serial output can be captured with: + pio device monitor -b 115200 -p /dev/ttytAMY | tee logs/ble_sounder_AMY_YYYYMMDD_HHMMSS.log + +Please implement the smallest reliable version first. Do not add Reticulum, LoRa logging, encryption, pairing, or routing in this exercise. + +Before you write, please identify anything area you need direction or confliction resolution on and await my instruction and confirmation to proceed. I want to iron out any areas so expectations coincide with your understanding. + +# Response: +I found a few decision points before writing `exercises/26_Bluetooth_discover`: + +1. **BLE library** + Existing BLE exercises use the ESP32 Arduino BLE library, not NimBLE. I recommend using the existing ESP32 BLE stack for the first version to avoid adding a new dependency. + +2. **Clock/GPS requirement** + The prompt says the unit should require a disciplined clock within 24 hours and coordinates. For field usefulness, I recommend: + - BLE advertise/scan/OLED always runs. + - SD logging starts only when RTC/GPS position are valid enough. + - OLED/serial clearly show `NO GPS/NO CLOCK/NO SD` status. + +3. **AMY SD card** + Since AMY’s SD reader is defective, I recommend keeping the same firmware for AMY but treating missing SD as non-fatal. AMY still sounders and shows OLED; it just does not write logs. + +4. **Web interface** + WiFi AP plus BLE scanning/advertising can affect BLE timing and power. I recommend implementing the shared `TBeamWeb` log download/delete interface now, but making logging/web opportunistic: if SD mounts, web starts; if not, BLE still works. + +5. **Payload format** + I recommend advertising a compact manufacturer-data string: + `TBMSND|1|AMY|seq|uptime` + and filtering only that prefix plus the fixed allow-list. + +6. **Build setup** + I recommend using per-node PlatformIO environments like exercise 18: `amy`, `bob`, `cy`, `dan`, `ed`, `flo`, `guy`, with `BOARD_ID`/`NODE_NAME` and `LOG_AP_IP_OCTET`. + +Confirm those choices and I’ll implement the smallest reliable version in `exercises/26_Bluetooth_discover` with `platformio.ini`, `src/main.cpp`, and `README.md`, then build at least one environment. + +# Further Instructions after 1st reply: +1. BLE Library: okay. +2. No. I want Clock/GPS to be working so I do not waste time thinking I'm gather userful data when they are not. It forces me to be assiduous in getting everything coordinated at the start. +3. AMY SD Card. Your proposal is fine. +4. Web Interface: Yes, especially since AMY cannot offer logs. +5. Payload format: Looks good. +6. Build setup: Yes, all settings in a single platformio.ini file, though. +Please proceed unless the above creates a gating factor. diff --git a/exercises/26_Bluetooth_discover/README.md b/exercises/26_Bluetooth_discover/README.md new file mode 100644 index 0000000..4705be7 --- /dev/null +++ b/exercises/26_Bluetooth_discover/README.md @@ -0,0 +1,74 @@ +# Exercise 26: Bluetooth Discover + +Plain BLE neighborhood sounder for LilyGO T-Beam SUPREME ESP32-S3 units. This exercise is not Reticulum and does not use LoRa. + +Each unit waits for GPS UTC, GPS coordinates, PPS-backed RTC discipline, and then starts BLE advertising/scanning. AMY may run without SD logging because its card reader is defective; other units should mount SD and write logs. + +## Build + +```sh +cd /usr/local/src/microreticulum/microReticulumTbeam/exercises/26_Bluetooth_discover +pio run -e amy +pio run -e bob +pio run -e cy +pio run -e dan +pio run -e ed +pio run -e flo +pio run -e guy +``` + +Upload example: + +```sh +pio run -e bob -t upload +pio device monitor -b 115200 -p /dev/ttytBOB +``` + +## Behavior + +- Advertises manufacturer data in this format: `TBMSND|1|NODE|seq|uptime`. +- Accepts only advertisements with prefix `TBMSND`, version `1`, and node name in `AMY, BOB, CY, DAN, ED, FLO, GUY`. +- Displays heard nodes sorted by rolling RSSI average, strongest first. +- Drops stale entries after 20 seconds. +- Logs accepted advertisements to SD as `/logs/YYYYMMDD_HHMISS_NODE_ble_seach.log`. +- Starts a WiFi AP for log access when SD is available. + +Default web addresses: + +- AMY: `http://192.168.23.1/` +- BOB: `http://192.168.24.1/` +- CY: `http://192.168.25.1/` +- DAN: `http://192.168.26.1/` +- ED: `http://192.168.27.1/` +- FLO: `http://192.168.28.1/` +- GUY: `http://192.168.29.1/` + +## Field Procedure + +1. Flash all units with this sounder image, each with its own environment. +2. Start all units where they can see sky and wait until each passes GPS/RTC startup discipline. +3. Place one unit near the intended origin, preferably central to a star topology. +4. Carry another unit and watch the OLED. +5. Find a position where it hears only the intended neighbor with weak or moderate RSSI. +6. Place it there. +7. Repeat outward to create a chain. +8. Record the topology manually before switching to the Reticulum BLE-interface firmware. + +## Limitations + +- RSSI is not a precise distance measurement. +- Body position, antenna orientation, terrain, buildings, and foliage matter. +- BLE advertisements are not guaranteed delivery. +- Scan and advertise duty-cycle choices affect detection latency and power draw. +- WiFi log download can affect BLE timing; use it for retrieving logs, not while making fine placement decisions. + +## Bench Acceptance Test + +- With AMY and BOB on the bench, AMY displays BOB with RSSI and age, and BOB displays AMY. +- A third unrelated Bluetooth device nearby does not appear on OLED or accepted serial records. +- If BOB is powered off, AMY ages BOB out after the stale timeout. +- Serial output can be captured with: + +```sh +pio device monitor -b 115200 -p /dev/ttytAMY | tee logs/ble_sounder_AMY_YYYYMMDD_HHMMSS.log +``` diff --git a/exercises/26_Bluetooth_discover/platformio.ini b/exercises/26_Bluetooth_discover/platformio.ini new file mode 100644 index 0000000..4cba5c1 --- /dev/null +++ b/exercises/26_Bluetooth_discover/platformio.ini @@ -0,0 +1,109 @@ +; Exercise 26: plain Bluetooth discovery sounder for LilyGO T-Beam SUPREME + +[platformio] +default_envs = cy + +[env] +platform = espressif32 +framework = arduino +board = esp32-s3-devkitc-1 +board_build.partitions = default_8MB.csv +monitor_speed = 115200 +upload_speed = 460800 +lib_extra_dirs = + ../../lib + ../18_GPS_Field_QA/lib +lib_deps = + Wire + olikraus/U8g2@^2.36.4 + lewisxhe/XPowersLib@0.3.3 + +build_flags = + -Wall + -Wno-missing-field-initializers + -Wno-format + -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_NAME=\"AMY\" + -D NODE_SLOT_INDEX=0 + -D LOG_AP_IP_OCTET=23 + -D GNSS_CHIP_NAME=\"L76K\" + +[env:bob] +extends = env +build_flags = + ${env.build_flags} + -D BOARD_ID=\"BOB\" + -D NODE_NAME=\"BOB\" + -D NODE_SLOT_INDEX=1 + -D LOG_AP_IP_OCTET=24 + -D GNSS_CHIP_NAME=\"L76K\" + +[env:cy] +extends = env +build_flags = + ${env.build_flags} + -D BOARD_ID=\"CY\" + -D NODE_NAME=\"CY\" + -D NODE_SLOT_INDEX=2 + -D LOG_AP_IP_OCTET=25 + -D GNSS_CHIP_NAME=\"L76K\" + +[env:dan] +extends = env +build_flags = + ${env.build_flags} + -D BOARD_ID=\"DAN\" + -D NODE_NAME=\"DAN\" + -D NODE_SLOT_INDEX=3 + -D LOG_AP_IP_OCTET=26 + -D GNSS_CHIP_NAME=\"L76K\" + +[env:ed] +extends = env +build_flags = + ${env.build_flags} + -D BOARD_ID=\"ED\" + -D NODE_NAME=\"ED\" + -D NODE_SLOT_INDEX=4 + -D LOG_AP_IP_OCTET=27 + -D GNSS_CHIP_NAME=\"L76K\" + +[env:flo] +extends = env +build_flags = + ${env.build_flags} + -D BOARD_ID=\"FLO\" + -D NODE_NAME=\"FLO\" + -D NODE_SLOT_INDEX=5 + -D LOG_AP_IP_OCTET=28 + -D GNSS_CHIP_NAME=\"L76K\" + +[env:guy] +extends = env +build_flags = + ${env.build_flags} + -D BOARD_ID=\"GUY\" + -D NODE_NAME=\"GUY\" + -D NODE_SLOT_INDEX=6 + -D LOG_AP_IP_OCTET=29 + -D GNSS_CHIP_NAME=\"MAX-M10S\" + -D GPS_UBLOX diff --git a/exercises/26_Bluetooth_discover/src/main.cpp b/exercises/26_Bluetooth_discover/src/main.cpp new file mode 100644 index 0000000..29507c2 --- /dev/null +++ b/exercises/26_Bluetooth_discover/src/main.cpp @@ -0,0 +1,656 @@ +// 2026-05-24 ChatGPT/Codex generated +// Exercise 26: Bluetooth Discover +// $Id$ +// $HeadURL$ +// +// Build: +// cd /usr/local/src/microreticulum/microReticulumTbeam/exercises/26_Bluetooth_discover +// pio run -e amy +// pio run -e bob +// pio run -e cy +// pio run -e dan +// pio run -e ed +// pio run -e flo +// pio run -e guy +// +// Upload/monitor example: +// pio run -e bob -t upload +// pio device monitor -b 115200 -p /dev/ttytBOB + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "ClockDiscipline.h" +#include "GnssManager.h" +#include "GnssTypes.h" +#include "tbeam_supreme_adapter.h" + +#ifndef BOARD_ID +#define BOARD_ID "CY" +#endif + +#ifndef NODE_NAME +#define NODE_NAME BOARD_ID +#endif + +#ifndef LOG_AP_IP_OCTET +#define LOG_AP_IP_OCTET 25 +#endif + +#ifndef BUTTON_PIN +#define BUTTON_PIN 0 +#endif + +namespace { + +using field_qa::ClockDateTime; +using field_qa::ClockDiscipline; +using field_qa::GnssSample; + +static constexpr const char* kProjectPrefix = "TBMSND"; +static constexpr uint8_t kProtocolVersion = 1; +static constexpr uint32_t kStaleMs = 20000; +static constexpr uint32_t kDisplayPeriodMs = 1000; +static constexpr uint32_t kAdvertisePeriodMs = 2000; +static constexpr uint32_t kScanPeriodMs = 2500; +static constexpr uint32_t kScanWindowSeconds = 2; +static constexpr uint32_t kLogFlushPeriodMs = 5000; +static constexpr uint32_t kStartupStatusPeriodMs = 1000; +static constexpr uint8_t kRssiWindow = 5; + +struct NodeState { + explicit NodeState(const char* nodeName) : name(nodeName) {} + + const char* name = ""; + bool self = false; + bool heard = false; + int lastRssi = 0; + int samples[kRssiWindow] = {}; + uint8_t sampleCount = 0; + uint8_t nextSample = 0; + uint32_t seenCount = 0; + uint32_t lastSeenMs = 0; + uint32_t lastSeq = 0; +}; + +XPowersLibInterface* g_pmu = nullptr; +tbeam::TBeamDisplay g_display; +tbeam::TBeamStorage g_storage(Serial); +tbeam::TBeamWeb g_web(Serial); +field_qa::GnssManager g_gnss; +field_qa::ClockDiscipline g_clock(Wire1); +BLEScan* g_scan = nullptr; + +NodeState g_nodes[] = { + NodeState("AMY"), + NodeState("BOB"), + NodeState("CY"), + NodeState("DAN"), + NodeState("ED"), + NodeState("FLO"), + NodeState("GUY"), +}; + +bool g_disciplined = false; +bool g_bleStarted = false; +bool g_storageReady = false; +bool g_webReady = false; +bool g_buttonWasPressed = false; +uint8_t g_displayMode = 0; +uint32_t g_sequence = 0; +volatile uint32_t g_ppsEdgeCount = 0; +uint32_t g_lastAdvertiseMs = 0; +uint32_t g_lastScanMs = 0; +uint32_t g_lastDisplayMs = 0; +uint32_t g_lastFlushMs = 0; +int64_t g_epochBase = 0; +uint32_t g_epochBaseMs = 0; +double g_latitude = 0.0; +double g_longitude = 0.0; +char g_logPath[128] = {}; + +void IRAM_ATTR onPpsEdge() { + ++g_ppsEdgeCount; +} + +int nodeIndexFor(const char* name) { + if (!name) { + return -1; + } + for (size_t i = 0; i < sizeof(g_nodes) / sizeof(g_nodes[0]); ++i) { + if (strcmp(g_nodes[i].name, name) == 0) { + return (int)i; + } + } + return -1; +} + +bool isSelfName(const char* name) { + return name && strcmp(name, NODE_NAME) == 0; +} + +int averageRssi(const NodeState& node) { + if (node.sampleCount == 0) { + return 0; + } + int total = 0; + for (uint8_t i = 0; i < node.sampleCount; ++i) { + total += node.samples[i]; + } + return (int)lroundf((float)total / (float)node.sampleCount); +} + +uint32_t ageSeconds(const NodeState& node, uint32_t now) { + if (!node.heard) { + return 9999; + } + return (now - node.lastSeenMs) / 1000UL; +} + +bool freshEnough(const NodeState& node, uint32_t now) { + return node.heard && (uint32_t)(now - node.lastSeenMs) <= kStaleMs; +} + +int64_t currentEpoch() { + if (!g_disciplined || g_epochBase <= 0) { + return 0; + } + return g_epochBase + (int64_t)((millis() - g_epochBaseMs) / 1000UL); +} + +void formatDateTime(int64_t epoch, char* out, size_t outSize) { + ClockDateTime dt{}; + if (epoch <= 0 || !ClockDiscipline::fromEpochSeconds(epoch, dt)) { + snprintf(out, outSize, "UNKNOWN"); + return; + } + 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 formatCompactDateTime(int64_t epoch, char* out, size_t outSize) { + ClockDateTime dt{}; + if (epoch <= 0 || !ClockDiscipline::fromEpochSeconds(epoch, dt)) { + snprintf(out, outSize, "00000000_000000"); + return; + } + 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 showLines(const char* l1, + const char* l2 = nullptr, + const char* l3 = nullptr, + const char* l4 = nullptr, + const char* l5 = nullptr, + const char* l6 = nullptr) { + g_display.setFont(tbeam::DisplayFont::SMALL); + g_display.showLines(l1, l2, l3, l4, l5, l6); +} + +bool waitForPps(void*, uint32_t timeoutMs) { + const uint32_t startEdges = g_ppsEdgeCount; + const uint32_t startMs = millis(); + while ((uint32_t)(millis() - startMs) < timeoutMs) { + g_gnss.poll(); + if (g_ppsEdgeCount != startEdges) { + return true; + } + delay(2); + } + return false; +} + +void printBootBanner() { + Serial.println(); + Serial.println("=================================================="); + Serial.println("Exercise 26: Bluetooth Discover"); + Serial.println("=================================================="); + Serial.printf("node=%s\n", NODE_NAME); + Serial.printf("prefix=%s version=%u\n", kProjectPrefix, (unsigned)kProtocolVersion); + Serial.printf("known_nodes=AMY,BOB,CY,DAN,ED,FLO,GUY\n"); + Serial.printf("stale_timeout_ms=%lu\n", (unsigned long)kStaleMs); + Serial.printf("scan_window_s=%lu scan_period_ms=%lu\n", + (unsigned long)kScanWindowSeconds, + (unsigned long)kScanPeriodMs); + Serial.printf("advertise_period_ms=%lu\n", (unsigned long)kAdvertisePeriodMs); + Serial.printf("web_url=http://192.168.%u.1/\n", (unsigned)LOG_AP_IP_OCTET); +} + +void markSelfNode() { + for (size_t i = 0; i < sizeof(g_nodes) / sizeof(g_nodes[0]); ++i) { + g_nodes[i].self = isSelfName(g_nodes[i].name); + } +} + +bool disciplineStartupClock() { + uint32_t lastStatusMs = 0; + uint32_t attemptCount = 0; + while (true) { + g_gnss.poll(); + const GnssSample sample = g_gnss.makeSample(); + const bool ready = sample.validTime && sample.validLocation && sample.validFix; + + const uint32_t now = millis(); + if ((uint32_t)(now - lastStatusMs) >= kStartupStatusPeriodMs) { + lastStatusMs = now; + char l2[32]; + char l3[32]; + snprintf(l2, sizeof(l2), "gps time:%c loc:%c fix:%c", sample.validTime ? 'Y' : 'N', sample.validLocation ? 'Y' : 'N', sample.validFix ? 'Y' : 'N'); + snprintf(l3, sizeof(l3), "sats:%d hdop:%.1f", sample.satsUsed, sample.hdop); + showLines(NODE_NAME " BLE SOUNDER", "WAIT GPS/RTC", l2, l3, "BLE disabled"); + Serial.printf("startup_gate node=%s gps_time=%u gps_loc=%u gps_fix=%u sats=%d hdop=%.1f attempts=%lu\n", + NODE_NAME, + sample.validTime ? 1U : 0U, + sample.validLocation ? 1U : 0U, + sample.validFix ? 1U : 0U, + sample.satsUsed, + sample.hdop, + (unsigned long)attemptCount); + } + + if (ready) { + ClockDateTime disciplined{}; + bool hadPriorRtc = false; + int64_t driftSeconds = 0; + ++attemptCount; + if (g_clock.disciplineFromGnss(sample, waitForPps, nullptr, disciplined, hadPriorRtc, driftSeconds)) { + g_epochBase = ClockDiscipline::toEpochSeconds(disciplined); + g_epochBaseMs = millis(); + g_latitude = sample.latitude; + g_longitude = sample.longitude; + char iso[32]; + ClockDiscipline::formatIsoUtc(disciplined, iso, sizeof(iso)); + Serial.printf("clock_disciplined node=%s utc=%s prior_rtc=%u drift_s=%lld lat=%.7f lon=%.7f\n", + NODE_NAME, + iso, + hadPriorRtc ? 1U : 0U, + (long long)driftSeconds, + g_latitude, + g_longitude); + return true; + } + Serial.println("clock discipline attempt failed; waiting for next valid GPS sample"); + } + delay(20); + } +} + +bool openDatedLog() { + if (!g_storage.ready()) { + return false; + } + char stamp[24]; + formatCompactDateTime(currentEpoch(), stamp, sizeof(stamp)); + snprintf(g_logPath, sizeof(g_logPath), "/logs/%s_%s_ble_seach.log", stamp, NODE_NAME); + if (!g_storage.ensureDirRecursive("/logs")) { + Serial.printf("sd_log_dir_failed err=%s\n", g_storage.lastError()); + return false; + } + if (!g_storage.openLog(g_logPath)) { + Serial.printf("sd_log_open_failed path=%s err=%s\n", g_logPath, g_storage.lastError()); + return false; + } + g_storage.println("human_time,epoch_ms,receiver,lat,lon,heard,rssi,avg_rssi,age_s,count,seq,payload"); + g_storage.flush(); + Serial.printf("sd_log_open path=%s\n", g_logPath); + return true; +} + +void startStorageAndWeb() { + tbeam::StorageConfig storageConfig; + storageConfig.logDir = "/logs"; + storageConfig.startupWarmupMs = 2500; + storageConfig.recoveryRailOffMs = 400; + storageConfig.recoveryRailOnSettleMs = 1200; + + g_storageReady = g_storage.begin(storageConfig); + if (!g_storageReady) { + Serial.printf("sd_unavailable node=%s err=%s\n", NODE_NAME, g_storage.lastError()); + if (!isSelfName("AMY")) { + Serial.println("WARNING: SD unavailable; BLE will run, but non-AMY units should be fixed before field logging"); + } + return; + } + + g_storageReady = openDatedLog(); + tbeam::WebConfig webConfig; + webConfig.ssidPrefix = "TBMSND"; + webConfig.boardId = NODE_NAME; + webConfig.password = nullptr; + webConfig.ipOctet = LOG_AP_IP_OCTET; + webConfig.enableDelete = true; + g_webReady = g_web.begin(g_storage, webConfig); + Serial.printf("web_ready=%u ssid=%s url=http://%s/\n", + g_webReady ? 1U : 0U, + g_web.ssid(), + g_web.ip().toString().c_str()); +} + +void updateAdvertisement() { + char payload[30]; + snprintf(payload, + sizeof(payload), + "%s|%u|%s|%04lu|%04lu", + kProjectPrefix, + (unsigned)kProtocolVersion, + NODE_NAME, + (unsigned long)(g_sequence % 10000UL), + (unsigned long)((millis() / 1000UL) % 10000UL)); + + BLEAdvertising* advertising = BLEDevice::getAdvertising(); + BLEAdvertisementData data; + data.setFlags(0x06); + data.setManufacturerData(std::string(payload)); + advertising->stop(); + advertising->setAdvertisementData(data); + advertising->setScanResponse(false); + advertising->setMinPreferred(0x00); + BLEDevice::startAdvertising(); + ++g_sequence; + g_lastAdvertiseMs = millis(); +} + +bool parsePayload(const std::string& data, char* outName, size_t outNameSize, uint32_t& outSeq, char* outPayload, size_t outPayloadSize) { + if (outNameSize == 0 || outPayloadSize == 0) { + return false; + } + outName[0] = '\0'; + outPayload[0] = '\0'; + if (data.empty() || data.size() >= outPayloadSize) { + return false; + } + memcpy(outPayload, data.data(), data.size()); + outPayload[data.size()] = '\0'; + + char work[48]; + strlcpy(work, outPayload, sizeof(work)); + char* save = nullptr; + const char* prefix = strtok_r(work, "|", &save); + const char* version = strtok_r(nullptr, "|", &save); + const char* name = strtok_r(nullptr, "|", &save); + const char* seq = strtok_r(nullptr, "|", &save); + if (!prefix || !version || !name || !seq) { + return false; + } + if (strcmp(prefix, kProjectPrefix) != 0 || atoi(version) != kProtocolVersion) { + return false; + } + if (nodeIndexFor(name) < 0 || isSelfName(name)) { + return false; + } + strlcpy(outName, name, outNameSize); + outSeq = (uint32_t)strtoul(seq, nullptr, 10); + return true; +} + +void logAcceptedAdvertisement(const NodeState& node, const char* payload) { + const int avg = averageRssi(node); + const uint32_t age = ageSeconds(node, millis()); + const int64_t epoch = currentEpoch(); + char human[32]; + formatDateTime(epoch, human, sizeof(human)); + const uint32_t epochMsPart = millis() % 1000UL; + const long long epochMs = (long long)(epoch * 1000LL + epochMsPart); + + Serial.printf("%lu,%s,%s,%d,%d,%lu,%lu,%lu,%s\n", + (unsigned long)millis(), + NODE_NAME, + node.name, + node.lastRssi, + avg, + (unsigned long)age, + (unsigned long)node.seenCount, + (unsigned long)node.lastSeq, + payload); + + if (g_storageReady && g_storage.isLogOpen()) { + char line[224]; + snprintf(line, + sizeof(line), + "%s,%lld,%s,%.7f,%.7f,%s,%d,%d,%lu,%lu,%lu,%s", + human, + epochMs, + NODE_NAME, + g_latitude, + g_longitude, + node.name, + node.lastRssi, + avg, + (unsigned long)age, + (unsigned long)node.seenCount, + (unsigned long)node.lastSeq, + payload); + g_storage.println(line); + } +} + +void acceptAdvertisement(const char* name, int rssi, uint32_t seq, const char* payload) { + const int idx = nodeIndexFor(name); + if (idx < 0) { + return; + } + NodeState& node = g_nodes[idx]; + node.heard = true; + node.lastRssi = rssi; + node.samples[node.nextSample] = rssi; + node.nextSample = (node.nextSample + 1U) % kRssiWindow; + if (node.sampleCount < kRssiWindow) { + ++node.sampleCount; + } + ++node.seenCount; + node.lastSeenMs = millis(); + node.lastSeq = seq; + logAcceptedAdvertisement(node, payload); +} + +class SounderAdvertisedCallbacks : public BLEAdvertisedDeviceCallbacks { + public: + void onResult(BLEAdvertisedDevice advertisedDevice) override { + if (!advertisedDevice.haveManufacturerData()) { + return; + } + char name[8]; + char payload[48]; + uint32_t seq = 0; + if (!parsePayload(advertisedDevice.getManufacturerData(), name, sizeof(name), seq, payload, sizeof(payload))) { + return; + } + acceptAdvertisement(name, advertisedDevice.getRSSI(), seq, payload); + } +}; + +void startBle() { + BLEDevice::init(std::string("TBMSND-") + NODE_NAME); + updateAdvertisement(); + g_scan = BLEDevice::getScan(); + g_scan->setAdvertisedDeviceCallbacks(new SounderAdvertisedCallbacks(), true); + g_scan->setInterval(1349); + g_scan->setWindow(449); + g_scan->setActiveScan(false); + g_bleStarted = true; + Serial.println("BLE started: advertising and scanning"); +} + +void pollBle() { + const uint32_t now = millis(); + if ((uint32_t)(now - g_lastAdvertiseMs) >= kAdvertisePeriodMs) { + updateAdvertisement(); + } + if (g_scan && (uint32_t)(now - g_lastScanMs) >= kScanPeriodMs) { + g_lastScanMs = now; + g_scan->start(kScanWindowSeconds, false); + g_scan->clearResults(); + updateAdvertisement(); + } +} + +void pollButton() { + const bool pressed = digitalRead(BUTTON_PIN) == LOW; + if (pressed && !g_buttonWasPressed) { + g_displayMode = (g_displayMode + 1U) % 3U; + g_lastDisplayMs = 0; + } + g_buttonWasPressed = pressed; +} + +void renderDisplay() { + const uint32_t now = millis(); + if ((uint32_t)(now - g_lastDisplayMs) < kDisplayPeriodMs) { + return; + } + g_lastDisplayMs = now; + + char title[32]; + snprintf(title, sizeof(title), "%s BLE SOUNDER", NODE_NAME); + + if (g_displayMode == 1) { + char rows[5][32] = {}; + uint8_t row = 0; + for (size_t i = 0; i < sizeof(g_nodes) / sizeof(g_nodes[0]) && row < 5; ++i) { + if (g_nodes[i].self) { + continue; + } + if (freshEnough(g_nodes[i], now)) { + snprintf(rows[row], sizeof(rows[row]), "%s %d avg%d %lus", g_nodes[i].name, g_nodes[i].lastRssi, averageRssi(g_nodes[i]), (unsigned long)ageSeconds(g_nodes[i], now)); + } else { + snprintf(rows[row], sizeof(rows[row]), "%s not-heard", g_nodes[i].name); + } + ++row; + } + showLines(title, rows[0], rows[1], rows[2], rows[3], rows[4]); + return; + } + + if (g_displayMode == 2) { + uint32_t heardTotal = 0; + uint32_t freshTotal = 0; + for (const NodeState& node : g_nodes) { + if (node.self) { + continue; + } + heardTotal += node.seenCount; + if (freshEnough(node, now)) { + ++freshTotal; + } + } + char l2[32]; + char l3[32]; + char l4[32]; + snprintf(l2, sizeof(l2), "fresh:%lu total:%lu", (unsigned long)freshTotal, (unsigned long)heardTotal); + snprintf(l3, sizeof(l3), "sd:%s web:%s", g_storageReady ? "Y" : "N", g_webReady ? "Y" : "N"); + snprintf(l4, sizeof(l4), "log:%s", g_logPath[0] ? g_logPath + 6 : "none"); + showLines(title, "DIAG", l2, l3, l4); + return; + } + + int order[7]; + uint8_t count = 0; + for (size_t i = 0; i < sizeof(g_nodes) / sizeof(g_nodes[0]); ++i) { + if (!g_nodes[i].self && freshEnough(g_nodes[i], now)) { + order[count++] = (int)i; + } + } + std::sort(order, order + count, [](int a, int b) { + return averageRssi(g_nodes[a]) > averageRssi(g_nodes[b]); + }); + + char rows[5][32] = {}; + for (uint8_t row = 0; row < count && row < 5; ++row) { + const NodeState& node = g_nodes[order[row]]; + snprintf(rows[row], + sizeof(rows[row]), + "%s %d avg%d %lus", + node.name, + node.lastRssi, + averageRssi(node), + (unsigned long)ageSeconds(node, now)); + } + if (count == 0) { + snprintf(rows[0], sizeof(rows[0]), "none heard"); + } + showLines(title, rows[0], rows[1], rows[2], rows[3], rows[4]); +} + +void pollStorageWeb() { + g_storage.update(); + if (g_storage.consumeRemovedEvent()) { + g_storageReady = false; + g_logPath[0] = '\0'; + Serial.println("sd_removed logging_disabled"); + } + if (g_storage.consumeMountedEvent() && !g_storageReady) { + g_storageReady = openDatedLog(); + } + if (g_webReady) { + g_web.update(); + } + if (g_storageReady && (uint32_t)(millis() - g_lastFlushMs) >= kLogFlushPeriodMs) { + g_lastFlushMs = millis(); + g_storage.flush(); + } +} + +} // namespace + +void setup() { + Serial.begin(115200); + delay(3000); + printBootBanner(); + markSelfNode(); + pinMode(BUTTON_PIN, INPUT_PULLUP); + + if (!tbeam_supreme::initPmuForPeripherals(g_pmu, &Serial)) { + Serial.println("WARNING: PMU init failed"); + } + g_display.begin(); + showLines(NODE_NAME " BLE SOUNDER", "booting", "PMU/OLED/GPS"); + g_gnss.begin(); + (void)g_gnss.probeAtStartup(Serial); +#ifdef GPS_1PPS_PIN + attachInterrupt(digitalPinToInterrupt(GPS_1PPS_PIN), onPpsEdge, RISING); +#endif + + g_disciplined = disciplineStartupClock(); + startStorageAndWeb(); + startBle(); +} + +void loop() { + g_gnss.poll(); + pollButton(); + pollStorageWeb(); + if (g_bleStarted) { + pollBle(); + } + renderDisplay(); +} From b407554210084159a541480fb187c17e897e0c67 Mon Sep 17 00:00:00 2001 From: John Poole Date: Mon, 25 May 2026 11:59:09 -0700 Subject: [PATCH 2/9] Prior to GPS code changes --- exercises/26_Bluetooth_discover/README.md | 116 ++++++++++++++++-- .../26_Bluetooth_discover/platformio.ini | 2 +- exercises/26_Bluetooth_discover/src/main.cpp | 104 +++++++++++----- lib/tbeam_web/src/TBeamWeb.cpp | 58 ++++++++- lib/tbeam_web/src/TBeamWeb.h | 1 + 5 files changed, 232 insertions(+), 49 deletions(-) diff --git a/exercises/26_Bluetooth_discover/README.md b/exercises/26_Bluetooth_discover/README.md index 4705be7..013811d 100644 --- a/exercises/26_Bluetooth_discover/README.md +++ b/exercises/26_Bluetooth_discover/README.md @@ -1,8 +1,8 @@ -# Exercise 26: Bluetooth Discover +# Exercise 26: BLE Discovery -Plain BLE neighborhood sounder for LilyGO T-Beam SUPREME ESP32-S3 units. This exercise is not Reticulum and does not use LoRa. +Plain BLE neighborhood discovery for LilyGO T-Beam SUPREME ESP32-S3 units. This exercise is not Reticulum and does not use LoRa. -Each unit waits for GPS UTC, GPS coordinates, PPS-backed RTC discipline, and then starts BLE advertising/scanning. AMY may run without SD logging because its card reader is defective; other units should mount SD and write logs. +Each unit displays `Take me outside` at startup, waits for GPS UTC, GPS coordinates, PPS-backed RTC discipline, and then starts BLE advertising/scanning. AMY may run without SD logging because its card reader is defective; other units should mount SD and write logs. ## Build @@ -20,7 +20,7 @@ pio run -e guy Upload example: ```sh -pio run -e bob -t upload +pio run -e bob -t upload --upload-port /dev/ttytBOB pio device monitor -b 115200 -p /dev/ttytBOB ``` @@ -35,17 +35,19 @@ pio device monitor -b 115200 -p /dev/ttytBOB Default web addresses: -- AMY: `http://192.168.23.1/` -- BOB: `http://192.168.24.1/` -- CY: `http://192.168.25.1/` -- DAN: `http://192.168.26.1/` -- ED: `http://192.168.27.1/` -- FLO: `http://192.168.28.1/` -- GUY: `http://192.168.29.1/` +- AMY: SSID `TBEAM-AMY`, `http://192.168.23.1/` +- BOB: SSID `TBEAM-BOB`, `http://192.168.24.1/` +- CY: SSID `TBEAM-CY`, `http://192.168.25.1/` +- DAN: SSID `TBEAM-DAN`, `http://192.168.26.1/` +- ED: SSID `TBEAM-ED`, `http://192.168.27.1/` +- FLO: SSID `TBEAM-FLO`, `http://192.168.28.1/` +- GUY: SSID `TBEAM-GUY`, `http://192.168.29.1/` + +The T-Beam hosts the WiFi access point and web page itself. No service needs to run on the workstation. To use the page, connect the workstation WiFi interface to the unit SSID, for example `TBEAM-ED`, then browse to that unit address, for example `http://192.168.27.1/`. A Panda USB WiFi adapter is useful only as the workstation WiFi interface used to join the T-Beam AP. The root page is a lightweight status page; click `Files` when you want the SD directory listing. ## Field Procedure -1. Flash all units with this sounder image, each with its own environment. +1. Flash all units with this BLE Discovery image, each with its own environment. 2. Start all units where they can see sky and wait until each passes GPS/RTC startup discipline. 3. Place one unit near the intended origin, preferably central to a star topology. 4. Carry another unit and watch the OLED. @@ -70,5 +72,93 @@ Default web addresses: - Serial output can be captured with: ```sh -pio device monitor -b 115200 -p /dev/ttytAMY | tee logs/ble_sounder_AMY_YYYYMMDD_HHMMSS.log +pio device monitor -b 115200 -p /dev/ttytAMY | tee logs/ble_discovery_AMY_YYYYMMDD_HHMMSS.log ``` +## Logs +Here are two logs from ED and FLO which were activated in the field. +```bash +jlpoole@jp ~/work/tbeam/logs $ ls -lath ed/*_16* +-rw-r--r-- 1 jlpoole jlpoole 1.1M May 25 09:53 ed/20260525_162217_ED_ble_seach.log +jlpoole@jp ~/work/tbeam/logs $ ls -lath flo/*_16* +-rw-r--r-- 1 jlpoole jlpoole 1.1M May 25 09:53 flo/20260525_162213_FLO_ble_seach.log +jlpoole@jp ~/work/tbeam/logs $ +``` + +Here are start and end samples from both: +```bash +jlpoole@jp ~/work/tbeam/logs $ cat -n ed/20260525_162217_ED_ble_seach.log|head -n 3 + 1 human_time,epoch_ms,receiver,lat,lon,heard,rssi,avg_rssi,age_s,count,seq,payload + 2 2026-05-25 16:22:18,1779726138897,ED,44.9364577,-123.0218702,FLO,-56,-56,0,1,1,TBMSND|1|FLO|0001|0775 + 3 2026-05-25 16:22:18,1779726138938,ED,44.9364577,-123.0218702,FLO,-51,-54,0,2,1,TBMSND|1|FLO|0001|0775 +jlpoole@jp ~/work/tbeam/logs $ cat -n ed/20260525_162217_ED_ble_seach.log|tail -n 1 + 10277 2026-05-25 16:47:42,1779727662217,ED,44.9364577,-123.0218702,FLO,-42,-42,0,10276,611,TBMSND|1|FLO|0611|2300 +jlpoole@jp ~/work/tbeam/logs $ cat -n flo/20260525_162213_FLO_ble_seach.log|head -n 3 + 1 human_time,epoch_ms,receiver,lat,lon,heard,rssi,avg_rssi,age_s,count,seq,payload + 2 2026-05-25 16:22:16,1779726136737,FLO,44.9365132,-123.0218183,ED,-52,-52,0,1,0,TBMSND|1|ED|0000|0805 + 3 2026-05-25 16:22:16,1779726136829,FLO,44.9365132,-123.0218183,ED,-51,-52,0,2,0,TBMSND|1|ED|0000|0805 +jlpoole@jp ~/work/tbeam/logs $ cat -n flo/20260525_162213_FLO_ble_seach.log|tail -n 1 + 10121 2026-05-25 16:47:37,1779727657219,FLO,44.9365132,-123.0218183,ED,-41,-40,0,10120,608,TBMSND|1|ED|0608|2325 +jlpoole@jp ~/work/tbeam/logs $ +``` +The header represents: + +| Column | Header | Explanation | +| ---: | --- | --- | +| 1 | `human_time` | Receiver timestamp in human-readable UTC form based on Greenwich Mean Time ("G= kStartupStatusPeriodMs) { lastStatusMs = now; - char l2[32]; - char l3[32]; - snprintf(l2, sizeof(l2), "gps time:%c loc:%c fix:%c", sample.validTime ? 'Y' : 'N', sample.validLocation ? 'Y' : 'N', sample.validFix ? 'Y' : 'N'); - snprintf(l3, sizeof(l3), "sats:%d hdop:%.1f", sample.satsUsed, sample.hdop); - showLines(NODE_NAME " BLE SOUNDER", "WAIT GPS/RTC", l2, l3, "BLE disabled"); + showGpsGateScreen(sample, attemptCount); Serial.printf("startup_gate node=%s gps_time=%u gps_loc=%u gps_fix=%u sats=%d hdop=%.1f attempts=%lu\n", NODE_NAME, sample.validTime ? 1U : 0U, @@ -301,12 +327,13 @@ bool disciplineStartupClock() { } Serial.println("clock discipline attempt failed; waiting for next valid GPS sample"); } + pollStorageWeb(); delay(20); } } bool openDatedLog() { - if (!g_storage.ready()) { + if (!g_disciplined || !g_storage.ready()) { return false; } char stamp[24]; @@ -323,6 +350,7 @@ bool openDatedLog() { g_storage.println("human_time,epoch_ms,receiver,lat,lon,heard,rssi,avg_rssi,age_s,count,seq,payload"); g_storage.flush(); Serial.printf("sd_log_open path=%s\n", g_logPath); + g_logReady = true; return true; } @@ -333,18 +361,16 @@ void startStorageAndWeb() { storageConfig.recoveryRailOffMs = 400; storageConfig.recoveryRailOnSettleMs = 1200; - g_storageReady = g_storage.begin(storageConfig); - if (!g_storageReady) { + g_sdReady = g_storage.begin(storageConfig); + if (!g_sdReady) { Serial.printf("sd_unavailable node=%s err=%s\n", NODE_NAME, g_storage.lastError()); if (!isSelfName("AMY")) { Serial.println("WARNING: SD unavailable; BLE will run, but non-AMY units should be fixed before field logging"); } - return; } - g_storageReady = openDatedLog(); tbeam::WebConfig webConfig; - webConfig.ssidPrefix = "TBMSND"; + webConfig.ssidPrefix = "TBEAM"; webConfig.boardId = NODE_NAME; webConfig.password = nullptr; webConfig.ipOctet = LOG_AP_IP_OCTET; @@ -354,6 +380,10 @@ void startStorageAndWeb() { g_webReady ? 1U : 0U, g_web.ssid(), g_web.ip().toString().c_str()); + + if (g_disciplined && g_sdReady) { + g_logReady = openDatedLog(); + } } void updateAdvertisement() { @@ -433,7 +463,7 @@ void logAcceptedAdvertisement(const NodeState& node, const char* payload) { (unsigned long)node.lastSeq, payload); - if (g_storageReady && g_storage.isLogOpen()) { + if (g_logReady && g_storage.isLogOpen()) { char line[224]; snprintf(line, sizeof(line), @@ -473,7 +503,7 @@ void acceptAdvertisement(const char* name, int rssi, uint32_t seq, const char* p logAcceptedAdvertisement(node, payload); } -class SounderAdvertisedCallbacks : public BLEAdvertisedDeviceCallbacks { +class DiscoveryAdvertisedCallbacks : public BLEAdvertisedDeviceCallbacks { public: void onResult(BLEAdvertisedDevice advertisedDevice) override { if (!advertisedDevice.haveManufacturerData()) { @@ -493,7 +523,7 @@ void startBle() { BLEDevice::init(std::string("TBMSND-") + NODE_NAME); updateAdvertisement(); g_scan = BLEDevice::getScan(); - g_scan->setAdvertisedDeviceCallbacks(new SounderAdvertisedCallbacks(), true); + g_scan->setAdvertisedDeviceCallbacks(new DiscoveryAdvertisedCallbacks(), true); g_scan->setInterval(1349); g_scan->setWindow(449); g_scan->setActiveScan(false); @@ -531,7 +561,7 @@ void renderDisplay() { g_lastDisplayMs = now; char title[32]; - snprintf(title, sizeof(title), "%s BLE SOUNDER", NODE_NAME); + makeTitle(title, sizeof(title)); if (g_displayMode == 1) { char rows[5][32] = {}; @@ -567,7 +597,7 @@ void renderDisplay() { char l3[32]; char l4[32]; snprintf(l2, sizeof(l2), "fresh:%lu total:%lu", (unsigned long)freshTotal, (unsigned long)heardTotal); - snprintf(l3, sizeof(l3), "sd:%s web:%s", g_storageReady ? "Y" : "N", g_webReady ? "Y" : "N"); + snprintf(l3, sizeof(l3), "sd:%s web:%s log:%s", g_sdReady ? "Y" : "N", g_webReady ? "Y" : "N", g_logReady ? "Y" : "N"); snprintf(l4, sizeof(l4), "log:%s", g_logPath[0] ? g_logPath + 6 : "none"); showLines(title, "DIAG", l2, l3, l4); return; @@ -604,17 +634,23 @@ void renderDisplay() { void pollStorageWeb() { g_storage.update(); if (g_storage.consumeRemovedEvent()) { - g_storageReady = false; + g_sdReady = false; + g_logReady = false; g_logPath[0] = '\0'; Serial.println("sd_removed logging_disabled"); } - if (g_storage.consumeMountedEvent() && !g_storageReady) { - g_storageReady = openDatedLog(); + if (g_storage.consumeMountedEvent()) { + g_sdReady = true; + Serial.println("sd_mounted"); + } + if (g_disciplined && g_storage.ready() && !g_logReady) { + g_sdReady = true; + g_logReady = openDatedLog(); } if (g_webReady) { g_web.update(); } - if (g_storageReady && (uint32_t)(millis() - g_lastFlushMs) >= kLogFlushPeriodMs) { + if (g_logReady && (uint32_t)(millis() - g_lastFlushMs) >= kLogFlushPeriodMs) { g_lastFlushMs = millis(); g_storage.flush(); } @@ -624,24 +660,30 @@ void pollStorageWeb() { void setup() { Serial.begin(115200); - delay(3000); - printBootBanner(); - markSelfNode(); - pinMode(BUTTON_PIN, INPUT_PULLUP); - if (!tbeam_supreme::initPmuForPeripherals(g_pmu, &Serial)) { Serial.println("WARNING: PMU init failed"); } g_display.begin(); - showLines(NODE_NAME " BLE SOUNDER", "booting", "PMU/OLED/GPS"); + showBootScreen("PMU/OLED ready"); + printBootBanner(); + markSelfNode(); + pinMode(BUTTON_PIN, INPUT_PULLUP); + + showBootScreen("Starting Web"); + startStorageAndWeb(); + showBootScreen("Starting GPS"); g_gnss.begin(); + showBootScreen("Probing GPS"); (void)g_gnss.probeAtStartup(Serial); #ifdef GPS_1PPS_PIN attachInterrupt(digitalPinToInterrupt(GPS_1PPS_PIN), onPpsEdge, RISING); #endif + showBootScreen("BLE disabled"); g_disciplined = disciplineStartupClock(); - startStorageAndWeb(); + showBootScreen("GPS disciplined"); + pollStorageWeb(); + showBootScreen("Starting BLE"); startBle(); } diff --git a/lib/tbeam_web/src/TBeamWeb.cpp b/lib/tbeam_web/src/TBeamWeb.cpp index 6045a88..b2c3fba 100644 --- a/lib/tbeam_web/src/TBeamWeb.cpp +++ b/lib/tbeam_web/src/TBeamWeb.cpp @@ -96,18 +96,31 @@ void TBeamWeb::handleNotFoundThunk() { } void TBeamWeb::handleRoot() { + bool countTruncated = false; + const bool sdReady = storage_ && storage_->ready(); + const size_t logsCount = sdReady ? countDirectoryEntries("/logs", 250, &countTruncated) : 0; + String body; body.reserve(2048); body += F(""); body += F(""); - body += F("T-Beam Files"); - body += F("

T-Beam Files

"); + body += F("T-Beam Status"); + body += F("

T-Beam Status

"); body += F("

SSID: "); body += htmlEscape(ssid_); body += F("

IP: "); body += htmlEscape(ip_.toString()); body += F("

SD: "); - body += (storage_ && storage_->ready()) ? F("mounted") : F("not mounted"); + body += sdReady ? F("mounted") : F("not mounted"); + body += F("

Log entries: "); + if (sdReady) { + body += String((unsigned long)logsCount); + if (countTruncated) { + body += F("+"); + } + } else { + body += F("unavailable"); + } body += F("

Stations: "); body += String(stationCount()); body += F("

Files "); @@ -154,7 +167,7 @@ void TBeamWeb::handleFiles() { body += F("

SD Files

Path: "); body += htmlEscape(path); body += F("

Home Root Logs

    "); - listDirectoryHtml(body, path, 4); + listDirectoryHtml(body, path, 0); body += F("
"); server_.send(200, "text/html", body); } @@ -289,6 +302,43 @@ void TBeamWeb::listDirectoryHtml(String& body, const char* path, uint8_t depth) dir.close(); } +size_t TBeamWeb::countDirectoryEntries(const char* path, size_t maxEntries, bool* truncated) { + if (truncated) { + *truncated = false; + } + if (maxEntries == 0) { + return 0; + } + File dir = SD.open(path, FILE_READ); + if (!dir) { + return 0; + } + if (!dir.isDirectory()) { + dir.close(); + return 1; + } + + size_t count = 0; + File entry = dir.openNextFile(); + while (entry) { + entry.close(); + ++count; + if (count >= maxEntries) { + File extra = dir.openNextFile(); + if (extra) { + if (truncated) { + *truncated = true; + } + extra.close(); + } + break; + } + entry = dir.openNextFile(); + } + dir.close(); + return count; +} + bool TBeamWeb::normalizePath(const String& input, char* out, size_t outSize) const { if (!out || outSize < 2 || input.length() == 0 || input.indexOf("..") >= 0) { return false; diff --git a/lib/tbeam_web/src/TBeamWeb.h b/lib/tbeam_web/src/TBeamWeb.h index 113a768..9932267 100644 --- a/lib/tbeam_web/src/TBeamWeb.h +++ b/lib/tbeam_web/src/TBeamWeb.h @@ -48,6 +48,7 @@ class TBeamWeb { void handleNotFound(); void listDirectoryHtml(String& body, const char* path, uint8_t depth); + size_t countDirectoryEntries(const char* path, size_t maxEntries, bool* truncated); bool normalizePath(const String& input, char* out, size_t outSize) const; String htmlEscape(const String& in) const; String urlEncode(const String& in) const; From aaa765adf89f213306a07f784fa6cda335694fc0 Mon Sep 17 00:00:00 2001 From: John Poole Date: Mon, 25 May 2026 14:10:58 -0700 Subject: [PATCH 3/9] After trial upon which import_exercise_26_ble_log.pl & schema were created. --- .../Codex_1_initial_prompt.md | 2 +- exercises/26_Bluetooth_discover/README.md | 80 +- .../scripts/create_exercise_26_ble_schema.sql | 379 +++++++++ .../scripts/exercise_26_smoke_test.awk | 141 ++++ .../scripts/import_exercise_26_ble_log.pl | 732 ++++++++++++++++++ exercises/26_Bluetooth_discover/src/main.cpp | 114 ++- 6 files changed, 1378 insertions(+), 70 deletions(-) create mode 100644 exercises/26_Bluetooth_discover/scripts/create_exercise_26_ble_schema.sql create mode 100755 exercises/26_Bluetooth_discover/scripts/exercise_26_smoke_test.awk create mode 100644 exercises/26_Bluetooth_discover/scripts/import_exercise_26_ble_log.pl diff --git a/exercises/26_Bluetooth_discover/Codex_1_initial_prompt.md b/exercises/26_Bluetooth_discover/Codex_1_initial_prompt.md index afdb67c..581dc7d 100644 --- a/exercises/26_Bluetooth_discover/Codex_1_initial_prompt.md +++ b/exercises/26_Bluetooth_discover/Codex_1_initial_prompt.md @@ -67,7 +67,7 @@ Functional requirements: - today's date - ChatGPT/Codex generated - Subversion keywords: $Id$ and $HeadURL$ -18. Add logging on the SD card using YYYYMMDD_HHMISS_[UNIT NAME]_ble_seach.log. Logging should include date + time, both epoch high precision and regular human readable (not high precision), GPS coordinates of current receiving unit, the advertisement received, the RSSI strength of the received transmission. +18. Add logging on the SD card using YYYYMMDD_HHMISS_[UNIT NAME]_ble_search.log. Logging should include date + time, both epoch high precision and regular human readable (not high precision), GPS coordinates of current receiving unit, the advertisement received, the RSSI strength of the received transmission. 19. Include web interface such as in Exercise 18, or later -- I cannot remember for sure, that allows downloading of the log files and deletion of the log files. We had a system where each unit had a unique IP for it, use that. Implementation details: diff --git a/exercises/26_Bluetooth_discover/README.md b/exercises/26_Bluetooth_discover/README.md index 013811d..9fda38e 100644 --- a/exercises/26_Bluetooth_discover/README.md +++ b/exercises/26_Bluetooth_discover/README.md @@ -26,12 +26,13 @@ pio device monitor -b 115200 -p /dev/ttytBOB ## Behavior -- Advertises manufacturer data in this format: `TBMSND|1|NODE|seq|uptime`. -- Accepts only advertisements with prefix `TBMSND`, version `1`, and node name in `AMY, BOB, CY, DAN, ED, FLO, GUY`. +- Advertises manufacturer data in this format: `B2|NODE|seq|tx_epoch_ms`. +- Accepts current `B2` advertisements and legacy `TBMSND|1|NODE|seq|uptime` advertisements from known nodes in `AMY, BOB, CY, DAN, ED, FLO, GUY`. - Displays heard nodes sorted by rolling RSSI average, strongest first. - Drops stale entries after 20 seconds. -- Logs accepted advertisements to SD as `/logs/YYYYMMDD_HHMISS_NODE_ble_seach.log`. -- Starts a WiFi AP for log access when SD is available. +- Logs accepted advertisements to SD as `/logs/YYYYMMDD_HHMISS_NODE_ble_search.log`. +- Refreshes the receiver GPS position at 1 Hz and logs the last known receiver coordinates plus GPS fix age. +- Starts a WiFi AP and HTTP web service during boot. This does not wait for GPS. Default web addresses: @@ -78,25 +79,25 @@ pio device monitor -b 115200 -p /dev/ttytAMY | tee logs/ble_discovery_AMY_YYYYMM Here are two logs from ED and FLO which were activated in the field. ```bash jlpoole@jp ~/work/tbeam/logs $ ls -lath ed/*_16* --rw-r--r-- 1 jlpoole jlpoole 1.1M May 25 09:53 ed/20260525_162217_ED_ble_seach.log +-rw-r--r-- 1 jlpoole jlpoole 1.1M May 25 09:53 ed/20260525_162217_ED_ble_search.log jlpoole@jp ~/work/tbeam/logs $ ls -lath flo/*_16* --rw-r--r-- 1 jlpoole jlpoole 1.1M May 25 09:53 flo/20260525_162213_FLO_ble_seach.log +-rw-r--r-- 1 jlpoole jlpoole 1.1M May 25 09:53 flo/20260525_162213_FLO_ble_search.log jlpoole@jp ~/work/tbeam/logs $ ``` -Here are start and end samples from both: +Here are start and end samples from an earlier trial. These show the previous 12-column schema; current firmware uses the 14-column schema described below. ```bash -jlpoole@jp ~/work/tbeam/logs $ cat -n ed/20260525_162217_ED_ble_seach.log|head -n 3 +jlpoole@jp ~/work/tbeam/logs $ cat -n ed/20260525_162217_ED_ble_search.log|head -n 3 1 human_time,epoch_ms,receiver,lat,lon,heard,rssi,avg_rssi,age_s,count,seq,payload 2 2026-05-25 16:22:18,1779726138897,ED,44.9364577,-123.0218702,FLO,-56,-56,0,1,1,TBMSND|1|FLO|0001|0775 3 2026-05-25 16:22:18,1779726138938,ED,44.9364577,-123.0218702,FLO,-51,-54,0,2,1,TBMSND|1|FLO|0001|0775 -jlpoole@jp ~/work/tbeam/logs $ cat -n ed/20260525_162217_ED_ble_seach.log|tail -n 1 +jlpoole@jp ~/work/tbeam/logs $ cat -n ed/20260525_162217_ED_ble_search.log|tail -n 1 10277 2026-05-25 16:47:42,1779727662217,ED,44.9364577,-123.0218702,FLO,-42,-42,0,10276,611,TBMSND|1|FLO|0611|2300 -jlpoole@jp ~/work/tbeam/logs $ cat -n flo/20260525_162213_FLO_ble_seach.log|head -n 3 +jlpoole@jp ~/work/tbeam/logs $ cat -n flo/20260525_162213_FLO_ble_search.log|head -n 3 1 human_time,epoch_ms,receiver,lat,lon,heard,rssi,avg_rssi,age_s,count,seq,payload 2 2026-05-25 16:22:16,1779726136737,FLO,44.9365132,-123.0218183,ED,-52,-52,0,1,0,TBMSND|1|ED|0000|0805 3 2026-05-25 16:22:16,1779726136829,FLO,44.9365132,-123.0218183,ED,-51,-52,0,2,0,TBMSND|1|ED|0000|0805 -jlpoole@jp ~/work/tbeam/logs $ cat -n flo/20260525_162213_FLO_ble_seach.log|tail -n 1 +jlpoole@jp ~/work/tbeam/logs $ cat -n flo/20260525_162213_FLO_ble_search.log|tail -n 1 10121 2026-05-25 16:47:37,1779727657219,FLO,44.9365132,-123.0218183,ED,-41,-40,0,10120,608,TBMSND|1|ED|0608|2325 jlpoole@jp ~/work/tbeam/logs $ ``` @@ -104,61 +105,54 @@ The header represents: | Column | Header | Explanation | | ---: | --- | --- | -| 1 | `human_time` | Receiver timestamp in human-readable UTC form based on Greenwich Mean Time ("G 0 +GROUP BY trial_id, receiver, heard; + +COMMIT; diff --git a/exercises/26_Bluetooth_discover/scripts/exercise_26_smoke_test.awk b/exercises/26_Bluetooth_discover/scripts/exercise_26_smoke_test.awk new file mode 100755 index 0000000..84f48a9 --- /dev/null +++ b/exercises/26_Bluetooth_discover/scripts/exercise_26_smoke_test.awk @@ -0,0 +1,141 @@ +#!/usr/bin/awk -f +# $Id$ +# $HeadURL$ +# +# Example: +# awk -f exercise_26_smoke_test.awk "$FLO_LOG" +# awk -f exercise_26_smoke_test.awk "$ED_LOG" +# +# BLE Exercise 26 GPS smoke test. + +BEGIN { + FS = "," + + # Approximate feet per degree latitude. + # Good enough for a smoke test around Salem, Oregon. + FEET_PER_DEG_LAT = 364000 +} + +NR == 1 { next } + +$4 != "" && $5 != "" { + n++ + + lat = $4 + 0 + lon = $5 + 0 + gps_age = $6 + 0 + + if (n == 1) { + first_time = $1 + first_lat = lat + first_lon = lon + + min_lat = max_lat = lat + min_lon = max_lon = lon + min_gps_age = max_gps_age = gps_age + } + +dlat_ft = (lat - first_lat) * FEET_PER_DEG_LAT + +pi = atan2(0, -1) +first_lat_rad = first_lat * pi / 180 +feet_per_deg_lon_here = FEET_PER_DEG_LAT * cos(first_lat_rad) + +dlon_ft = (lon - first_lon) * feet_per_deg_lon_here +dist_from_start_ft = sqrt(dlat_ft * dlat_ft + dlon_ft * dlon_ft) + +if (dist_from_start_ft > max_dist_from_start_ft) { + max_dist_from_start_ft = dist_from_start_ft + max_dist_time = $1 + max_dist_lat = lat + max_dist_lon = lon +} + + + last_time = $1 + last_lat = lat + last_lon = lon + + if (n % 2 == 0) { + mid_time = $1 + mid_lat = lat + mid_lon = lon + } + + key = sprintf("%.7f,%.7f", lat, lon) + seen[key]++ + + sum_lat += lat + sum_lon += lon + sum2_lat += lat * lat + sum2_lon += lon * lon + + sum_gps_age += gps_age + if (gps_age < min_gps_age) min_gps_age = gps_age + if (gps_age > max_gps_age) max_gps_age = gps_age + + if (lat < min_lat) min_lat = lat + if (lat > max_lat) max_lat = lat + if (lon < min_lon) min_lon = lon + if (lon > max_lon) max_lon = lon +} + +END { + if (n < 2) { + print "Not enough coordinate rows" + exit + } + + mean_lat = sum_lat / n + mean_lon = sum_lon / n + + sd_lat = sqrt((sum2_lat - n * mean_lat * mean_lat) / (n - 1)) + sd_lon = sqrt((sum2_lon - n * mean_lon * mean_lon) / (n - 1)) + + lat_range_deg = max_lat - min_lat + lon_range_deg = max_lon - min_lon + + pi = atan2(0, -1) + mean_lat_rad = mean_lat * pi / 180 + feet_per_deg_lon = FEET_PER_DEG_LAT * cos(mean_lat_rad) + + lat_range_ft = lat_range_deg * FEET_PER_DEG_LAT + lon_range_ft = lon_range_deg * feet_per_deg_lon + + sd_lat_ft = sd_lat * FEET_PER_DEG_LAT + sd_lon_ft = sd_lon * feet_per_deg_lon + + diag_ft = sqrt(lat_range_ft * lat_range_ft + lon_range_ft * lon_range_ft) + + printf "rows: %d\n", n + printf "distinct coordinate pairs: %d\n", length(seen) + printf "\n" + + printf "first: %s %.7f, %.7f\n", first_time, first_lat, first_lon + printf "mid: %s %.7f, %.7f\n", mid_time, mid_lat, mid_lon + printf "last: %s %.7f, %.7f\n", last_time, last_lat, last_lon + printf "\n" + + printf "mean_lat: %.8f\n", mean_lat + printf "mean_lon: %.8f\n", mean_lon + printf "sd_lat: %.10f degrees %.2f ft\n", sd_lat, sd_lat_ft + printf "sd_lon: %.10f degrees %.2f ft\n", sd_lon, sd_lon_ft + printf "\n" + + printf "min_lat: %.8f\n", min_lat + printf "max_lat: %.8f\n", max_lat + printf "min_lon: %.8f\n", min_lon + printf "max_lon: %.8f\n", max_lon + printf "\n" + + printf "lat_range: %.10f degrees %.2f ft north/south\n", lat_range_deg, lat_range_ft + printf "lon_range: %.10f degrees %.2f ft east/west\n", lon_range_deg, lon_range_ft + printf "bbox_diagonal: %.2f ft\n", diag_ft + printf "\n" + + printf "gps_age_ms min/avg/max: %.0f / %.1f / %.0f\n", min_gps_age, sum_gps_age / n, max_gps_age + +printf "max_dist_from_start: %.2f ft\n", max_dist_from_start_ft +printf "max_dist_time: %s\n", max_dist_time +printf "max_dist_coord: %.7f, %.7f\n", max_dist_lat, max_dist_lon +} diff --git a/exercises/26_Bluetooth_discover/scripts/import_exercise_26_ble_log.pl b/exercises/26_Bluetooth_discover/scripts/import_exercise_26_ble_log.pl new file mode 100644 index 0000000..b6a9d7e --- /dev/null +++ b/exercises/26_Bluetooth_discover/scripts/import_exercise_26_ble_log.pl @@ -0,0 +1,732 @@ +#!/usr/bin/env perl +# $Id$ +# $HeadURL$ +# +# Example: +# +# sqlite3 ble_fieldtest_20260525_1945_ed_flo.sqlite < create_exercise_26_ble_schema.sql +# +# ./import_exercise_26_ble_log.pl \ +# --db ble_fieldtest_20260525_1945_ed_flo.sqlite \ +# --log "$FLO_LOG" +# +# ./import_exercise_26_ble_log.pl \ +# --db ble_fieldtest_20260525_1945_ed_flo.sqlite \ +# --log "$ED_LOG" +# +# Manifest: +# +# The importer expects a manifest file with the same name as the log plus +# ".manifest" appended. +# +# Example: +# flo/20260525_194537_FLO_ble_search.log.manifest +# +# Simple key=value format: +# +# trial_name=Peck Cottage ED FLO BLE walk test 20260525_1945 +# trial_label=peck_cottage_ed_flo_20260525_1945 +# firmware_exercise=26_Bluetooth_discover +# firmware_git_commit=unknown +# field_site=Peck Cottage steps +# operator=jlpoole +# receiver_role=moving +# receiver_start_description=upside-down plastic pot at Peck Cottage steps +# receiver_notes=FLO was hand-carried north and across the street, then returned to base. +# test_notes=Two-unit BLE discovery test. ED stationary, FLO moving. +# +# Purpose: +# +# Import Exercise 26 BLE Discovery logs into SQLite. +# +# Required Perl modules: +# +# DBI +# DBD::SQLite +# Digest::SHA +# Getopt::Long +# File::Basename +# +# Gentoo package hints: +# +# emerge --ask dev-perl/DBI dev-perl/DBD-SQLite +# + +use strict; +use warnings; + +use DBI; +use Digest::SHA qw(sha256_hex); +use File::Basename qw(basename); +use Getopt::Long qw(GetOptions); + +my $db_file; +my $log_file; +my $help; + +GetOptions( + 'db=s' => \$db_file, + 'log=s' => \$log_file, + 'help' => \$help, +) or die usage(); + +if ($help) { + print usage(); + exit 0; +} + +die usage() unless defined $db_file && defined $log_file; + +die "Database file does not exist: $db_file\n" + unless -f $db_file; + +die "Log file does not exist: $log_file\n" + unless -f $log_file; + +my $manifest_file = $log_file . ".manifest"; + +die "Manifest file does not exist: $manifest_file\n" + unless -f $manifest_file; + +my %manifest = read_manifest($manifest_file); + +my $trial_label = required_manifest(\%manifest, 'trial_label'); +my $trial_name = required_manifest(\%manifest, 'trial_name'); + +my $sha256 = file_sha256($log_file); +my $byte_count = -s $log_file; +my $base_name = basename($log_file); + +my $dbh = DBI->connect( + "dbi:SQLite:dbname=$db_file", + "", + "", + { + RaiseError => 1, + AutoCommit => 0, + sqlite_unicode => 1, + } +); + +$dbh->do('PRAGMA foreign_keys = ON'); + +eval { + my $existing = $dbh->selectrow_array( + 'SELECT log_file_id FROM log_file WHERE sha256 = ?', + undef, + $sha256 + ); + + if (defined $existing) { + die "This log already appears to be imported: log_file_id=$existing sha256=$sha256\n"; + } + + my $trial_id = upsert_trial($dbh, \%manifest); + + my $log_stats = scan_log_for_stats($log_file); + + my $receiver = $log_stats->{receiver} + or die "Could not determine receiver from log: $log_file\n"; + + upsert_unit($dbh, $receiver); + + my $log_file_id = insert_log_file( + $dbh, + trial_id => $trial_id, + receiver => $receiver, + path => $log_file, + basename => $base_name, + manifest_path => $manifest_file, + sha256 => $sha256, + byte_count => $byte_count, + source_row_count => $log_stats->{source_row_count}, + first_rx_epoch_ms => $log_stats->{first_rx_epoch_ms}, + last_rx_epoch_ms => $log_stats->{last_rx_epoch_ms}, + receiver_role => $manifest{receiver_role}, + receiver_start_description => $manifest{receiver_start_description}, + receiver_notes => $manifest{receiver_notes}, + test_notes => $manifest{test_notes}, + ); + + insert_manifest_kv($dbh, $log_file_id, \%manifest); + + import_log_rows( + $dbh, + trial_id => $trial_id, + log_file_id => $log_file_id, + log_file => $log_file, + ); + + update_trial_time_bounds($dbh, $trial_id); + + $dbh->commit; + + print "Imported log successfully\n"; + print " database: $db_file\n"; + print " log: $log_file\n"; + print " manifest: $manifest_file\n"; + print " trial_id: $trial_id\n"; + print " log_file_id: $log_file_id\n"; + print " receiver: $receiver\n"; + print " rows: $log_stats->{source_row_count}\n"; + print " sha256: $sha256\n"; +}; + +if ($@) { + my $err = $@; + eval { $dbh->rollback }; + die $err; +} + +$dbh->disconnect; + +exit 0; + +sub usage { + return <<"EOF"; +Usage: + $0 --db DATABASE.sqlite --log path/to/log.csv + +Example: + $0 --db ble_fieldtest_20260525_1945_ed_flo.sqlite --log "\$FLO_LOG" + +The importer expects a manifest beside the log: + + path/to/log.csv.manifest + +EOF +} + +sub required_manifest { + my ($manifest_ref, $key) = @_; + + die "Manifest is missing required key: $key\n" + unless exists $manifest_ref->{$key} + && defined $manifest_ref->{$key} + && $manifest_ref->{$key} ne ''; + + return $manifest_ref->{$key}; +} + +sub read_manifest { + my ($path) = @_; + + open my $fh, '<', $path + or die "Could not open manifest $path: $!\n"; + + my %manifest; + + while (my $line = <$fh>) { + chomp $line; + $line =~ s/\r\z//; + + next if $line =~ /^\s*$/; + next if $line =~ /^\s*#/; + + die "Bad manifest line, expected key=value: $line\n" + unless $line =~ /^\s*([^=]+?)\s*=\s*(.*?)\s*$/; + + my ($key, $value) = ($1, $2); + + $key =~ s/^\s+|\s+$//g; + $value =~ s/^\s+|\s+$//g; + + die "Empty manifest key in line: $line\n" + if $key eq ''; + + $manifest{$key} = $value; + } + + close $fh; + + return %manifest; +} + +sub file_sha256 { + my ($path) = @_; + + open my $fh, '<:raw', $path + or die "Could not open $path for SHA-256: $!\n"; + + my $ctx = Digest::SHA->new(256); + $ctx->addfile($fh); + + close $fh; + + return $ctx->hexdigest; +} + +sub scan_log_for_stats { + my ($path) = @_; + + open my $fh, '<', $path + or die "Could not open log $path: $!\n"; + + my $header = <$fh>; + die "Empty log file: $path\n" unless defined $header; + + chomp $header; + $header =~ s/\r\z//; + + my @cols = split /,/, $header, -1; + validate_header(@cols); + + my $row_count = 0; + my $receiver; + my $first_rx_epoch_ms; + my $last_rx_epoch_ms; + + while (my $line = <$fh>) { + chomp $line; + $line =~ s/\r\z//; + next if $line =~ /^\s*$/; + + my @f = split /,/, $line, -1; + + next unless @f >= 14; + + $row_count++; + + my $rx_epoch_ms = to_int_or_undef($f[1]); + my $rx_receiver = $f[2]; + + $receiver //= $rx_receiver; + + if (defined $rx_epoch_ms) { + $first_rx_epoch_ms //= $rx_epoch_ms; + $last_rx_epoch_ms = $rx_epoch_ms; + } + } + + close $fh; + + return { + receiver => $receiver, + source_row_count => $row_count, + first_rx_epoch_ms => $first_rx_epoch_ms, + last_rx_epoch_ms => $last_rx_epoch_ms, + }; +} + +sub validate_header { + my (@cols) = @_; + + my @expected = qw( + human_time + rx_epoch_ms + receiver + rx_lat + rx_lon + rx_gps_age_ms + heard + rssi + avg_rssi + age_s + count + seq + tx_epoch_ms + payload + ); + + die "Unexpected column count in header. Expected 14, got " . scalar(@cols) . "\n" + unless @cols == @expected; + + for my $i (0 .. $#expected) { + die "Unexpected header column " . ($i + 1) . ". Expected '$expected[$i]', got '$cols[$i]'\n" + unless $cols[$i] eq $expected[$i]; + } +} + +sub upsert_trial { + my ($dbh, $manifest_ref) = @_; + + my $trial_label = required_manifest($manifest_ref, 'trial_label'); + my $trial_name = required_manifest($manifest_ref, 'trial_name'); + + my $existing = $dbh->selectrow_array( + 'SELECT trial_id FROM trial WHERE trial_label = ?', + undef, + $trial_label + ); + + return $existing if defined $existing; + + my $sth = $dbh->prepare(q{ + INSERT INTO trial ( + trial_label, + trial_name, + field_site, + operator, + firmware_exercise, + firmware_git_commit, + notes + ) + VALUES (?, ?, ?, ?, ?, ?, ?) + }); + + $sth->execute( + $trial_label, + $trial_name, + $manifest_ref->{field_site}, + $manifest_ref->{operator}, + $manifest_ref->{firmware_exercise}, + $manifest_ref->{firmware_git_commit}, + $manifest_ref->{test_notes}, + ); + + return $dbh->sqlite_last_insert_rowid; +} + +sub upsert_unit { + my ($dbh, $unit_name) = @_; + + return unless defined $unit_name && $unit_name ne ''; + + my $sth = $dbh->prepare(q{ + INSERT OR IGNORE INTO unit (unit_name) + VALUES (?) + }); + + $sth->execute($unit_name); +} + +sub insert_log_file { + my ($dbh, %arg) = @_; + + my $sth = $dbh->prepare(q{ + INSERT INTO log_file ( + trial_id, + receiver, + path, + basename, + manifest_path, + sha256, + byte_count, + source_row_count, + first_rx_epoch_ms, + last_rx_epoch_ms, + receiver_role, + receiver_start_description, + receiver_notes, + test_notes + ) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + }); + + $sth->execute( + $arg{trial_id}, + $arg{receiver}, + $arg{path}, + $arg{basename}, + $arg{manifest_path}, + $arg{sha256}, + $arg{byte_count}, + $arg{source_row_count}, + $arg{first_rx_epoch_ms}, + $arg{last_rx_epoch_ms}, + $arg{receiver_role}, + $arg{receiver_start_description}, + $arg{receiver_notes}, + $arg{test_notes}, + ); + + return $dbh->sqlite_last_insert_rowid; +} + +sub insert_manifest_kv { + my ($dbh, $log_file_id, $manifest_ref) = @_; + + my $sth = $dbh->prepare(q{ + INSERT INTO log_manifest_kv (log_file_id, key, value) + VALUES (?, ?, ?) + }); + + for my $key (sort keys %{$manifest_ref}) { + $sth->execute($log_file_id, $key, $manifest_ref->{$key}); + } +} + +sub import_log_rows { + my ($dbh, %arg) = @_; + + my $trial_id = $arg{trial_id}; + my $log_file_id = $arg{log_file_id}; + my $log_file = $arg{log_file}; + + open my $fh, '<', $log_file + or die "Could not open log $log_file: $!\n"; + + my $header = <$fh>; + die "Empty log file: $log_file\n" unless defined $header; + + chomp $header; + $header =~ s/\r\z//; + validate_header(split /,/, $header, -1); + + my $raw_sth = $dbh->prepare(q{ + INSERT INTO ble_observation_raw ( + trial_id, + log_file_id, + source_line_no, + human_time, + rx_epoch_ms, + receiver, + rx_lat, + rx_lon, + rx_gps_age_ms, + heard, + rssi, + avg_rssi, + age_s, + count, + seq, + tx_epoch_ms, + payload + ) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + }); + + my $obs_sth = $dbh->prepare(q{ + INSERT INTO ble_observation ( + raw_id, + trial_id, + log_file_id, + source_line_no, + rx_epoch_ms, + rx_epoch_s, + tx_epoch_ms, + tx_epoch_s, + rx_tx_delta_ms, + receiver, + heard, + rx_lat, + rx_lon, + rx_gps_age_ms, + rssi, + avg_rssi, + age_s, + receiver_count, + receiver_seq_field, + payload_raw, + payload_kind, + payload_node, + payload_seq, + payload_tx_epoch_ms, + payload_legacy_uptime, + parse_warning + ) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + }); + + my $line_no = 1; + my $data_rows = 0; + + while (my $line = <$fh>) { + $line_no++; + chomp $line; + $line =~ s/\r\z//; + + next if $line =~ /^\s*$/; + + my @f = split /,/, $line, -1; + + die "Line $line_no has too few columns. Expected 14, got " . scalar(@f) . "\n" + unless @f >= 14; + + my $human_time = $f[0]; + my $rx_epoch_ms = to_int_or_undef($f[1]); + my $receiver = $f[2]; + my $rx_lat = to_num_or_undef($f[3]); + my $rx_lon = to_num_or_undef($f[4]); + my $rx_gps_age_ms = to_int_or_undef($f[5]); + my $heard = $f[6]; + my $rssi = to_int_or_undef($f[7]); + my $avg_rssi = to_int_or_undef($f[8]); + my $age_s = to_int_or_undef($f[9]); + my $count = to_int_or_undef($f[10]); + my $seq = to_int_or_undef($f[11]); + my $tx_epoch_ms = to_int_or_undef($f[12]); + my $payload = $f[13]; + + die "Line $line_no has no rx_epoch_ms\n" + unless defined $rx_epoch_ms; + + die "Line $line_no has no receiver\n" + unless defined $receiver && $receiver ne ''; + + die "Line $line_no has no heard unit\n" + unless defined $heard && $heard ne ''; + + upsert_unit($dbh, $receiver); + upsert_unit($dbh, $heard); + + $raw_sth->execute( + $trial_id, + $log_file_id, + $line_no, + $human_time, + $rx_epoch_ms, + $receiver, + $rx_lat, + $rx_lon, + $rx_gps_age_ms, + $heard, + $rssi, + $avg_rssi, + $age_s, + $count, + $seq, + $tx_epoch_ms, + $payload, + ); + + my $raw_id = $dbh->sqlite_last_insert_rowid; + + my $parsed = parse_payload($payload); + + my $rx_epoch_s = $rx_epoch_ms / 1000.0; + + my $tx_epoch_s; + my $rx_tx_delta_ms; + + if (defined $tx_epoch_ms && $tx_epoch_ms > 0) { + $tx_epoch_s = $tx_epoch_ms / 1000.0; + $rx_tx_delta_ms = $rx_epoch_ms - $tx_epoch_ms; + } + + $obs_sth->execute( + $raw_id, + $trial_id, + $log_file_id, + $line_no, + $rx_epoch_ms, + $rx_epoch_s, + $tx_epoch_ms, + $tx_epoch_s, + $rx_tx_delta_ms, + $receiver, + $heard, + $rx_lat, + $rx_lon, + $rx_gps_age_ms, + $rssi, + $avg_rssi, + $age_s, + $count, + $seq, + $payload, + $parsed->{payload_kind}, + $parsed->{payload_node}, + $parsed->{payload_seq}, + $parsed->{payload_tx_epoch_ms}, + $parsed->{payload_legacy_uptime}, + $parsed->{parse_warning}, + ); + + $data_rows++; + } + + close $fh; + + return $data_rows; +} + +sub parse_payload { + my ($payload) = @_; + + my %p = ( + payload_kind => undef, + payload_node => undef, + payload_seq => undef, + payload_tx_epoch_ms => undef, + payload_legacy_uptime => undef, + parse_warning => undef, + ); + + if (!defined $payload || $payload eq '') { + $p{parse_warning} = 'empty payload'; + return \%p; + } + + my @parts = split /\|/, $payload, -1; + + if (@parts == 4 && $parts[0] eq 'B2') { + $p{payload_kind} = 'B2'; + $p{payload_node} = $parts[1]; + $p{payload_seq} = to_int_or_undef($parts[2]); + $p{payload_tx_epoch_ms} = to_int_or_undef($parts[3]); + + if (!defined $p{payload_seq}) { + $p{parse_warning} = 'B2 payload seq was not numeric'; + } + elsif (!defined $p{payload_tx_epoch_ms}) { + $p{parse_warning} = 'B2 payload tx_epoch_ms was not numeric'; + } + + return \%p; + } + + if (@parts == 5 && $parts[0] eq 'TBMSND' && $parts[1] eq '1') { + $p{payload_kind} = 'TBMSND1'; + $p{payload_node} = $parts[2]; + $p{payload_seq} = to_int_or_undef($parts[3]); + $p{payload_legacy_uptime} = to_int_or_undef($parts[4]); + + if (!defined $p{payload_seq}) { + $p{parse_warning} = 'legacy payload seq was not numeric'; + } + elsif (!defined $p{payload_legacy_uptime}) { + $p{parse_warning} = 'legacy payload uptime was not numeric'; + } + + return \%p; + } + + $p{parse_warning} = 'unrecognized payload format'; + return \%p; +} + +sub update_trial_time_bounds { + my ($dbh, $trial_id) = @_; + + $dbh->do(q{ + UPDATE trial + SET trial_start_epoch_ms = ( + SELECT MIN(first_rx_epoch_ms) + FROM log_file + WHERE trial_id = ? + ), + trial_end_epoch_ms = ( + SELECT MAX(last_rx_epoch_ms) + FROM log_file + WHERE trial_id = ? + ) + WHERE trial_id = ? + }, undef, $trial_id, $trial_id, $trial_id); +} + +sub to_int_or_undef { + my ($value) = @_; + + return undef unless defined $value; + return undef if $value eq ''; + + $value =~ s/^\s+|\s+$//g; + + return undef unless $value =~ /^-?\d+$/; + + return int($value); +} + +sub to_num_or_undef { + my ($value) = @_; + + return undef unless defined $value; + return undef if $value eq ''; + + $value =~ s/^\s+|\s+$//g; + + return undef unless $value =~ /^-?(?:\d+(?:\.\d*)?|\.\d+)$/; + + return 0 + $value; +} diff --git a/exercises/26_Bluetooth_discover/src/main.cpp b/exercises/26_Bluetooth_discover/src/main.cpp index 9e66fe5..04bc8dd 100644 --- a/exercises/26_Bluetooth_discover/src/main.cpp +++ b/exercises/26_Bluetooth_discover/src/main.cpp @@ -65,13 +65,15 @@ using field_qa::GnssSample; static constexpr const char* kAppTitle = "BLE Discovery"; static constexpr const char* kProjectPrefix = "TBMSND"; static constexpr uint8_t kProtocolVersion = 1; +static constexpr const char* kTimePayloadPrefix = "B2"; static constexpr uint32_t kStaleMs = 20000; static constexpr uint32_t kDisplayPeriodMs = 1000; -static constexpr uint32_t kAdvertisePeriodMs = 2000; +static constexpr uint32_t kAdvertisePeriodMs = 1000; static constexpr uint32_t kScanPeriodMs = 2500; static constexpr uint32_t kScanWindowSeconds = 2; static constexpr uint32_t kLogFlushPeriodMs = 5000; static constexpr uint32_t kStartupStatusPeriodMs = 1000; +static constexpr uint32_t kGpsRefreshPeriodMs = 1000; static constexpr uint8_t kRssiWindow = 5; struct NodeState { @@ -87,6 +89,7 @@ struct NodeState { uint32_t seenCount = 0; uint32_t lastSeenMs = 0; uint32_t lastSeq = 0; + int64_t lastTxEpochMs = 0; }; XPowersLibInterface* g_pmu = nullptr; @@ -116,17 +119,22 @@ bool g_buttonWasPressed = false; uint8_t g_displayMode = 0; uint32_t g_sequence = 0; volatile uint32_t g_ppsEdgeCount = 0; +volatile uint32_t g_lastPpsMs = 0; uint32_t g_lastAdvertiseMs = 0; uint32_t g_lastScanMs = 0; uint32_t g_lastDisplayMs = 0; uint32_t g_lastFlushMs = 0; +uint32_t g_lastGpsRefreshMs = 0; int64_t g_epochBase = 0; uint32_t g_epochBaseMs = 0; double g_latitude = 0.0; double g_longitude = 0.0; +uint32_t g_gpsFixMs = 0; +bool g_hasLocation = false; char g_logPath[128] = {}; void IRAM_ATTR onPpsEdge() { + g_lastPpsMs = millis(); ++g_ppsEdgeCount; } @@ -168,11 +176,39 @@ bool freshEnough(const NodeState& node, uint32_t now) { return node.heard && (uint32_t)(now - node.lastSeenMs) <= kStaleMs; } +int64_t currentEpochMs(); + int64_t currentEpoch() { + return currentEpochMs() / 1000LL; +} + +int64_t currentEpochMs() { if (!g_disciplined || g_epochBase <= 0) { return 0; } - return g_epochBase + (int64_t)((millis() - g_epochBaseMs) / 1000UL); + return (g_epochBase * 1000LL) + (int64_t)(millis() - g_epochBaseMs); +} + +uint32_t gpsAgeMs(uint32_t now) { + if (!g_hasLocation || g_gpsFixMs == 0) { + return UINT32_MAX; + } + return now - g_gpsFixMs; +} + +void refreshGpsPosition(bool force = false) { + const uint32_t now = millis(); + if (!force && (uint32_t)(now - g_lastGpsRefreshMs) < kGpsRefreshPeriodMs) { + return; + } + g_lastGpsRefreshMs = now; + const GnssSample sample = g_gnss.makeSample(); + if (sample.validLocation && sample.validFix) { + g_latitude = sample.latitude; + g_longitude = sample.longitude; + g_gpsFixMs = sample.sampleMillis; + g_hasLocation = true; + } } void formatDateTime(int64_t epoch, char* out, size_t outSize) { @@ -311,9 +347,12 @@ bool disciplineStartupClock() { ++attemptCount; if (g_clock.disciplineFromGnss(sample, waitForPps, nullptr, disciplined, hadPriorRtc, driftSeconds)) { g_epochBase = ClockDiscipline::toEpochSeconds(disciplined); - g_epochBaseMs = millis(); + g_epochBaseMs = g_lastPpsMs > 0 ? g_lastPpsMs : millis(); g_latitude = sample.latitude; g_longitude = sample.longitude; + g_gpsFixMs = sample.sampleMillis; + g_hasLocation = true; + g_lastGpsRefreshMs = millis(); char iso[32]; ClockDiscipline::formatIsoUtc(disciplined, iso, sizeof(iso)); Serial.printf("clock_disciplined node=%s utc=%s prior_rtc=%u drift_s=%lld lat=%.7f lon=%.7f\n", @@ -338,7 +377,7 @@ bool openDatedLog() { } char stamp[24]; formatCompactDateTime(currentEpoch(), stamp, sizeof(stamp)); - snprintf(g_logPath, sizeof(g_logPath), "/logs/%s_%s_ble_seach.log", stamp, NODE_NAME); + snprintf(g_logPath, sizeof(g_logPath), "/logs/%s_%s_ble_search.log", stamp, NODE_NAME); if (!g_storage.ensureDirRecursive("/logs")) { Serial.printf("sd_log_dir_failed err=%s\n", g_storage.lastError()); return false; @@ -347,7 +386,7 @@ bool openDatedLog() { Serial.printf("sd_log_open_failed path=%s err=%s\n", g_logPath, g_storage.lastError()); return false; } - g_storage.println("human_time,epoch_ms,receiver,lat,lon,heard,rssi,avg_rssi,age_s,count,seq,payload"); + g_storage.println("human_time,rx_epoch_ms,receiver,rx_lat,rx_lon,rx_gps_age_ms,heard,rssi,avg_rssi,age_s,count,seq,tx_epoch_ms,payload"); g_storage.flush(); Serial.printf("sd_log_open path=%s\n", g_logPath); g_logReady = true; @@ -387,15 +426,14 @@ void startStorageAndWeb() { } void updateAdvertisement() { - char payload[30]; + char payload[32]; snprintf(payload, sizeof(payload), - "%s|%u|%s|%04lu|%04lu", - kProjectPrefix, - (unsigned)kProtocolVersion, + "%s|%s|%04lu|%lld", + kTimePayloadPrefix, NODE_NAME, (unsigned long)(g_sequence % 10000UL), - (unsigned long)((millis() / 1000UL) % 10000UL)); + (long long)currentEpochMs()); BLEAdvertising* advertising = BLEDevice::getAdvertising(); BLEAdvertisementData data; @@ -410,12 +448,14 @@ void updateAdvertisement() { g_lastAdvertiseMs = millis(); } -bool parsePayload(const std::string& data, char* outName, size_t outNameSize, uint32_t& outSeq, char* outPayload, size_t outPayloadSize) { +bool parsePayload(const std::string& data, char* outName, size_t outNameSize, uint32_t& outSeq, int64_t& outTxEpochMs, char* outPayload, size_t outPayloadSize) { if (outNameSize == 0 || outPayloadSize == 0) { return false; } outName[0] = '\0'; outPayload[0] = '\0'; + outSeq = 0; + outTxEpochMs = 0; if (data.empty() || data.size() >= outPayloadSize) { return false; } @@ -426,13 +466,26 @@ bool parsePayload(const std::string& data, char* outName, size_t outNameSize, ui strlcpy(work, outPayload, sizeof(work)); char* save = nullptr; const char* prefix = strtok_r(work, "|", &save); - const char* version = strtok_r(nullptr, "|", &save); - const char* name = strtok_r(nullptr, "|", &save); - const char* seq = strtok_r(nullptr, "|", &save); - if (!prefix || !version || !name || !seq) { + if (!prefix) { return false; } - if (strcmp(prefix, kProjectPrefix) != 0 || atoi(version) != kProtocolVersion) { + + const char* name = nullptr; + const char* seq = nullptr; + const char* txEpochMs = nullptr; + if (strcmp(prefix, kTimePayloadPrefix) == 0) { + name = strtok_r(nullptr, "|", &save); + seq = strtok_r(nullptr, "|", &save); + txEpochMs = strtok_r(nullptr, "|", &save); + } else { + const char* version = strtok_r(nullptr, "|", &save); + name = strtok_r(nullptr, "|", &save); + seq = strtok_r(nullptr, "|", &save); + if (strcmp(prefix, kProjectPrefix) != 0 || !version || atoi(version) != kProtocolVersion) { + return false; + } + } + if (!name || !seq) { return false; } if (nodeIndexFor(name) < 0 || isSelfName(name)) { @@ -440,19 +493,22 @@ bool parsePayload(const std::string& data, char* outName, size_t outNameSize, ui } strlcpy(outName, name, outNameSize); outSeq = (uint32_t)strtoul(seq, nullptr, 10); + if (txEpochMs && txEpochMs[0]) { + outTxEpochMs = (int64_t)strtoll(txEpochMs, nullptr, 10); + } return true; } void logAcceptedAdvertisement(const NodeState& node, const char* payload) { const int avg = averageRssi(node); const uint32_t age = ageSeconds(node, millis()); - const int64_t epoch = currentEpoch(); + const int64_t epochMs = currentEpochMs(); + const int64_t epoch = epochMs / 1000LL; + const uint32_t fixAge = gpsAgeMs(millis()); char human[32]; formatDateTime(epoch, human, sizeof(human)); - const uint32_t epochMsPart = millis() % 1000UL; - const long long epochMs = (long long)(epoch * 1000LL + epochMsPart); - Serial.printf("%lu,%s,%s,%d,%d,%lu,%lu,%lu,%s\n", + Serial.printf("%lu,%s,%s,%d,%d,%lu,%lu,%lu,%lld,%s\n", (unsigned long)millis(), NODE_NAME, node.name, @@ -461,30 +517,33 @@ void logAcceptedAdvertisement(const NodeState& node, const char* payload) { (unsigned long)age, (unsigned long)node.seenCount, (unsigned long)node.lastSeq, + (long long)node.lastTxEpochMs, payload); if (g_logReady && g_storage.isLogOpen()) { - char line[224]; + char line[256]; snprintf(line, sizeof(line), - "%s,%lld,%s,%.7f,%.7f,%s,%d,%d,%lu,%lu,%lu,%s", + "%s,%lld,%s,%.7f,%.7f,%lu,%s,%d,%d,%lu,%lu,%lu,%lld,%s", human, - epochMs, + (long long)epochMs, NODE_NAME, g_latitude, g_longitude, + (unsigned long)fixAge, node.name, node.lastRssi, avg, (unsigned long)age, (unsigned long)node.seenCount, (unsigned long)node.lastSeq, + (long long)node.lastTxEpochMs, payload); g_storage.println(line); } } -void acceptAdvertisement(const char* name, int rssi, uint32_t seq, const char* payload) { +void acceptAdvertisement(const char* name, int rssi, uint32_t seq, int64_t txEpochMs, const char* payload) { const int idx = nodeIndexFor(name); if (idx < 0) { return; @@ -500,6 +559,7 @@ void acceptAdvertisement(const char* name, int rssi, uint32_t seq, const char* p ++node.seenCount; node.lastSeenMs = millis(); node.lastSeq = seq; + node.lastTxEpochMs = txEpochMs; logAcceptedAdvertisement(node, payload); } @@ -512,10 +572,11 @@ class DiscoveryAdvertisedCallbacks : public BLEAdvertisedDeviceCallbacks { char name[8]; char payload[48]; uint32_t seq = 0; - if (!parsePayload(advertisedDevice.getManufacturerData(), name, sizeof(name), seq, payload, sizeof(payload))) { + int64_t txEpochMs = 0; + if (!parsePayload(advertisedDevice.getManufacturerData(), name, sizeof(name), seq, txEpochMs, payload, sizeof(payload))) { return; } - acceptAdvertisement(name, advertisedDevice.getRSSI(), seq, payload); + acceptAdvertisement(name, advertisedDevice.getRSSI(), seq, txEpochMs, payload); } }; @@ -689,6 +750,7 @@ void setup() { void loop() { g_gnss.poll(); + refreshGpsPosition(); pollButton(); pollStorageWeb(); if (g_bleStarted) { From 577caba6354ee8341e732bf2916f820679f36e7f Mon Sep 17 00:00:00 2001 From: John Poole Date: Mon, 25 May 2026 14:56:23 -0700 Subject: [PATCH 4/9] After time changes, but before testing trial #2 --- exercises/26_Bluetooth_discover/.gitignore | 2 + .../Codex_2_timing_prompt.md | 24 ++++ exercises/26_Bluetooth_discover/README.md | 46 ++++--- .../scripts/create_exercise_26_ble_schema.sql | 34 +++++ .../scripts/import_exercise_26_ble_log.pl | 41 ++++-- exercises/26_Bluetooth_discover/src/main.cpp | 118 ++++++++++++++---- 6 files changed, 217 insertions(+), 48 deletions(-) create mode 100644 exercises/26_Bluetooth_discover/.gitignore create mode 100644 exercises/26_Bluetooth_discover/Codex_2_timing_prompt.md mode change 100644 => 100755 exercises/26_Bluetooth_discover/scripts/import_exercise_26_ble_log.pl diff --git a/exercises/26_Bluetooth_discover/.gitignore b/exercises/26_Bluetooth_discover/.gitignore new file mode 100644 index 0000000..47e0292 --- /dev/null +++ b/exercises/26_Bluetooth_discover/.gitignore @@ -0,0 +1,2 @@ +*.sqlite + diff --git a/exercises/26_Bluetooth_discover/Codex_2_timing_prompt.md b/exercises/26_Bluetooth_discover/Codex_2_timing_prompt.md new file mode 100644 index 0000000..703a725 --- /dev/null +++ b/exercises/26_Bluetooth_discover/Codex_2_timing_prompt.md @@ -0,0 +1,24 @@ +4. Clarification on runtime clock validity: + +Do not require continuous GPS visibility to keep the clock valid. Use a holdover model. + +At startup: +- Require GPS/PPS/RTC discipline before entering BLE survey mode. +- Set: + g_clockValid = true + g_lastDisciplineEpochMs = currentEpochMs() + g_lastDisciplineMillis = millis() + +During runtime: +- clock_valid remains true while: + millis() - g_lastDisciplineMillis <= kMaxDisciplineAgeMs +- gps_valid is evaluated separately from the latest GPS fix age: + rx_gps_age_ms <= kMaxGpsFixAgeMs + +If the unit later moves under trees or near buildings and loses satellite visibility: +- Do not immediately invalidate clock_valid. +- Let gps_valid become false when rx_gps_age_ms exceeds kMaxGpsFixAgeMs. +- Continue OLED BLE display if possible. +- Do not write normal observation rows unless both clock_valid and gps_valid are true, or else write rows with explicit validity fields so analysis can filter them. + +The important point is: a temporary GPS outage should stale the coordinates, not necessarily the disciplined clock. diff --git a/exercises/26_Bluetooth_discover/README.md b/exercises/26_Bluetooth_discover/README.md index 9fda38e..1d25217 100644 --- a/exercises/26_Bluetooth_discover/README.md +++ b/exercises/26_Bluetooth_discover/README.md @@ -26,12 +26,12 @@ pio device monitor -b 115200 -p /dev/ttytBOB ## Behavior -- Advertises manufacturer data in this format: `B2|NODE|seq|tx_epoch_ms`. +- Advertises manufacturer data in this format: `B2|NODE|seq|tx_payload_epoch_ms`. - Accepts current `B2` advertisements and legacy `TBMSND|1|NODE|seq|uptime` advertisements from known nodes in `AMY, BOB, CY, DAN, ED, FLO, GUY`. - Displays heard nodes sorted by rolling RSSI average, strongest first. - Drops stale entries after 20 seconds. -- Logs accepted advertisements to SD as `/logs/YYYYMMDD_HHMISS_NODE_ble_search.log`. -- Refreshes the receiver GPS position at 1 Hz and logs the last known receiver coordinates plus GPS fix age. +- Logs valid accepted advertisements to SD as `/logs/YYYYMMDD_HHMISS_NODE_ble_search.log`. +- Refreshes the receiver GPS position at 1 Hz and logs the last known receiver coordinates plus GPS fix age. Observation rows are written only while the holdover clock and GPS fix age are both valid. - Starts a WiFi AP and HTTP web service during boot. This does not wait for GPS. Default web addresses: @@ -85,7 +85,7 @@ jlpoole@jp ~/work/tbeam/logs $ ls -lath flo/*_16* jlpoole@jp ~/work/tbeam/logs $ ``` -Here are start and end samples from an earlier trial. These show the previous 12-column schema; current firmware uses the 14-column schema described below. +Here are start and end samples from an earlier trial. These show the previous 12-column schema; current firmware uses the 18-column schema described below. ```bash jlpoole@jp ~/work/tbeam/logs $ cat -n ed/20260525_162217_ED_ble_search.log|head -n 3 1 human_time,epoch_ms,receiver,lat,lon,heard,rssi,avg_rssi,age_s,count,seq,payload @@ -110,22 +110,26 @@ The header represents: | 3 | `receiver` | Unit that wrote the log row. | | 4 | `rx_lat` | Receiver GPS latitude from the latest valid local GPS fix. | | 5 | `rx_lon` | Receiver GPS longitude from the latest valid local GPS fix. | -| 6 | `rx_gps_age_ms` | Age of the receiver GPS fix in milliseconds when the row was written. If GPS becomes unavailable, the last known coordinates remain and this age grows. | -| 7 | `heard` | Remote unit heard in the BLE advertisement. | -| 8 | `rssi` | RSSI measured by the receiver for this advertisement. | -| 9 | `avg_rssi` | Receiver-calculated arithmetic mean of the most recent RSSI measurements for this heard unit, using up to the last 5 accepted advertisements and rounded to the nearest integer. The window size is `kRssiWindow = 5` in `main.cpp`. | -| 10 | `age_s` | Age in seconds of the displayed/heard entry. | -| 11 | `count` | Number of accepted advertisements from that heard unit. | -| 12 | `seq` | Sequence number advertised by the heard unit. | -| 13 | `tx_epoch_ms` | Sender timestamp from the BLE payload as Unix epoch milliseconds, when available. Legacy v1 payloads report `0` here. | -| 14 | `payload` | Raw BLE manufacturer-data string. See Payload Definition below. | +| 6 | `gps_fix_age_ms` | Age of the receiver GPS fix in milliseconds when the row was written. If GPS becomes unavailable, the last known coordinates remain and this age grows. | +| 7 | `clock_valid` | `1` when the startup-disciplined clock is still inside the configured holdover window. | +| 8 | `gps_valid` | `1` when the receiver GPS fix age is inside the configured freshness window. | +| 9 | `discipline_age_ms` | Milliseconds since the startup GPS/PPS/RTC discipline event. | +| 10 | `last_discipline_epoch_ms` | Epoch milliseconds assigned to the PPS edge used for startup discipline. | +| 11 | `heard` | Remote unit heard in the BLE advertisement. | +| 12 | `rssi` | RSSI measured by the receiver for this advertisement. | +| 13 | `avg_rssi` | Receiver-calculated arithmetic mean of the most recent RSSI measurements for this heard unit, using up to the last 5 accepted advertisements and rounded to the nearest integer. The window size is `kRssiWindow = 5` in `main.cpp`. | +| 14 | `age_s` | Age in seconds of the displayed/heard entry. | +| 15 | `count` | Number of accepted advertisements from that heard unit. | +| 16 | `seq` | Sequence number advertised by the heard unit. | +| 17 | `tx_payload_epoch_ms` | Sender payload generation timestamp from the BLE payload as Unix epoch milliseconds, when available. Legacy v1 payloads report `0` here. | +| 18 | `payload` | Raw BLE manufacturer-data string. See Payload Definition below. | ## Payload definition `payload` is the exact BLE manufacturer-data string received from the other unit. Current firmware advertises a compact v2 text payload: ```text -B2|NODE|SEQ|TX_EPOCH_MS +B2|NODE|SEQ|TX_PAYLOAD_EPOCH_MS ``` Example: @@ -141,7 +145,7 @@ Meaning: | `B2` | `B2` | Compact Exercise 26 payload prefix and version. | | `NODE` | `FLO` | Sending unit name. Receiver accepts only known units and ignores itself. | | `SEQ` | `0611` | Sender’s advertisement sequence number, zero-padded, wraps every 10,000 advertisements. | -| `TX_EPOCH_MS` | `1779727662217` | Sender timestamp as Unix epoch milliseconds, derived from the disciplined local clock. | +| `TX_PAYLOAD_EPOCH_MS` | `1779727662217` | Sender payload generation timestamp as Unix epoch milliseconds, derived from the disciplined local clock. This is not a per-advertisement RF transmit timestamp. | The receiver also accepts the legacy v1 payload used by earlier Exercise 26 firmware: @@ -155,4 +159,14 @@ Constraints currently enforced by the receiver: - Prefix must be current `B2` or legacy `TBMSND` version `1`. - Node must be one of `AMY, BOB, CY, DAN, ED, FLO, GUY`. - Node must not be the receiver’s own name. -- Current `B2` payloads provide `SEQ` and `TX_EPOCH_MS`; legacy v1 payloads provide `SEQ` only and log `tx_epoch_ms` as `0`. +- Current `B2` payloads provide `SEQ` and `TX_PAYLOAD_EPOCH_MS`; legacy v1 payloads provide `SEQ` only and log `tx_payload_epoch_ms` as `0`. + +## Clock Discipline and Timestamp Semantics + +BLE survey advertising starts only after startup GPS UTC, GPS coordinates, and PPS-backed RTC discipline have succeeded. Before that point the OLED remains on a blocking GPS/clock status page and the unit does not enter active BLE survey mode. + +Observation logging requires both `clock_valid=1` and `gps_valid=1`. The clock uses a holdover model: startup discipline sets `last_discipline_epoch_ms`, and `clock_valid` remains true until `discipline_age_ms` exceeds the configured holdover limit. Temporary GPS loss does not immediately invalidate the clock. GPS validity is tracked separately with `gps_fix_age_ms`; if the fix becomes stale, observation rows are suppressed until GPS freshness returns. + +`rx_epoch_ms` is the receiver's local disciplined-clock time at the moment it writes the observation row. `tx_payload_epoch_ms` is the sender's local disciplined-clock time when it generated and installed that BLE advertising payload. A single payload may be observed many times before the sender replaces it, so `tx_payload_epoch_ms` is useful for correlating logs but is not an RF transmit timestamp. + +`rx_epoch_ms - tx_payload_epoch_ms` is diagnostic for clock alignment, scheduling, payload age, and repeated-observation behavior. It is not BLE propagation delay. The status fields that prove a row was written under valid survey conditions are `clock_valid`, `gps_valid`, `gps_fix_age_ms`, `discipline_age_ms`, and `last_discipline_epoch_ms`. diff --git a/exercises/26_Bluetooth_discover/scripts/create_exercise_26_ble_schema.sql b/exercises/26_Bluetooth_discover/scripts/create_exercise_26_ble_schema.sql index 4d5b544..717441f 100644 --- a/exercises/26_Bluetooth_discover/scripts/create_exercise_26_ble_schema.sql +++ b/exercises/26_Bluetooth_discover/scripts/create_exercise_26_ble_schema.sql @@ -376,4 +376,38 @@ WHERE tx_epoch_ms IS NOT NULL AND tx_epoch_ms > 0 GROUP BY trial_id, receiver, heard; +-- +-- Additiona views created after initial import of 1st test set +-- and anomolies seen +-- +CREATE VIEW IF NOT EXISTS v_rx_tx_delta_by_seq AS +SELECT + trial_id, + receiver, + heard, + payload_seq, + COUNT(*) AS observations, + MIN(rx_tx_delta_ms) AS min_delta_ms, + AVG(rx_tx_delta_ms) AS avg_delta_ms, + MAX(rx_tx_delta_ms) AS max_delta_ms, + MIN(rx_epoch_ms) AS first_rx_epoch_ms, + MAX(rx_epoch_ms) AS last_rx_epoch_ms +FROM ble_observation +WHERE tx_epoch_ms IS NOT NULL + AND tx_epoch_ms > 0 +GROUP BY trial_id, receiver, heard, payload_seq; + +CREATE VIEW IF NOT EXISTS v_payload_seq_summary AS +SELECT + trial_id, + receiver, + heard, + MIN(payload_seq) AS min_payload_seq, + MAX(payload_seq) AS max_payload_seq, + COUNT(DISTINCT payload_seq) AS distinct_payload_seq, + COUNT(*) AS observations, + COUNT(*) - COUNT(DISTINCT payload_seq) AS repeated_seq_observations +FROM ble_observation +GROUP BY trial_id, receiver, heard; + COMMIT; diff --git a/exercises/26_Bluetooth_discover/scripts/import_exercise_26_ble_log.pl b/exercises/26_Bluetooth_discover/scripts/import_exercise_26_ble_log.pl old mode 100644 new mode 100755 index b6a9d7e..7d0feea --- a/exercises/26_Bluetooth_discover/scripts/import_exercise_26_ble_log.pl +++ b/exercises/26_Bluetooth_discover/scripts/import_exercise_26_ble_log.pl @@ -4,16 +4,19 @@ # # Example: # -# sqlite3 ble_fieldtest_20260525_1945_ed_flo.sqlite < create_exercise_26_ble_schema.sql -# -# ./import_exercise_26_ble_log.pl \ -# --db ble_fieldtest_20260525_1945_ed_flo.sqlite \ -# --log "$FLO_LOG" -# -# ./import_exercise_26_ble_log.pl \ -# --db ble_fieldtest_20260525_1945_ed_flo.sqlite \ -# --log "$ED_LOG" -# + +=pod + sqlite3 ble_fieldtest_20260525_1945_ed_flo.sqlite < create_exercise_26_ble_schema.sql + + ./import_exercise_26_ble_log.pl \ + --db ble_fieldtest_20260525_1945_ed_flo.sqlite \ + --log "$FLO_LOG" + + ./import_exercise_26_ble_log.pl \ + --db ble_fieldtest_20260525_1945_ed_flo.sqlite \ + --log "$ED_LOG" +=cut + # Manifest: # # The importer expects a manifest file with the same name as the log plus @@ -35,6 +38,24 @@ # receiver_notes=FLO was hand-carried north and across the street, then returned to base. # test_notes=Two-unit BLE discovery test. ED stationary, FLO moving. # + +=pod +An after import smoke test: + +sqlite3 -header -column ble_fieldtest_20260525_1945_ed_flo.sqlite \ + 'SELECT * FROM v_log_file_summary;' + +sqlite3 -header -column ble_fieldtest_20260525_1945_ed_flo.sqlite \ + 'SELECT * FROM v_receiver_gps_summary;' + +sqlite3 -header -column ble_fieldtest_20260525_1945_ed_flo.sqlite \ + 'SELECT * FROM v_link_rssi_summary;' + +sqlite3 -header -column ble_fieldtest_20260525_1945_ed_flo.sqlite \ + 'SELECT * FROM v_rx_tx_timing_summary;' + +=cut + # Purpose: # # Import Exercise 26 BLE Discovery logs into SQLite. diff --git a/exercises/26_Bluetooth_discover/src/main.cpp b/exercises/26_Bluetooth_discover/src/main.cpp index 04bc8dd..7e518d5 100644 --- a/exercises/26_Bluetooth_discover/src/main.cpp +++ b/exercises/26_Bluetooth_discover/src/main.cpp @@ -74,6 +74,8 @@ static constexpr uint32_t kScanWindowSeconds = 2; static constexpr uint32_t kLogFlushPeriodMs = 5000; static constexpr uint32_t kStartupStatusPeriodMs = 1000; static constexpr uint32_t kGpsRefreshPeriodMs = 1000; +static constexpr uint32_t kMaxGpsFixAgeMs = 5000; +static constexpr uint32_t kMaxDisciplineAgeMs = 4UL * 60UL * 60UL * 1000UL; static constexpr uint8_t kRssiWindow = 5; struct NodeState { @@ -89,7 +91,7 @@ struct NodeState { uint32_t seenCount = 0; uint32_t lastSeenMs = 0; uint32_t lastSeq = 0; - int64_t lastTxEpochMs = 0; + int64_t lastTxPayloadEpochMs = 0; }; XPowersLibInterface* g_pmu = nullptr; @@ -111,6 +113,7 @@ NodeState g_nodes[] = { }; bool g_disciplined = false; +bool g_clockValid = false; bool g_bleStarted = false; bool g_sdReady = false; bool g_logReady = false; @@ -127,6 +130,8 @@ uint32_t g_lastFlushMs = 0; uint32_t g_lastGpsRefreshMs = 0; int64_t g_epochBase = 0; uint32_t g_epochBaseMs = 0; +int64_t g_lastDisciplineEpochMs = 0; +uint32_t g_lastDisciplineMillis = 0; double g_latitude = 0.0; double g_longitude = 0.0; uint32_t g_gpsFixMs = 0; @@ -183,7 +188,7 @@ int64_t currentEpoch() { } int64_t currentEpochMs() { - if (!g_disciplined || g_epochBase <= 0) { + if (!g_clockValid || g_epochBase <= 0) { return 0; } return (g_epochBase * 1000LL) + (int64_t)(millis() - g_epochBaseMs); @@ -196,6 +201,26 @@ uint32_t gpsAgeMs(uint32_t now) { return now - g_gpsFixMs; } +uint32_t disciplineAgeMs(uint32_t now) { + if (!g_clockValid || g_lastDisciplineMillis == 0) { + return UINT32_MAX; + } + return now - g_lastDisciplineMillis; +} + +bool clockValidNow(uint32_t now) { + g_clockValid = g_disciplined && g_lastDisciplineMillis > 0 && (uint32_t)(now - g_lastDisciplineMillis) <= kMaxDisciplineAgeMs; + return g_clockValid; +} + +bool gpsValidNow(uint32_t now) { + return g_hasLocation && gpsAgeMs(now) <= kMaxGpsFixAgeMs; +} + +bool fieldDataValid(uint32_t now) { + return clockValidNow(now) && gpsValidNow(now); +} + void refreshGpsPosition(bool force = false) { const uint32_t now = millis(); if (!force && (uint32_t)(now - g_lastGpsRefreshMs) < kGpsRefreshPeriodMs) { @@ -307,6 +332,10 @@ void printBootBanner() { (unsigned long)kScanWindowSeconds, (unsigned long)kScanPeriodMs); Serial.printf("advertise_period_ms=%lu\n", (unsigned long)kAdvertisePeriodMs); + Serial.printf("clock_disciplined=%u time_source=GPS_PPS_RTC_REQUIRED\n", g_clockValid ? 1U : 0U); + Serial.printf("max_gps_fix_age_ms=%lu max_discipline_age_ms=%lu\n", + (unsigned long)kMaxGpsFixAgeMs, + (unsigned long)kMaxDisciplineAgeMs); Serial.printf("web_url=http://192.168.%u.1/\n", (unsigned)LOG_AP_IP_OCTET); } @@ -348,6 +377,9 @@ bool disciplineStartupClock() { if (g_clock.disciplineFromGnss(sample, waitForPps, nullptr, disciplined, hadPriorRtc, driftSeconds)) { g_epochBase = ClockDiscipline::toEpochSeconds(disciplined); g_epochBaseMs = g_lastPpsMs > 0 ? g_lastPpsMs : millis(); + g_clockValid = true; + g_lastDisciplineMillis = g_epochBaseMs; + g_lastDisciplineEpochMs = currentEpochMs(); g_latitude = sample.latitude; g_longitude = sample.longitude; g_gpsFixMs = sample.sampleMillis; @@ -355,11 +387,12 @@ bool disciplineStartupClock() { g_lastGpsRefreshMs = millis(); char iso[32]; ClockDiscipline::formatIsoUtc(disciplined, iso, sizeof(iso)); - Serial.printf("clock_disciplined node=%s utc=%s prior_rtc=%u drift_s=%lld lat=%.7f lon=%.7f\n", + Serial.printf("clock_disciplined node=%s utc=%s prior_rtc=%u drift_s=%lld last_discipline_epoch_ms=%lld lat=%.7f lon=%.7f\n", NODE_NAME, iso, hadPriorRtc ? 1U : 0U, (long long)driftSeconds, + (long long)g_lastDisciplineEpochMs, g_latitude, g_longitude); return true; @@ -372,7 +405,7 @@ bool disciplineStartupClock() { } bool openDatedLog() { - if (!g_disciplined || !g_storage.ready()) { + if (!fieldDataValid(millis()) || !g_storage.ready()) { return false; } char stamp[24]; @@ -386,7 +419,7 @@ bool openDatedLog() { Serial.printf("sd_log_open_failed path=%s err=%s\n", g_logPath, g_storage.lastError()); return false; } - g_storage.println("human_time,rx_epoch_ms,receiver,rx_lat,rx_lon,rx_gps_age_ms,heard,rssi,avg_rssi,age_s,count,seq,tx_epoch_ms,payload"); + g_storage.println("human_time,rx_epoch_ms,receiver,rx_lat,rx_lon,gps_fix_age_ms,clock_valid,gps_valid,discipline_age_ms,last_discipline_epoch_ms,heard,rssi,avg_rssi,age_s,count,seq,tx_payload_epoch_ms,payload"); g_storage.flush(); Serial.printf("sd_log_open path=%s\n", g_logPath); g_logReady = true; @@ -448,14 +481,14 @@ void updateAdvertisement() { g_lastAdvertiseMs = millis(); } -bool parsePayload(const std::string& data, char* outName, size_t outNameSize, uint32_t& outSeq, int64_t& outTxEpochMs, char* outPayload, size_t outPayloadSize) { +bool parsePayload(const std::string& data, char* outName, size_t outNameSize, uint32_t& outSeq, int64_t& outTxPayloadEpochMs, char* outPayload, size_t outPayloadSize) { if (outNameSize == 0 || outPayloadSize == 0) { return false; } outName[0] = '\0'; outPayload[0] = '\0'; outSeq = 0; - outTxEpochMs = 0; + outTxPayloadEpochMs = 0; if (data.empty() || data.size() >= outPayloadSize) { return false; } @@ -494,22 +527,41 @@ bool parsePayload(const std::string& data, char* outName, size_t outNameSize, ui strlcpy(outName, name, outNameSize); outSeq = (uint32_t)strtoul(seq, nullptr, 10); if (txEpochMs && txEpochMs[0]) { - outTxEpochMs = (int64_t)strtoll(txEpochMs, nullptr, 10); + outTxPayloadEpochMs = (int64_t)strtoll(txEpochMs, nullptr, 10); } return true; } void logAcceptedAdvertisement(const NodeState& node, const char* payload) { + const uint32_t now = millis(); + const bool clockValid = clockValidNow(now); + const uint32_t fixAge = gpsAgeMs(now); + const bool gpsValid = gpsValidNow(now); + const uint32_t discAge = disciplineAgeMs(now); + if (!clockValid || !gpsValid) { + static uint32_t lastSuppressedMs = 0; + if ((uint32_t)(now - lastSuppressedMs) >= 1000U) { + lastSuppressedMs = now; + Serial.printf("observation_suppressed node=%s heard=%s clock_valid=%u gps_valid=%u gps_fix_age_ms=%lu discipline_age_ms=%lu\n", + NODE_NAME, + node.name, + clockValid ? 1U : 0U, + gpsValid ? 1U : 0U, + (unsigned long)fixAge, + (unsigned long)discAge); + } + return; + } + const int avg = averageRssi(node); - const uint32_t age = ageSeconds(node, millis()); + const uint32_t age = ageSeconds(node, now); const int64_t epochMs = currentEpochMs(); const int64_t epoch = epochMs / 1000LL; - const uint32_t fixAge = gpsAgeMs(millis()); char human[32]; formatDateTime(epoch, human, sizeof(human)); Serial.printf("%lu,%s,%s,%d,%d,%lu,%lu,%lu,%lld,%s\n", - (unsigned long)millis(), + (unsigned long)now, NODE_NAME, node.name, node.lastRssi, @@ -517,33 +569,37 @@ void logAcceptedAdvertisement(const NodeState& node, const char* payload) { (unsigned long)age, (unsigned long)node.seenCount, (unsigned long)node.lastSeq, - (long long)node.lastTxEpochMs, + (long long)node.lastTxPayloadEpochMs, payload); if (g_logReady && g_storage.isLogOpen()) { - char line[256]; + char line[320]; snprintf(line, sizeof(line), - "%s,%lld,%s,%.7f,%.7f,%lu,%s,%d,%d,%lu,%lu,%lu,%lld,%s", + "%s,%lld,%s,%.7f,%.7f,%lu,%u,%u,%lu,%lld,%s,%d,%d,%lu,%lu,%lu,%lld,%s", human, (long long)epochMs, NODE_NAME, g_latitude, g_longitude, (unsigned long)fixAge, + clockValid ? 1U : 0U, + gpsValid ? 1U : 0U, + (unsigned long)discAge, + (long long)g_lastDisciplineEpochMs, node.name, node.lastRssi, avg, (unsigned long)age, (unsigned long)node.seenCount, (unsigned long)node.lastSeq, - (long long)node.lastTxEpochMs, + (long long)node.lastTxPayloadEpochMs, payload); g_storage.println(line); } } -void acceptAdvertisement(const char* name, int rssi, uint32_t seq, int64_t txEpochMs, const char* payload) { +void acceptAdvertisement(const char* name, int rssi, uint32_t seq, int64_t txPayloadEpochMs, const char* payload) { const int idx = nodeIndexFor(name); if (idx < 0) { return; @@ -559,7 +615,7 @@ void acceptAdvertisement(const char* name, int rssi, uint32_t seq, int64_t txEpo ++node.seenCount; node.lastSeenMs = millis(); node.lastSeq = seq; - node.lastTxEpochMs = txEpochMs; + node.lastTxPayloadEpochMs = txPayloadEpochMs; logAcceptedAdvertisement(node, payload); } @@ -572,11 +628,11 @@ class DiscoveryAdvertisedCallbacks : public BLEAdvertisedDeviceCallbacks { char name[8]; char payload[48]; uint32_t seq = 0; - int64_t txEpochMs = 0; - if (!parsePayload(advertisedDevice.getManufacturerData(), name, sizeof(name), seq, txEpochMs, payload, sizeof(payload))) { + int64_t txPayloadEpochMs = 0; + if (!parsePayload(advertisedDevice.getManufacturerData(), name, sizeof(name), seq, txPayloadEpochMs, payload, sizeof(payload))) { return; } - acceptAdvertisement(name, advertisedDevice.getRSSI(), seq, txEpochMs, payload); + acceptAdvertisement(name, advertisedDevice.getRSSI(), seq, txPayloadEpochMs, payload); } }; @@ -594,6 +650,10 @@ void startBle() { void pollBle() { const uint32_t now = millis(); + if (!clockValidNow(now)) { + BLEDevice::getAdvertising()->stop(); + return; + } if ((uint32_t)(now - g_lastAdvertiseMs) >= kAdvertisePeriodMs) { updateAdvertisement(); } @@ -623,6 +683,20 @@ void renderDisplay() { char title[32]; makeTitle(title, sizeof(title)); + const bool clockValid = clockValidNow(now); + const uint32_t fixAge = gpsAgeMs(now); + const bool gpsValid = gpsValidNow(now); + + if (!clockValid) { + showLines(title, "CLOCK STALE", "BLE survey paused", "logging paused"); + return; + } + if (!gpsValid) { + char ageLine[32]; + snprintf(ageLine, sizeof(ageLine), "gps age:%lus", fixAge == UINT32_MAX ? 9999UL : (unsigned long)(fixAge / 1000UL)); + showLines(title, "GPS STALE", ageLine, "logging paused"); + return; + } if (g_displayMode == 1) { char rows[5][32] = {}; @@ -658,7 +732,7 @@ void renderDisplay() { char l3[32]; char l4[32]; snprintf(l2, sizeof(l2), "fresh:%lu total:%lu", (unsigned long)freshTotal, (unsigned long)heardTotal); - snprintf(l3, sizeof(l3), "sd:%s web:%s log:%s", g_sdReady ? "Y" : "N", g_webReady ? "Y" : "N", g_logReady ? "Y" : "N"); + snprintf(l3, sizeof(l3), "clk:%u gps:%u log:%u", clockValid ? 1U : 0U, gpsValid ? 1U : 0U, g_logReady ? 1U : 0U); snprintf(l4, sizeof(l4), "log:%s", g_logPath[0] ? g_logPath + 6 : "none"); showLines(title, "DIAG", l2, l3, l4); return; @@ -704,7 +778,7 @@ void pollStorageWeb() { g_sdReady = true; Serial.println("sd_mounted"); } - if (g_disciplined && g_storage.ready() && !g_logReady) { + if (fieldDataValid(millis()) && g_storage.ready() && !g_logReady) { g_sdReady = true; g_logReady = openDatedLog(); } From de2262f747cc0cef9d826fd8d656a5e5111f3050 Mon Sep 17 00:00:00 2001 From: John Poole Date: Tue, 26 May 2026 07:34:11 -0700 Subject: [PATCH 5/9] Preserving for posterity before update to 18 column --- .../scripts/create_exercise_26_ble_schema.sql | 28 ++++++++++++++++++ .../scripts/import_exercise_26_ble_log.pl | 29 +++++++++---------- 2 files changed, 42 insertions(+), 15 deletions(-) diff --git a/exercises/26_Bluetooth_discover/scripts/create_exercise_26_ble_schema.sql b/exercises/26_Bluetooth_discover/scripts/create_exercise_26_ble_schema.sql index 717441f..a9d1663 100644 --- a/exercises/26_Bluetooth_discover/scripts/create_exercise_26_ble_schema.sql +++ b/exercises/26_Bluetooth_discover/scripts/create_exercise_26_ble_schema.sql @@ -410,4 +410,32 @@ SELECT FROM ble_observation GROUP BY trial_id, receiver, heard; +-- +-- for mapping +-- +CREATE VIEW IF NOT EXISTS v_map_observation_points AS +SELECT + obs_id, + trial_id, + receiver, + heard, + rx_epoch_ms, + rx_epoch_s, + rx_lat, + rx_lon, + rx_gps_age_ms, + rssi, + avg_rssi, + payload_seq, + tx_epoch_ms, + rx_tx_delta_ms +FROM ble_observation +WHERE rx_lat IS NOT NULL + AND rx_lon IS NOT NULL; + +CREATE VIEW IF NOT EXISTS v_map_good_gps_points AS +SELECT * +FROM v_map_observation_points +WHERE rx_gps_age_ms <= 5000; + COMMIT; diff --git a/exercises/26_Bluetooth_discover/scripts/import_exercise_26_ble_log.pl b/exercises/26_Bluetooth_discover/scripts/import_exercise_26_ble_log.pl index 7d0feea..9cda509 100755 --- a/exercises/26_Bluetooth_discover/scripts/import_exercise_26_ble_log.pl +++ b/exercises/26_Bluetooth_discover/scripts/import_exercise_26_ble_log.pl @@ -24,22 +24,21 @@ # # Example: # flo/20260525_194537_FLO_ble_search.log.manifest -# -# Simple key=value format: -# -# trial_name=Peck Cottage ED FLO BLE walk test 20260525_1945 -# trial_label=peck_cottage_ed_flo_20260525_1945 -# firmware_exercise=26_Bluetooth_discover -# firmware_git_commit=unknown -# field_site=Peck Cottage steps -# operator=jlpoole -# receiver_role=moving -# receiver_start_description=upside-down plastic pot at Peck Cottage steps -# receiver_notes=FLO was hand-carried north and across the street, then returned to base. -# test_notes=Two-unit BLE discovery test. ED stationary, FLO moving. -# - =pod + Simple key=value format: + + trial_name=Peck Cottage ED FLO BLE walk test 20260525_1945 + trial_label=peck_cottage_ed_flo_20260525_1945 + firmware_exercise=26_Bluetooth_discover + firmware_git_commit=unknown + field_site=Peck Cottage steps + operator=jlpoole + receiver_role=moving + receiver_start_description=upside-down plastic pot at Peck Cottage steps + receiver_notes=FLO was hand-carried north and across the street, then returned to base. + test_notes=Two-unit BLE discovery test. ED stationary, FLO moving. + + An after import smoke test: sqlite3 -header -column ble_fieldtest_20260525_1945_ed_flo.sqlite \ From a982223055f6fe630cfb49a3400de1ceea28591f Mon Sep 17 00:00:00 2001 From: John Poole Date: Tue, 26 May 2026 07:36:04 -0700 Subject: [PATCH 6/9] Picking up changes I had made before migrating to 18 column --- .../scripts/import_exercise_26_ble_log.pl | 9 +++++++++ 1 file changed, 9 insertions(+) mode change 100755 => 100644 exercises/26_Bluetooth_discover/scripts/import_exercise_26_ble_log.pl diff --git a/exercises/26_Bluetooth_discover/scripts/import_exercise_26_ble_log.pl b/exercises/26_Bluetooth_discover/scripts/import_exercise_26_ble_log.pl old mode 100755 new mode 100644 index 9cda509..5cd2d7e --- a/exercises/26_Bluetooth_discover/scripts/import_exercise_26_ble_log.pl +++ b/exercises/26_Bluetooth_discover/scripts/import_exercise_26_ble_log.pl @@ -15,6 +15,15 @@ ./import_exercise_26_ble_log.pl \ --db ble_fieldtest_20260525_1945_ed_flo.sqlite \ --log "$ED_LOG" + + sqlite3 ble_fieldtest_20260526_0334_bob_cy_ed_flo.sqlite < create_exercise_26_ble_schema.sql + export DB_BLE=ble_fieldtest_20260526_0334_bob_cy_ed_flo.sqlite + + export BOB_LOG=/home/jlpoole/work/tbeam/logs/bob/20260526_033431_BOB_ble_search.log + ./import_exercise_26_ble_log.pl \ + --db $DB_BLE \ + --log "$BOB_LOG" + =cut # Manifest: From a8fc2cf9d5234afe7f3f5c3a593ded0aaf0eef37 Mon Sep 17 00:00:00 2001 From: John Poole Date: Wed, 27 May 2026 11:09:24 -0700 Subject: [PATCH 7/9] Preserving an earlier version of the schema along with other files --- exercises/26_Bluetooth_discover/README.md | 27 +- .../scripts/create_exercise_26_ble_schema.sql | 70 ++-- .../scripts/import_exercise_26_ble_log.pl | 300 +++++++++++------- exercises/26_Bluetooth_discover/src/main.cpp | 215 ++++++++++--- 4 files changed, 443 insertions(+), 169 deletions(-) mode change 100644 => 100755 exercises/26_Bluetooth_discover/scripts/import_exercise_26_ble_log.pl diff --git a/exercises/26_Bluetooth_discover/README.md b/exercises/26_Bluetooth_discover/README.md index 1d25217..7ac5993 100644 --- a/exercises/26_Bluetooth_discover/README.md +++ b/exercises/26_Bluetooth_discover/README.md @@ -31,7 +31,8 @@ pio device monitor -b 115200 -p /dev/ttytBOB - Displays heard nodes sorted by rolling RSSI average, strongest first. - Drops stale entries after 20 seconds. - Logs valid accepted advertisements to SD as `/logs/YYYYMMDD_HHMISS_NODE_ble_search.log`. -- Refreshes the receiver GPS position at 1 Hz and logs the last known receiver coordinates plus GPS fix age. Observation rows are written only while the holdover clock and GPS fix age are both valid. +- Refreshes the receiver GPS position at 1 Hz and logs the last known receiver coordinates plus GPS fix age. Observation rows are written while the holdover clock is valid; stale GPS is recorded with `gps_valid=0` instead of suppressing the row. +- Writes periodic `# STATUS` heartbeat lines so a quiet BLE interval can be distinguished from a reset, hang, SD fault, or power interruption. - Starts a WiFi AP and HTTP web service during boot. This does not wait for GPS. Default web addresses: @@ -85,7 +86,7 @@ jlpoole@jp ~/work/tbeam/logs $ ls -lath flo/*_16* jlpoole@jp ~/work/tbeam/logs $ ``` -Here are start and end samples from an earlier trial. These show the previous 12-column schema; current firmware uses the 18-column schema described below. +Here are start and end samples from an earlier trial. These show the previous 12-column schema; current firmware uses the 19-column schema described below. ```bash jlpoole@jp ~/work/tbeam/logs $ cat -n ed/20260525_162217_ED_ble_search.log|head -n 3 1 human_time,epoch_ms,receiver,lat,lon,heard,rssi,avg_rssi,age_s,count,seq,payload @@ -123,6 +124,7 @@ The header represents: | 16 | `seq` | Sequence number advertised by the heard unit. | | 17 | `tx_payload_epoch_ms` | Sender payload generation timestamp from the BLE payload as Unix epoch milliseconds, when available. Legacy v1 payloads report `0` here. | | 18 | `payload` | Raw BLE manufacturer-data string. See Payload Definition below. | +| 19 | `vbat_mv` | Battery voltage reported by the PMU, in millivolts. `-1` means the PMU value was unavailable. | ## Payload definition @@ -165,8 +167,27 @@ Constraints currently enforced by the receiver: BLE survey advertising starts only after startup GPS UTC, GPS coordinates, and PPS-backed RTC discipline have succeeded. Before that point the OLED remains on a blocking GPS/clock status page and the unit does not enter active BLE survey mode. -Observation logging requires both `clock_valid=1` and `gps_valid=1`. The clock uses a holdover model: startup discipline sets `last_discipline_epoch_ms`, and `clock_valid` remains true until `discipline_age_ms` exceeds the configured holdover limit. Temporary GPS loss does not immediately invalidate the clock. GPS validity is tracked separately with `gps_fix_age_ms`; if the fix becomes stale, observation rows are suppressed until GPS freshness returns. +Observation logging requires `clock_valid=1`. The clock uses a holdover model: startup discipline sets `last_discipline_epoch_ms`, and `clock_valid` remains true until `discipline_age_ms` exceeds the configured holdover limit. Temporary GPS loss does not immediately invalidate the clock, and it does not suppress observation rows. GPS validity is tracked separately with `gps_fix_age_ms` and `gps_valid`, so stale-coordinate rows can be filtered later. `rx_epoch_ms` is the receiver's local disciplined-clock time at the moment it writes the observation row. `tx_payload_epoch_ms` is the sender's local disciplined-clock time when it generated and installed that BLE advertising payload. A single payload may be observed many times before the sender replaces it, so `tx_payload_epoch_ms` is useful for correlating logs but is not an RF transmit timestamp. `rx_epoch_ms - tx_payload_epoch_ms` is diagnostic for clock alignment, scheduling, payload age, and repeated-observation behavior. It is not BLE propagation delay. The status fields that prove a row was written under valid survey conditions are `clock_valid`, `gps_valid`, `gps_fix_age_ms`, `discipline_age_ms`, and `last_discipline_epoch_ms`. + +## Event Lines + +Comment-style event lines begin with `# EVENT` and are not normal CSV observation rows. They preserve diagnostic transitions without complicating the ordinary CSV row format. Current event names are: + +- `boot` +- `log_opened` +- `log_closed` +- `gps_stale` +- `gps_fresh` +- `clock_stale` +- `sd_removed` +- `sd_remounted` +- `sd_write_short` +- `sd_write_ok` + +## Status Lines + +Comment-style status lines begin with `# STATUS` and are written about every 10 seconds while the log is open. They are not normal CSV observation rows. They report the unit's clock/GPS state, SD/log state, rows written, heard-node counts, and `vbat_mv`. These heartbeat lines are intended to show whether the firmware was still alive during periods with no BLE observations. diff --git a/exercises/26_Bluetooth_discover/scripts/create_exercise_26_ble_schema.sql b/exercises/26_Bluetooth_discover/scripts/create_exercise_26_ble_schema.sql index a9d1663..b8cb351 100644 --- a/exercises/26_Bluetooth_discover/scripts/create_exercise_26_ble_schema.sql +++ b/exercises/26_Bluetooth_discover/scripts/create_exercise_26_ble_schema.sql @@ -15,12 +15,13 @@ -- -- Current Exercise 26 log format: -- --- human_time,rx_epoch_ms,receiver,rx_lat,rx_lon,rx_gps_age_ms, --- heard,rssi,avg_rssi,age_s,count,seq,tx_epoch_ms,payload +-- human_time,rx_epoch_ms,receiver,rx_lat,rx_lon,gps_fix_age_ms, +-- clock_valid,gps_valid,discipline_age_ms,last_discipline_epoch_ms, +-- heard,rssi,avg_rssi,age_s,count,seq,tx_payload_epoch_ms,payload,vbat_mv -- -- Current payload format: -- --- B2|NODE|SEQ|TX_EPOCH_MS +-- B2|NODE|SEQ|TX_PAYLOAD_EPOCH_MS -- -- Legacy payload format: -- @@ -167,7 +168,11 @@ CREATE TABLE IF NOT EXISTS ble_observation_raw ( receiver TEXT, rx_lat REAL, rx_lon REAL, - rx_gps_age_ms INTEGER, + gps_fix_age_ms INTEGER, + clock_valid INTEGER, + gps_valid INTEGER, + discipline_age_ms INTEGER, + last_discipline_epoch_ms INTEGER, heard TEXT, rssi INTEGER, @@ -175,8 +180,9 @@ CREATE TABLE IF NOT EXISTS ble_observation_raw ( age_s INTEGER, count INTEGER, seq INTEGER, - tx_epoch_ms INTEGER, + tx_payload_epoch_ms INTEGER, payload TEXT, + vbat_mv INTEGER, UNIQUE(log_file_id, source_line_no) ); @@ -216,8 +222,8 @@ CREATE TABLE IF NOT EXISTS ble_observation ( rx_epoch_ms INTEGER NOT NULL, rx_epoch_s REAL NOT NULL, - tx_epoch_ms INTEGER, - tx_epoch_s REAL, + tx_payload_epoch_ms INTEGER, + tx_payload_epoch_s REAL, rx_tx_delta_ms INTEGER, @@ -226,7 +232,12 @@ CREATE TABLE IF NOT EXISTS ble_observation ( rx_lat REAL, rx_lon REAL, - rx_gps_age_ms INTEGER, + gps_fix_age_ms INTEGER, + clock_valid INTEGER, + gps_valid INTEGER, + discipline_age_ms INTEGER, + last_discipline_epoch_ms INTEGER, + vbat_mv INTEGER, rssi INTEGER, avg_rssi INTEGER, @@ -314,9 +325,9 @@ SELECT MAX(rx_lat) - MIN(rx_lat) AS lat_range_deg, MAX(rx_lon) - MIN(rx_lon) AS lon_range_deg, - MIN(rx_gps_age_ms) AS min_rx_gps_age_ms, - AVG(rx_gps_age_ms) AS avg_rx_gps_age_ms, - MAX(rx_gps_age_ms) AS max_rx_gps_age_ms + MIN(gps_fix_age_ms) AS min_gps_fix_age_ms, + AVG(gps_fix_age_ms) AS avg_gps_fix_age_ms, + MAX(gps_fix_age_ms) AS max_gps_fix_age_ms FROM ble_observation GROUP BY trial_id, log_file_id, receiver; @@ -372,8 +383,8 @@ SELECT AVG(rx_tx_delta_ms) AS avg_rx_tx_delta_ms, MAX(rx_tx_delta_ms) AS max_rx_tx_delta_ms FROM ble_observation -WHERE tx_epoch_ms IS NOT NULL - AND tx_epoch_ms > 0 +WHERE tx_payload_epoch_ms IS NOT NULL + AND tx_payload_epoch_ms > 0 GROUP BY trial_id, receiver, heard; -- @@ -393,8 +404,8 @@ SELECT MIN(rx_epoch_ms) AS first_rx_epoch_ms, MAX(rx_epoch_ms) AS last_rx_epoch_ms FROM ble_observation -WHERE tx_epoch_ms IS NOT NULL - AND tx_epoch_ms > 0 +WHERE tx_payload_epoch_ms IS NOT NULL + AND tx_payload_epoch_ms > 0 GROUP BY trial_id, receiver, heard, payload_seq; CREATE VIEW IF NOT EXISTS v_payload_seq_summary AS @@ -423,11 +434,11 @@ SELECT rx_epoch_s, rx_lat, rx_lon, - rx_gps_age_ms, + gps_fix_age_ms, rssi, avg_rssi, payload_seq, - tx_epoch_ms, + tx_payload_epoch_ms, rx_tx_delta_ms FROM ble_observation WHERE rx_lat IS NOT NULL @@ -436,6 +447,29 @@ WHERE rx_lat IS NOT NULL CREATE VIEW IF NOT EXISTS v_map_good_gps_points AS SELECT * FROM v_map_observation_points -WHERE rx_gps_age_ms <= 5000; +WHERE gps_fix_age_ms <= 5000; + + +-- --------------------------------------------------------------------------- +-- v_battery_summary +-- +-- Per receiver battery voltage summary for diagnosing power-related logging +-- interruptions. vbat_mv = -1 means the PMU value was unavailable. +-- --------------------------------------------------------------------------- + +CREATE VIEW IF NOT EXISTS v_battery_summary AS +SELECT + trial_id, + log_file_id, + receiver, + + COUNT(*) AS row_count, + SUM(CASE WHEN vbat_mv IS NOT NULL AND vbat_mv >= 0 THEN 1 ELSE 0 END) AS vbat_sample_count, + + MIN(CASE WHEN vbat_mv >= 0 THEN vbat_mv END) AS min_vbat_mv, + AVG(CASE WHEN vbat_mv >= 0 THEN vbat_mv END) AS avg_vbat_mv, + MAX(CASE WHEN vbat_mv >= 0 THEN vbat_mv END) AS max_vbat_mv +FROM ble_observation +GROUP BY trial_id, log_file_id, receiver; COMMIT; diff --git a/exercises/26_Bluetooth_discover/scripts/import_exercise_26_ble_log.pl b/exercises/26_Bluetooth_discover/scripts/import_exercise_26_ble_log.pl old mode 100644 new mode 100755 index 5cd2d7e..67cab96 --- a/exercises/26_Bluetooth_discover/scripts/import_exercise_26_ble_log.pl +++ b/exercises/26_Bluetooth_discover/scripts/import_exercise_26_ble_log.pl @@ -1,31 +1,29 @@ #!/usr/bin/env perl # $Id$ # $HeadURL$ -# -# Example: -# - =pod - sqlite3 ble_fieldtest_20260525_1945_ed_flo.sqlite < create_exercise_26_ble_schema.sql + Example: + + export DB_BLE=ble_fieldtest_20260526_1859.sqlite + sqlite3 ${DB_BLE} < create_exercise_26_ble_schema.sql + + export LOG_DIR=/home/jlpoole/work/tbeam/logs/20260526_1859 + export BOB_LOG=${LOG_DIR}/20260526_185847_BOB_ble_search.log + export CY_LOG=${LOG_DIR}/20260526_185846_CY_ble_search.log + export ED_LOG=${LOG_DIR}/20260526_185910_ED_ble_search.log + export FLO_LOG=${LOG_DIR}/20260526_185903_FLO_ble_search.log + # smoke test + date; awk -f exercise_26_smoke_test.awk ${BOB_LOG} + date; awk -f exercise_26_smoke_test.awk ${CY_LOG} + date; awk -f exercise_26_smoke_test.awk ${ED_LOG} + date; awk -f exercise_26_smoke_test.awk ${FLO_LOG} + ./import_exercise_26_ble_log.pl \ - --db ble_fieldtest_20260525_1945_ed_flo.sqlite \ - --log "$FLO_LOG" - - ./import_exercise_26_ble_log.pl \ - --db ble_fieldtest_20260525_1945_ed_flo.sqlite \ - --log "$ED_LOG" - - sqlite3 ble_fieldtest_20260526_0334_bob_cy_ed_flo.sqlite < create_exercise_26_ble_schema.sql - export DB_BLE=ble_fieldtest_20260526_0334_bob_cy_ed_flo.sqlite - - export BOB_LOG=/home/jlpoole/work/tbeam/logs/bob/20260526_033431_BOB_ble_search.log - ./import_exercise_26_ble_log.pl \ - --db $DB_BLE \ - --log "$BOB_LOG" + --db ${DB_BLE} \ + --log "${BOB_LOG}" =cut - # Manifest: # # The importer expects a manifest file with the same name as the log plus @@ -33,11 +31,13 @@ # # Example: # flo/20260525_194537_FLO_ble_search.log.manifest +# + =pod Simple key=value format: - trial_name=Peck Cottage ED FLO BLE walk test 20260525_1945 - trial_label=peck_cottage_ed_flo_20260525_1945 + trial_name=Peck Cottage four-unit BLE walk test 20260526_1430 + trial_label=peck_cottage_four_unit_20260526_1430 firmware_exercise=26_Bluetooth_discover firmware_git_commit=unknown field_site=Peck Cottage steps @@ -45,28 +45,12 @@ receiver_role=moving receiver_start_description=upside-down plastic pot at Peck Cottage steps receiver_notes=FLO was hand-carried north and across the street, then returned to base. - test_notes=Two-unit BLE discovery test. ED stationary, FLO moving. - - -An after import smoke test: - -sqlite3 -header -column ble_fieldtest_20260525_1945_ed_flo.sqlite \ - 'SELECT * FROM v_log_file_summary;' - -sqlite3 -header -column ble_fieldtest_20260525_1945_ed_flo.sqlite \ - 'SELECT * FROM v_receiver_gps_summary;' - -sqlite3 -header -column ble_fieldtest_20260525_1945_ed_flo.sqlite \ - 'SELECT * FROM v_link_rssi_summary;' - -sqlite3 -header -column ble_fieldtest_20260525_1945_ed_flo.sqlite \ - 'SELECT * FROM v_rx_tx_timing_summary;' - + test_notes=Four-unit BLE discovery test. ED/BOB stationary, FLO/CY moving. =cut # Purpose: # -# Import Exercise 26 BLE Discovery logs into SQLite. +# Import current 19-column Exercise 26 BLE Discovery logs into SQLite. # # Required Perl modules: # @@ -179,7 +163,7 @@ eval { insert_manifest_kv($dbh, $log_file_id, \%manifest); - import_log_rows( + my $imported_rows = import_log_rows( $dbh, trial_id => $trial_id, log_file_id => $log_file_id, @@ -197,7 +181,8 @@ eval { print " trial_id: $trial_id\n"; print " log_file_id: $log_file_id\n"; print " receiver: $receiver\n"; - print " rows: $log_stats->{source_row_count}\n"; + print " rows scanned: $log_stats->{source_row_count}\n"; + print " rows imported:$imported_rows\n"; print " sha256: $sha256\n"; }; @@ -217,12 +202,16 @@ Usage: $0 --db DATABASE.sqlite --log path/to/log.csv Example: - $0 --db ble_fieldtest_20260525_1945_ed_flo.sqlite --log "\$FLO_LOG" + $0 --db ble_fieldtest_YYYYMMDD_HHMM.sqlite --log "\$FLO_LOG" The importer expects a manifest beside the log: path/to/log.csv.manifest +This importer expects the current 19-column Exercise 26 header: + + human_time,rx_epoch_ms,receiver,rx_lat,rx_lon,gps_fix_age_ms,clock_valid,gps_valid,discipline_age_ms,last_discipline_epoch_ms,heard,rssi,avg_rssi,age_s,count,seq,tx_payload_epoch_ms,payload,vbat_mv + EOF } @@ -291,28 +280,26 @@ sub scan_log_for_stats { open my $fh, '<', $path or die "Could not open log $path: $!\n"; - my $header = <$fh>; - die "Empty log file: $path\n" unless defined $header; - - chomp $header; - $header =~ s/\r\z//; - - my @cols = split /,/, $header, -1; - validate_header(@cols); + my ($header, $header_line_no) = read_and_validate_log_header($fh, $path); my $row_count = 0; my $receiver; my $first_rx_epoch_ms; my $last_rx_epoch_ms; + my $line_no = $header_line_no; + while (my $line = <$fh>) { + $line_no++; chomp $line; $line =~ s/\r\z//; next if $line =~ /^\s*$/; + next if $line =~ /^\s*#/; + next if $line eq $header; my @f = split /,/, $line, -1; - next unless @f >= 14; + next unless @f == 19; $row_count++; @@ -337,6 +324,38 @@ sub scan_log_for_stats { }; } +sub read_and_validate_log_header { + my ($fh, $path) = @_; + + my $line_no = 0; + my $header; + + while (my $line = <$fh>) { + $line_no++; + chomp $line; + $line =~ s/\r\z//; + + next if $line =~ /^\s*$/; + + if ($line =~ /^\#\s*fields=(.*)\z/) { + $header = $1; + last; + } + + next if $line =~ /^\s*#/; + + $header = $line; + last; + } + + die "Could not find CSV header in log file: $path\n" + unless defined $header && $header ne ''; + + validate_header(split /,/, $header, -1); + + return ($header, $line_no); +} + sub validate_header { my (@cols) = @_; @@ -346,22 +365,27 @@ sub validate_header { receiver rx_lat rx_lon - rx_gps_age_ms + gps_fix_age_ms + clock_valid + gps_valid + discipline_age_ms + last_discipline_epoch_ms heard rssi avg_rssi age_s count seq - tx_epoch_ms + tx_payload_epoch_ms payload + vbat_mv ); - die "Unexpected column count in header. Expected 14, got " . scalar(@cols) . "\n" + die __LINE__." Unexpected column count in header. Expected 19, got " . scalar(@cols) . "\n" unless @cols == @expected; for my $i (0 .. $#expected) { - die "Unexpected header column " . ($i + 1) . ". Expected '$expected[$i]', got '$cols[$i]'\n" + die __LINE__." Unexpected header column " . ($i + 1) . ". Expected '$expected[$i]', got '$cols[$i]'\n" unless $cols[$i] eq $expected[$i]; } } @@ -485,12 +509,7 @@ sub import_log_rows { open my $fh, '<', $log_file or die "Could not open log $log_file: $!\n"; - my $header = <$fh>; - die "Empty log file: $log_file\n" unless defined $header; - - chomp $header; - $header =~ s/\r\z//; - validate_header(split /,/, $header, -1); + my ($header, $header_line_no) = read_and_validate_log_header($fh, $log_file); my $raw_sth = $dbh->prepare(q{ INSERT INTO ble_observation_raw ( @@ -502,17 +521,22 @@ sub import_log_rows { receiver, rx_lat, rx_lon, - rx_gps_age_ms, + gps_fix_age_ms, + clock_valid, + gps_valid, + discipline_age_ms, + last_discipline_epoch_ms, heard, rssi, avg_rssi, age_s, count, seq, - tx_epoch_ms, - payload + tx_payload_epoch_ms, + payload, + vbat_mv ) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) }); my $obs_sth = $dbh->prepare(q{ @@ -523,14 +547,19 @@ sub import_log_rows { source_line_no, rx_epoch_ms, rx_epoch_s, - tx_epoch_ms, - tx_epoch_s, + tx_payload_epoch_ms, + tx_payload_epoch_s, rx_tx_delta_ms, receiver, heard, rx_lat, rx_lon, - rx_gps_age_ms, + gps_fix_age_ms, + clock_valid, + gps_valid, + discipline_age_ms, + last_discipline_epoch_ms, + vbat_mv, rssi, avg_rssi, age_s, @@ -544,10 +573,10 @@ sub import_log_rows { payload_legacy_uptime, parse_warning ) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) }); - my $line_no = 1; + my $line_no = $header_line_no; my $data_rows = 0; while (my $line = <$fh>) { @@ -556,26 +585,33 @@ sub import_log_rows { $line =~ s/\r\z//; next if $line =~ /^\s*$/; + next if $line =~ /^\s*#/; + next if $line eq $header; my @f = split /,/, $line, -1; - die "Line $line_no has too few columns. Expected 14, got " . scalar(@f) . "\n" - unless @f >= 14; + die "Line $line_no has wrong column count. Expected 19, got " . scalar(@f) . "\n" + unless @f == 19; - my $human_time = $f[0]; - my $rx_epoch_ms = to_int_or_undef($f[1]); - my $receiver = $f[2]; - my $rx_lat = to_num_or_undef($f[3]); - my $rx_lon = to_num_or_undef($f[4]); - my $rx_gps_age_ms = to_int_or_undef($f[5]); - my $heard = $f[6]; - my $rssi = to_int_or_undef($f[7]); - my $avg_rssi = to_int_or_undef($f[8]); - my $age_s = to_int_or_undef($f[9]); - my $count = to_int_or_undef($f[10]); - my $seq = to_int_or_undef($f[11]); - my $tx_epoch_ms = to_int_or_undef($f[12]); - my $payload = $f[13]; + my $human_time = $f[0]; + my $rx_epoch_ms = to_int_or_undef($f[1]); + my $receiver = $f[2]; + my $rx_lat = to_num_or_undef($f[3]); + my $rx_lon = to_num_or_undef($f[4]); + my $gps_fix_age_ms = to_int_or_undef($f[5]); + my $clock_valid = to_int_or_undef($f[6]); + my $gps_valid = to_int_or_undef($f[7]); + my $discipline_age_ms = to_int_or_undef($f[8]); + my $last_discipline_epoch_ms = to_int_or_undef($f[9]); + my $heard = $f[10]; + my $rssi = to_int_or_undef($f[11]); + my $avg_rssi = to_int_or_undef($f[12]); + my $age_s = to_int_or_undef($f[13]); + my $count = to_int_or_undef($f[14]); + my $seq = to_int_or_undef($f[15]); + my $tx_payload_epoch_ms = to_int_or_undef($f[16]); + my $payload = $f[17]; + my $vbat_mv = to_int_or_undef($f[18]); die "Line $line_no has no rx_epoch_ms\n" unless defined $rx_epoch_ms; @@ -586,6 +622,12 @@ sub import_log_rows { die "Line $line_no has no heard unit\n" unless defined $heard && $heard ne ''; + die "Line $line_no has invalid clock_valid value\n" + unless defined $clock_valid && ($clock_valid == 0 || $clock_valid == 1); + + die "Line $line_no has invalid gps_valid value\n" + unless defined $gps_valid && ($gps_valid == 0 || $gps_valid == 1); + upsert_unit($dbh, $receiver); upsert_unit($dbh, $heard); @@ -598,15 +640,20 @@ sub import_log_rows { $receiver, $rx_lat, $rx_lon, - $rx_gps_age_ms, + $gps_fix_age_ms, + $clock_valid, + $gps_valid, + $discipline_age_ms, + $last_discipline_epoch_ms, $heard, $rssi, $avg_rssi, $age_s, $count, $seq, - $tx_epoch_ms, + $tx_payload_epoch_ms, $payload, + $vbat_mv, ); my $raw_id = $dbh->sqlite_last_insert_rowid; @@ -615,12 +662,31 @@ sub import_log_rows { my $rx_epoch_s = $rx_epoch_ms / 1000.0; - my $tx_epoch_s; - my $rx_tx_delta_ms; + my $tx_payload_epoch_s; + my $rx_tx_payload_delta_ms; - if (defined $tx_epoch_ms && $tx_epoch_ms > 0) { - $tx_epoch_s = $tx_epoch_ms / 1000.0; - $rx_tx_delta_ms = $rx_epoch_ms - $tx_epoch_ms; + if (defined $tx_payload_epoch_ms && $tx_payload_epoch_ms > 0) { + $tx_payload_epoch_s = $tx_payload_epoch_ms / 1000.0; + $rx_tx_payload_delta_ms = $rx_epoch_ms - $tx_payload_epoch_ms; + } + + if (defined $parsed->{payload_node} + && $parsed->{payload_node} ne '' + && $parsed->{payload_node} ne $heard) { + $parsed->{parse_warning} = append_warning( + $parsed->{parse_warning}, + "payload node '$parsed->{payload_node}' does not match heard '$heard'" + ); + } + + if (defined $parsed->{payload_tx_payload_epoch_ms} + && defined $tx_payload_epoch_ms + && $parsed->{payload_tx_payload_epoch_ms} != $tx_payload_epoch_ms + && $tx_payload_epoch_ms != 0) { + $parsed->{parse_warning} = append_warning( + $parsed->{parse_warning}, + "payload epoch does not match tx_payload_epoch_ms column" + ); } $obs_sth->execute( @@ -630,14 +696,19 @@ sub import_log_rows { $line_no, $rx_epoch_ms, $rx_epoch_s, - $tx_epoch_ms, - $tx_epoch_s, - $rx_tx_delta_ms, + $tx_payload_epoch_ms, + $tx_payload_epoch_s, + $rx_tx_payload_delta_ms, $receiver, $heard, $rx_lat, $rx_lon, - $rx_gps_age_ms, + $gps_fix_age_ms, + $clock_valid, + $gps_valid, + $discipline_age_ms, + $last_discipline_epoch_ms, + $vbat_mv, $rssi, $avg_rssi, $age_s, @@ -647,7 +718,7 @@ sub import_log_rows { $parsed->{payload_kind}, $parsed->{payload_node}, $parsed->{payload_seq}, - $parsed->{payload_tx_epoch_ms}, + $parsed->{payload_tx_payload_epoch_ms}, $parsed->{payload_legacy_uptime}, $parsed->{parse_warning}, ); @@ -664,12 +735,12 @@ sub parse_payload { my ($payload) = @_; my %p = ( - payload_kind => undef, - payload_node => undef, - payload_seq => undef, - payload_tx_epoch_ms => undef, - payload_legacy_uptime => undef, - parse_warning => undef, + payload_kind => undef, + payload_node => undef, + payload_seq => undef, + payload_tx_payload_epoch_ms => undef, + payload_legacy_uptime => undef, + parse_warning => undef, ); if (!defined $payload || $payload eq '') { @@ -680,16 +751,16 @@ sub parse_payload { my @parts = split /\|/, $payload, -1; if (@parts == 4 && $parts[0] eq 'B2') { - $p{payload_kind} = 'B2'; - $p{payload_node} = $parts[1]; - $p{payload_seq} = to_int_or_undef($parts[2]); - $p{payload_tx_epoch_ms} = to_int_or_undef($parts[3]); + $p{payload_kind} = 'B2'; + $p{payload_node} = $parts[1]; + $p{payload_seq} = to_int_or_undef($parts[2]); + $p{payload_tx_payload_epoch_ms} = to_int_or_undef($parts[3]); if (!defined $p{payload_seq}) { $p{parse_warning} = 'B2 payload seq was not numeric'; } - elsif (!defined $p{payload_tx_epoch_ms}) { - $p{parse_warning} = 'B2 payload tx_epoch_ms was not numeric'; + elsif (!defined $p{payload_tx_payload_epoch_ms}) { + $p{parse_warning} = 'B2 payload tx_payload_epoch_ms was not numeric'; } return \%p; @@ -715,6 +786,13 @@ sub parse_payload { return \%p; } +sub append_warning { + my ($old, $new) = @_; + + return $new unless defined $old && $old ne ''; + return "$old; $new"; +} + sub update_trial_time_bounds { my ($dbh, $trial_id) = @_; diff --git a/exercises/26_Bluetooth_discover/src/main.cpp b/exercises/26_Bluetooth_discover/src/main.cpp index 7e518d5..3e898dd 100644 --- a/exercises/26_Bluetooth_discover/src/main.cpp +++ b/exercises/26_Bluetooth_discover/src/main.cpp @@ -30,6 +30,7 @@ #include #include #include +#include #include #include #include @@ -71,10 +72,11 @@ static constexpr uint32_t kDisplayPeriodMs = 1000; static constexpr uint32_t kAdvertisePeriodMs = 1000; static constexpr uint32_t kScanPeriodMs = 2500; static constexpr uint32_t kScanWindowSeconds = 2; -static constexpr uint32_t kLogFlushPeriodMs = 5000; +static constexpr uint32_t kLogFlushPeriodMs = 1000; +static constexpr uint32_t kStatusLogPeriodMs = 10000; static constexpr uint32_t kStartupStatusPeriodMs = 1000; static constexpr uint32_t kGpsRefreshPeriodMs = 1000; -static constexpr uint32_t kMaxGpsFixAgeMs = 5000; +static constexpr uint32_t kMaxGpsFixAgeMs = 30000; static constexpr uint32_t kMaxDisciplineAgeMs = 4UL * 60UL * 60UL * 1000UL; static constexpr uint8_t kRssiWindow = 5; @@ -119,6 +121,10 @@ bool g_sdReady = false; bool g_logReady = false; bool g_webReady = false; bool g_buttonWasPressed = false; +bool g_prevGpsValid = false; +bool g_prevClockValid = false; +bool g_statusInitialized = false; +bool g_lastWriteOk = true; uint8_t g_displayMode = 0; uint32_t g_sequence = 0; volatile uint32_t g_ppsEdgeCount = 0; @@ -127,7 +133,9 @@ uint32_t g_lastAdvertiseMs = 0; uint32_t g_lastScanMs = 0; uint32_t g_lastDisplayMs = 0; uint32_t g_lastFlushMs = 0; +uint32_t g_lastStatusLogMs = 0; uint32_t g_lastGpsRefreshMs = 0; +uint32_t g_rowsWritten = 0; int64_t g_epochBase = 0; uint32_t g_epochBaseMs = 0; int64_t g_lastDisciplineEpochMs = 0; @@ -217,8 +225,118 @@ bool gpsValidNow(uint32_t now) { return g_hasLocation && gpsAgeMs(now) <= kMaxGpsFixAgeMs; } -bool fieldDataValid(uint32_t now) { - return clockValidNow(now) && gpsValidNow(now); +int batteryMillivolts() { + if (!g_pmu) { + return -1; + } + return (int)g_pmu->getBattVoltage(); +} + +uint8_t heardTotal() { + uint8_t count = 0; + for (size_t i = 0; i < sizeof(g_nodes) / sizeof(g_nodes[0]); ++i) { + if (!g_nodes[i].self && g_nodes[i].heard) { + ++count; + } + } + return count; +} + +uint8_t heardFresh(uint32_t now) { + uint8_t count = 0; + for (size_t i = 0; i < sizeof(g_nodes) / sizeof(g_nodes[0]); ++i) { + if (!g_nodes[i].self && freshEnough(g_nodes[i], now)) { + ++count; + } + } + return count; +} + +void emitEvent(const char* event, const char* extra = nullptr) { + char line[256]; + const int64_t epochMs = currentEpochMs(); + const int n = snprintf(line, + sizeof(line), + "# EVENT epoch_ms=%lld ms=%lu unit=%s event=%s vbat_mv=%d%s%s", + (long long)epochMs, + (unsigned long)millis(), + NODE_NAME, + event ? event : "unknown", + batteryMillivolts(), + (extra && extra[0]) ? " " : "", + (extra && extra[0]) ? extra : ""); + Serial.println(line); + if (n > 0 && g_logReady && g_storage.isLogOpen()) { + g_storage.println(line); + } +} + +void emitStatus(bool force = false) { + const uint32_t now = millis(); + if (!force && (uint32_t)(now - g_lastStatusLogMs) < kStatusLogPeriodMs) { + return; + } + g_lastStatusLogMs = now; + + const bool clockValid = clockValidNow(now); + const bool gpsValid = gpsValidNow(now); + char line[256]; + const int n = snprintf(line, + sizeof(line), + "# STATUS epoch_ms=%lld ms=%lu unit=%s clock_valid=%u gps_valid=%u gps_fix_age_ms=%lu discipline_age_ms=%lu last_discipline_epoch_ms=%lld sd_ready=%u log_ready=%u rows_written=%lu heard_total=%u heard_fresh=%u vbat_mv=%d", + (long long)currentEpochMs(), + (unsigned long)now, + NODE_NAME, + clockValid ? 1U : 0U, + gpsValid ? 1U : 0U, + (unsigned long)gpsAgeMs(now), + (unsigned long)disciplineAgeMs(now), + (long long)g_lastDisciplineEpochMs, + g_storage.ready() ? 1U : 0U, + (g_logReady && g_storage.isLogOpen()) ? 1U : 0U, + (unsigned long)g_rowsWritten, + heardTotal(), + heardFresh(now), + batteryMillivolts()); + Serial.println(line); + if (n > 0 && g_logReady && g_storage.isLogOpen()) { + const size_t expected = (size_t)n + 1U; + const size_t wrote = g_storage.println(line); + if (wrote != expected && g_lastWriteOk) { + char extra[96]; + snprintf(extra, sizeof(extra), "bytes_expected=%lu bytes_written=%lu", (unsigned long)expected, (unsigned long)wrote); + emitEvent("sd_write_short", extra); + g_lastWriteOk = false; + } + } +} + +void pollStatusEvents() { + const uint32_t now = millis(); + const bool clockValid = clockValidNow(now); + const bool gpsValid = gpsValidNow(now); + if (!g_statusInitialized) { + g_statusInitialized = true; + g_prevClockValid = clockValid; + g_prevGpsValid = gpsValid; + return; + } + if (g_prevGpsValid && !gpsValid) { + char extra[80]; + snprintf(extra, sizeof(extra), "gps_fix_age_ms=%lu", (unsigned long)gpsAgeMs(now)); + emitEvent("gps_stale", extra); + } else if (!g_prevGpsValid && gpsValid) { + char extra[80]; + snprintf(extra, sizeof(extra), "gps_fix_age_ms=%lu", (unsigned long)gpsAgeMs(now)); + emitEvent("gps_fresh", extra); + } + if (g_prevClockValid && !clockValid) { + char extra[96]; + snprintf(extra, sizeof(extra), "discipline_age_ms=%lu", (unsigned long)disciplineAgeMs(now)); + emitEvent("clock_stale", extra); + } + g_prevGpsValid = gpsValid; + g_prevClockValid = clockValid; } void refreshGpsPosition(bool force = false) { @@ -405,7 +523,7 @@ bool disciplineStartupClock() { } bool openDatedLog() { - if (!fieldDataValid(millis()) || !g_storage.ready()) { + if (!clockValidNow(millis()) || !g_hasLocation || !g_storage.ready()) { return false; } char stamp[24]; @@ -419,10 +537,16 @@ bool openDatedLog() { Serial.printf("sd_log_open_failed path=%s err=%s\n", g_logPath, g_storage.lastError()); return false; } - g_storage.println("human_time,rx_epoch_ms,receiver,rx_lat,rx_lon,gps_fix_age_ms,clock_valid,gps_valid,discipline_age_ms,last_discipline_epoch_ms,heard,rssi,avg_rssi,age_s,count,seq,tx_payload_epoch_ms,payload"); + g_storage.println("# schema_version=2"); + g_storage.println("# fields=human_time,rx_epoch_ms,receiver,rx_lat,rx_lon,gps_fix_age_ms,clock_valid,gps_valid,discipline_age_ms,last_discipline_epoch_ms,heard,rssi,avg_rssi,age_s,count,seq,tx_payload_epoch_ms,payload,vbat_mv"); + g_storage.println("human_time,rx_epoch_ms,receiver,rx_lat,rx_lon,gps_fix_age_ms,clock_valid,gps_valid,discipline_age_ms,last_discipline_epoch_ms,heard,rssi,avg_rssi,age_s,count,seq,tx_payload_epoch_ms,payload,vbat_mv"); g_storage.flush(); Serial.printf("sd_log_open path=%s\n", g_logPath); g_logReady = true; + char extra[160]; + snprintf(extra, sizeof(extra), "reset_reason=%d logfile=%s", (int)esp_reset_reason(), g_logPath); + emitEvent("log_opened", extra); + emitStatus(true); return true; } @@ -538,7 +662,7 @@ void logAcceptedAdvertisement(const NodeState& node, const char* payload) { const uint32_t fixAge = gpsAgeMs(now); const bool gpsValid = gpsValidNow(now); const uint32_t discAge = disciplineAgeMs(now); - if (!clockValid || !gpsValid) { + if (!clockValid) { static uint32_t lastSuppressedMs = 0; if ((uint32_t)(now - lastSuppressedMs) >= 1000U) { lastSuppressedMs = now; @@ -574,28 +698,42 @@ void logAcceptedAdvertisement(const NodeState& node, const char* payload) { if (g_logReady && g_storage.isLogOpen()) { char line[320]; - snprintf(line, - sizeof(line), - "%s,%lld,%s,%.7f,%.7f,%lu,%u,%u,%lu,%lld,%s,%d,%d,%lu,%lu,%lu,%lld,%s", - human, - (long long)epochMs, - NODE_NAME, - g_latitude, - g_longitude, - (unsigned long)fixAge, - clockValid ? 1U : 0U, - gpsValid ? 1U : 0U, - (unsigned long)discAge, - (long long)g_lastDisciplineEpochMs, - node.name, - node.lastRssi, - avg, - (unsigned long)age, - (unsigned long)node.seenCount, - (unsigned long)node.lastSeq, - (long long)node.lastTxPayloadEpochMs, - payload); - g_storage.println(line); + const int n = snprintf(line, + sizeof(line), + "%s,%lld,%s,%.7f,%.7f,%lu,%u,%u,%lu,%lld,%s,%d,%d,%lu,%lu,%lu,%lld,%s,%d", + human, + (long long)epochMs, + NODE_NAME, + g_latitude, + g_longitude, + (unsigned long)fixAge, + clockValid ? 1U : 0U, + gpsValid ? 1U : 0U, + (unsigned long)discAge, + (long long)g_lastDisciplineEpochMs, + node.name, + node.lastRssi, + avg, + (unsigned long)age, + (unsigned long)node.seenCount, + (unsigned long)node.lastSeq, + (long long)node.lastTxPayloadEpochMs, + payload, + batteryMillivolts()); + const size_t expected = n > 0 ? (size_t)n + 1U : 0U; + const size_t wrote = n > 0 ? g_storage.println(line) : 0U; + const bool writeOk = expected > 0 && wrote == expected; + if (!writeOk && g_lastWriteOk) { + char extra[96]; + snprintf(extra, sizeof(extra), "bytes_expected=%lu bytes_written=%lu", (unsigned long)expected, (unsigned long)wrote); + emitEvent("sd_write_short", extra); + } else if (writeOk && !g_lastWriteOk) { + emitEvent("sd_write_ok"); + } + g_lastWriteOk = writeOk; + if (writeOk) { + ++g_rowsWritten; + } } } @@ -684,19 +822,12 @@ void renderDisplay() { char title[32]; makeTitle(title, sizeof(title)); const bool clockValid = clockValidNow(now); - const uint32_t fixAge = gpsAgeMs(now); const bool gpsValid = gpsValidNow(now); if (!clockValid) { showLines(title, "CLOCK STALE", "BLE survey paused", "logging paused"); return; } - if (!gpsValid) { - char ageLine[32]; - snprintf(ageLine, sizeof(ageLine), "gps age:%lus", fixAge == UINT32_MAX ? 9999UL : (unsigned long)(fixAge / 1000UL)); - showLines(title, "GPS STALE", ageLine, "logging paused"); - return; - } if (g_displayMode == 1) { char rows[5][32] = {}; @@ -769,6 +900,8 @@ void renderDisplay() { void pollStorageWeb() { g_storage.update(); if (g_storage.consumeRemovedEvent()) { + emitEvent("log_closed"); + emitEvent("sd_removed"); g_sdReady = false; g_logReady = false; g_logPath[0] = '\0'; @@ -776,9 +909,10 @@ void pollStorageWeb() { } if (g_storage.consumeMountedEvent()) { g_sdReady = true; + emitEvent("sd_remounted"); Serial.println("sd_mounted"); } - if (fieldDataValid(millis()) && g_storage.ready() && !g_logReady) { + if (clockValidNow(millis()) && g_hasLocation && g_storage.ready() && !g_logReady) { g_sdReady = true; g_logReady = openDatedLog(); } @@ -789,6 +923,9 @@ void pollStorageWeb() { g_lastFlushMs = millis(); g_storage.flush(); } + if (g_logReady) { + emitStatus(); + } } } // namespace @@ -801,6 +938,9 @@ void setup() { g_display.begin(); showBootScreen("PMU/OLED ready"); printBootBanner(); + char bootExtra[48]; + snprintf(bootExtra, sizeof(bootExtra), "reset_reason=%d", (int)esp_reset_reason()); + emitEvent("boot", bootExtra); markSelfNode(); pinMode(BUTTON_PIN, INPUT_PULLUP); @@ -825,6 +965,7 @@ void setup() { void loop() { g_gnss.poll(); refreshGpsPosition(); + pollStatusEvents(); pollButton(); pollStorageWeb(); if (g_bleStarted) { From fe46db2b3ccecc7bb5f3dc11ac544536d8bfce77 Mon Sep 17 00:00:00 2001 From: John Poole Date: Wed, 27 May 2026 11:10:14 -0700 Subject: [PATCH 8/9] latest schema for 18 column logs --- .../scripts/create_exercise_26_ble_schema.sql | 229 +++++++++++------- 1 file changed, 145 insertions(+), 84 deletions(-) diff --git a/exercises/26_Bluetooth_discover/scripts/create_exercise_26_ble_schema.sql b/exercises/26_Bluetooth_discover/scripts/create_exercise_26_ble_schema.sql index b8cb351..7dfa192 100644 --- a/exercises/26_Bluetooth_discover/scripts/create_exercise_26_ble_schema.sql +++ b/exercises/26_Bluetooth_discover/scripts/create_exercise_26_ble_schema.sql @@ -2,30 +2,34 @@ -- $HeadURL$ -- -- Example: --- sqlite3 ble_fieldtest_20260525_1945_ed_flo.sqlite < create_exercise_26_ble_schema.sql +-- sqlite3 ble_fieldtest_YYYYMMDD_HHMM.sqlite < create_exercise_26_ble_schema.sql -- -- SQLite schema for Exercise 26 BLE Discovery field-test logs. -- --- Design goals: --- 1. Preserve the raw log rows. --- 2. Preserve per-log manifest metadata. --- 3. Normalize parsed BLE observations. --- 4. Keep one portable SQLite database per field trial. --- 5. Support later export to GeoJSON / MapLibre / GIS tools. +-- This schema targets the current 18-column Exercise 26 log format only. -- -- Current Exercise 26 log format: -- -- human_time,rx_epoch_ms,receiver,rx_lat,rx_lon,gps_fix_age_ms, -- clock_valid,gps_valid,discipline_age_ms,last_discipline_epoch_ms, --- heard,rssi,avg_rssi,age_s,count,seq,tx_payload_epoch_ms,payload,vbat_mv +-- heard,rssi,avg_rssi,age_s,count,seq,tx_payload_epoch_ms,payload -- -- Current payload format: -- -- B2|NODE|SEQ|TX_PAYLOAD_EPOCH_MS -- --- Legacy payload format: +-- Timestamp semantics: -- --- TBMSND|1|NODE|SEQ|UPTIME +-- rx_epoch_ms is the receiver's disciplined-clock time when the observation +-- row is written. +-- +-- tx_payload_epoch_ms is the sender's disciplined-clock time when the BLE +-- advertising payload was generated/configured. It is not a per-RF-packet +-- transmit timestamp. +-- +-- rx_tx_payload_delta_ms is diagnostic only. It is useful for clock, +-- scheduling, payload-age, and repeated-observation analysis. It is not +-- BLE propagation delay. -- PRAGMA foreign_keys = ON; @@ -39,7 +43,7 @@ BEGIN TRANSACTION; -- -- A trial may contain multiple logs, usually one log per T-Beam receiver. -- Example: --- trial_label = peck_cottage_ed_flo_20260525_1945 +-- trial_label = peck_cottage_four_unit_20260526_1430 -- --------------------------------------------------------------------------- CREATE TABLE IF NOT EXISTS trial ( @@ -66,10 +70,6 @@ CREATE TABLE IF NOT EXISTS trial ( -- unit -- -- One row per named T-Beam unit encountered or described. --- --- This table is intentionally sparse. It allows later enrichment with --- hardware-specific observations such as GNSS chip, SD card problems, --- antenna notes, case color, battery notes, etc. -- --------------------------------------------------------------------------- CREATE TABLE IF NOT EXISTS unit ( @@ -90,15 +90,13 @@ CREATE TABLE IF NOT EXISTS unit ( -- -- One row per imported physical log file. -- --- The sha256 column allows repeatable provenance checks and prevents accidental --- duplicate ingestion of the same log. +-- sha256 prevents accidental duplicate ingestion. -- --------------------------------------------------------------------------- CREATE TABLE IF NOT EXISTS log_file ( log_file_id INTEGER PRIMARY KEY AUTOINCREMENT, trial_id INTEGER NOT NULL REFERENCES trial(trial_id) ON DELETE CASCADE, - receiver TEXT NOT NULL REFERENCES unit(unit_name), path TEXT NOT NULL, @@ -130,9 +128,6 @@ ON log_file(receiver); -- log_manifest_kv -- -- Raw key/value manifest entries. --- --- This preserves metadata exactly as supplied outside the CSV log. --- The importer also copies selected keys into trial/log_file columns. -- --------------------------------------------------------------------------- CREATE TABLE IF NOT EXISTS log_manifest_kv ( @@ -149,10 +144,7 @@ CREATE TABLE IF NOT EXISTS log_manifest_kv ( -- --------------------------------------------------------------------------- -- ble_observation_raw -- --- Faithful row-level import from the CSV log. --- --- This table is meant to preserve the CSV fields as received, with minimal --- interpretation. Parsed/normalized fields live in ble_observation. +-- Faithful row-level import from the current 18-column CSV log. -- --------------------------------------------------------------------------- CREATE TABLE IF NOT EXISTS ble_observation_raw ( @@ -169,6 +161,7 @@ CREATE TABLE IF NOT EXISTS ble_observation_raw ( rx_lat REAL, rx_lon REAL, gps_fix_age_ms INTEGER, + clock_valid INTEGER, gps_valid INTEGER, discipline_age_ms INTEGER, @@ -182,7 +175,6 @@ CREATE TABLE IF NOT EXISTS ble_observation_raw ( seq INTEGER, tx_payload_epoch_ms INTEGER, payload TEXT, - vbat_mv INTEGER, UNIQUE(log_file_id, source_line_no) ); @@ -196,17 +188,16 @@ ON ble_observation_raw(receiver, heard, rx_epoch_ms); CREATE INDEX IF NOT EXISTS idx_raw_rx_position ON ble_observation_raw(rx_lat, rx_lon); +CREATE INDEX IF NOT EXISTS idx_raw_validity +ON ble_observation_raw(clock_valid, gps_valid, gps_fix_age_ms); + -- --------------------------------------------------------------------------- -- ble_observation -- -- Parsed and normalized BLE observation table. -- --- This table keeps the important analytical values in typed columns. --- rx_tx_delta_ms is the receiver timestamp minus the sender timestamp. --- --- rx_tx_delta_ms is not a pure radio propagation delay. It includes BLE --- advertising/scanning timing, firmware scheduling, clock error, and logging --- latency. It is still useful for clock-alignment diagnostics. +-- rx_tx_payload_delta_ms is the receiver row timestamp minus the sender +-- payload-generation timestamp. It is diagnostic only. -- --------------------------------------------------------------------------- CREATE TABLE IF NOT EXISTS ble_observation ( @@ -225,7 +216,7 @@ CREATE TABLE IF NOT EXISTS ble_observation ( tx_payload_epoch_ms INTEGER, tx_payload_epoch_s REAL, - rx_tx_delta_ms INTEGER, + rx_tx_payload_delta_ms INTEGER, receiver TEXT NOT NULL REFERENCES unit(unit_name), heard TEXT NOT NULL REFERENCES unit(unit_name), @@ -233,11 +224,11 @@ CREATE TABLE IF NOT EXISTS ble_observation ( rx_lat REAL, rx_lon REAL, gps_fix_age_ms INTEGER, + clock_valid INTEGER, gps_valid INTEGER, discipline_age_ms INTEGER, last_discipline_epoch_ms INTEGER, - vbat_mv INTEGER, rssi INTEGER, avg_rssi INTEGER, @@ -250,7 +241,7 @@ CREATE TABLE IF NOT EXISTS ble_observation ( payload_kind TEXT, payload_node TEXT, payload_seq INTEGER, - payload_tx_epoch_ms INTEGER, + payload_tx_payload_epoch_ms INTEGER, payload_legacy_uptime INTEGER, parse_warning TEXT @@ -271,10 +262,14 @@ ON ble_observation(rx_lat, rx_lon); CREATE INDEX IF NOT EXISTS idx_obs_rssi ON ble_observation(receiver, heard, rssi); +CREATE INDEX IF NOT EXISTS idx_obs_validity +ON ble_observation(clock_valid, gps_valid, gps_fix_age_ms); + +CREATE INDEX IF NOT EXISTS idx_obs_payload_time +ON ble_observation(tx_payload_epoch_ms, rx_tx_payload_delta_ms); + -- --------------------------------------------------------------------------- -- v_log_file_summary --- --- Per-log summary. -- --------------------------------------------------------------------------- CREATE VIEW IF NOT EXISTS v_log_file_summary AS @@ -296,10 +291,8 @@ JOIN trial t ON t.trial_id = lf.trial_id; -- --------------------------------------------------------------------------- -- v_receiver_gps_summary -- --- Per receiver GPS spread summary in degrees. --- --- Distance conversion is intentionally left outside SQLite because plain SQLite --- may not have trigonometric math functions compiled in. +-- Per receiver GPS spread and validity summary in degrees. +-- Distance conversion remains outside SQLite. -- --------------------------------------------------------------------------- CREATE VIEW IF NOT EXISTS v_receiver_gps_summary AS @@ -327,7 +320,19 @@ SELECT MIN(gps_fix_age_ms) AS min_gps_fix_age_ms, AVG(gps_fix_age_ms) AS avg_gps_fix_age_ms, - MAX(gps_fix_age_ms) AS max_gps_fix_age_ms + MAX(gps_fix_age_ms) AS max_gps_fix_age_ms, + + MIN(clock_valid) AS min_clock_valid, + MAX(clock_valid) AS max_clock_valid, + MIN(gps_valid) AS min_gps_valid, + MAX(gps_valid) AS max_gps_valid, + + MIN(discipline_age_ms) AS min_discipline_age_ms, + AVG(discipline_age_ms) AS avg_discipline_age_ms, + MAX(discipline_age_ms) AS max_discipline_age_ms, + + MIN(last_discipline_epoch_ms) AS min_last_discipline_epoch_ms, + MAX(last_discipline_epoch_ms) AS max_last_discipline_epoch_ms FROM ble_observation GROUP BY trial_id, log_file_id, receiver; @@ -358,20 +363,24 @@ SELECT MAX(avg_rssi) AS max_rolling_avg_rssi, MIN(payload_seq) AS min_payload_seq, - MAX(payload_seq) AS max_payload_seq + MAX(payload_seq) AS max_payload_seq, + + MIN(clock_valid) AS min_clock_valid, + MIN(gps_valid) AS min_gps_valid FROM ble_observation GROUP BY trial_id, receiver, heard; -- --------------------------------------------------------------------------- --- v_rx_tx_timing_summary +-- v_rx_tx_payload_timing_summary -- -- Per receiver/heard timing-delta summary. -- --- rx_tx_delta_ms is useful for detecting clock disagreement, restarts, and --- logging/scan oddities. It should not be interpreted as RF travel time. +-- rx_tx_payload_delta_ms is useful for detecting clock disagreement, +-- scheduling, repeated payload observations, and payload-age behavior. +-- It is not RF propagation delay. -- --------------------------------------------------------------------------- -CREATE VIEW IF NOT EXISTS v_rx_tx_timing_summary AS +CREATE VIEW IF NOT EXISTS v_rx_tx_payload_timing_summary AS SELECT trial_id, receiver, @@ -379,28 +388,28 @@ SELECT COUNT(*) AS observation_count, - MIN(rx_tx_delta_ms) AS min_rx_tx_delta_ms, - AVG(rx_tx_delta_ms) AS avg_rx_tx_delta_ms, - MAX(rx_tx_delta_ms) AS max_rx_tx_delta_ms + MIN(rx_tx_payload_delta_ms) AS min_rx_tx_payload_delta_ms, + AVG(rx_tx_payload_delta_ms) AS avg_rx_tx_payload_delta_ms, + MAX(rx_tx_payload_delta_ms) AS max_rx_tx_payload_delta_ms FROM ble_observation WHERE tx_payload_epoch_ms IS NOT NULL AND tx_payload_epoch_ms > 0 GROUP BY trial_id, receiver, heard; --- --- Additiona views created after initial import of 1st test set --- and anomolies seen --- -CREATE VIEW IF NOT EXISTS v_rx_tx_delta_by_seq AS +-- --------------------------------------------------------------------------- +-- v_rx_tx_payload_delta_by_seq +-- --------------------------------------------------------------------------- + +CREATE VIEW IF NOT EXISTS v_rx_tx_payload_delta_by_seq AS SELECT trial_id, receiver, heard, payload_seq, COUNT(*) AS observations, - MIN(rx_tx_delta_ms) AS min_delta_ms, - AVG(rx_tx_delta_ms) AS avg_delta_ms, - MAX(rx_tx_delta_ms) AS max_delta_ms, + MIN(rx_tx_payload_delta_ms) AS min_delta_ms, + AVG(rx_tx_payload_delta_ms) AS avg_delta_ms, + MAX(rx_tx_payload_delta_ms) AS max_delta_ms, MIN(rx_epoch_ms) AS first_rx_epoch_ms, MAX(rx_epoch_ms) AS last_rx_epoch_ms FROM ble_observation @@ -408,6 +417,10 @@ WHERE tx_payload_epoch_ms IS NOT NULL AND tx_payload_epoch_ms > 0 GROUP BY trial_id, receiver, heard, payload_seq; +-- --------------------------------------------------------------------------- +-- v_payload_seq_summary +-- --------------------------------------------------------------------------- + CREATE VIEW IF NOT EXISTS v_payload_seq_summary AS SELECT trial_id, @@ -421,13 +434,78 @@ SELECT FROM ble_observation GROUP BY trial_id, receiver, heard; +-- --------------------------------------------------------------------------- +-- v_payload_seq_receive_spread -- --- for mapping +-- Shows how long the same advertised payload sequence was observed by a +-- receiver. Useful for confirming repeated observation of one payload. +-- --------------------------------------------------------------------------- + +CREATE VIEW IF NOT EXISTS v_payload_seq_receive_spread AS +SELECT + trial_id, + receiver, + heard, + payload_seq, + COUNT(*) AS observations, + MIN(rx_epoch_ms) AS first_rx_epoch_ms, + MAX(rx_epoch_ms) AS last_rx_epoch_ms, + MAX(rx_epoch_ms) - MIN(rx_epoch_ms) AS rx_spread_ms, + MIN(rx_tx_payload_delta_ms) AS min_delta_ms, + AVG(rx_tx_payload_delta_ms) AS avg_delta_ms, + MAX(rx_tx_payload_delta_ms) AS max_delta_ms +FROM ble_observation +WHERE tx_payload_epoch_ms IS NOT NULL + AND tx_payload_epoch_ms > 0 +GROUP BY trial_id, receiver, heard, payload_seq; + +-- --------------------------------------------------------------------------- +-- v_payload_tx_epoch_step -- +-- Shows elapsed sender payload-generation time between successive payload +-- sequence values as observed by each receiver/heard pair. +-- --------------------------------------------------------------------------- + +CREATE VIEW IF NOT EXISTS v_payload_tx_epoch_step AS +WITH x AS ( + SELECT + trial_id, + receiver, + heard, + payload_seq, + MIN(payload_tx_payload_epoch_ms) AS payload_tx_payload_epoch_ms + FROM ble_observation + WHERE payload_tx_payload_epoch_ms IS NOT NULL + AND payload_tx_payload_epoch_ms > 0 + GROUP BY trial_id, receiver, heard, payload_seq +), +y AS ( + SELECT + x.*, + LAG(payload_tx_payload_epoch_ms) OVER ( + PARTITION BY trial_id, receiver, heard + ORDER BY payload_seq + ) AS prev_payload_tx_payload_epoch_ms + FROM x +) +SELECT + *, + payload_tx_payload_epoch_ms - prev_payload_tx_payload_epoch_ms AS tx_payload_epoch_step_ms +FROM y +WHERE prev_payload_tx_payload_epoch_ms IS NOT NULL; + +-- --------------------------------------------------------------------------- +-- v_map_observation_points +-- +-- Mapping-oriented observation view. +-- --------------------------------------------------------------------------- + CREATE VIEW IF NOT EXISTS v_map_observation_points AS SELECT obs_id, trial_id, + log_file_id, + source_line_no, receiver, heard, rx_epoch_ms, @@ -435,11 +513,15 @@ SELECT rx_lat, rx_lon, gps_fix_age_ms, + clock_valid, + gps_valid, + discipline_age_ms, + last_discipline_epoch_ms, rssi, avg_rssi, payload_seq, tx_payload_epoch_ms, - rx_tx_delta_ms + rx_tx_payload_delta_ms FROM ble_observation WHERE rx_lat IS NOT NULL AND rx_lon IS NOT NULL; @@ -447,29 +529,8 @@ WHERE rx_lat IS NOT NULL CREATE VIEW IF NOT EXISTS v_map_good_gps_points AS SELECT * FROM v_map_observation_points -WHERE gps_fix_age_ms <= 5000; - - --- --------------------------------------------------------------------------- --- v_battery_summary --- --- Per receiver battery voltage summary for diagnosing power-related logging --- interruptions. vbat_mv = -1 means the PMU value was unavailable. --- --------------------------------------------------------------------------- - -CREATE VIEW IF NOT EXISTS v_battery_summary AS -SELECT - trial_id, - log_file_id, - receiver, - - COUNT(*) AS row_count, - SUM(CASE WHEN vbat_mv IS NOT NULL AND vbat_mv >= 0 THEN 1 ELSE 0 END) AS vbat_sample_count, - - MIN(CASE WHEN vbat_mv >= 0 THEN vbat_mv END) AS min_vbat_mv, - AVG(CASE WHEN vbat_mv >= 0 THEN vbat_mv END) AS avg_vbat_mv, - MAX(CASE WHEN vbat_mv >= 0 THEN vbat_mv END) AS max_vbat_mv -FROM ble_observation -GROUP BY trial_id, log_file_id, receiver; +WHERE clock_valid = 1 + AND gps_valid = 1 + AND gps_fix_age_ms <= 5000; COMMIT; From 8a75b96ac55f8780816d094b63c8a19326b3eb95 Mon Sep 17 00:00:00 2001 From: John Poole Date: Wed, 27 May 2026 11:11:59 -0700 Subject: [PATCH 9/9] Working rssi plotly, also saved in Subversion --- .../scripts/exercise26_ble_rssi_plotly.pl | 626 ++++++++++++++++++ 1 file changed, 626 insertions(+) create mode 100644 exercises/26_Bluetooth_discover/scripts/exercise26_ble_rssi_plotly.pl diff --git a/exercises/26_Bluetooth_discover/scripts/exercise26_ble_rssi_plotly.pl b/exercises/26_Bluetooth_discover/scripts/exercise26_ble_rssi_plotly.pl new file mode 100644 index 0000000..e9e5e79 --- /dev/null +++ b/exercises/26_Bluetooth_discover/scripts/exercise26_ble_rssi_plotly.pl @@ -0,0 +1,626 @@ +#!/usr/bin/env perl +# 20260526 ChatGPT generated +# $Id$ +# $HeadURL$ +# +# sudo ln -s /home/jlpoole/work/perl/exercise26_ble_rssi_plotly.pl /var/www/localhost/cgi-bin/exercise26_ble_rssi_plotly.pl +# chmod +x /home/jlpoole/work/perl/exercise26_ble_rssi_plotly.pl +# +# Example browser URLs: +# /cgi-bin/exercise26_ble_rssi_plotly.pl +# /cgi-bin/exercise26_ble_rssi_plotly.pl?receiver=BOB +# /cgi-bin/exercise26_ble_rssi_plotly.pl?receiver=BOB&value=avg_rssi +# /cgi-bin/exercise26_ble_rssi_plotly.pl?pairs=BOB:CY,CY:BOB,ED:FLO,FLO:ED +# /cgi-bin/exercise26_ble_rssi_plotly.pl?pairs=BOB:CY,CY:BOB,ED:FLO,FLO:ED&value=avg_rssi&gps_valid=1&clock_valid=1 +# /cgi-bin/exercise26_ble_rssi_plotly.pl?receiver=FLO&gps_max_ms=5000&bucket_s=5 +# +# Console test: +# REQUEST_METHOD=GET QUERY_STRING='receiver=BOB&value=avg_rssi' ./exercise26_ble_rssi_plotly.pl > /tmp/rssi.html +# ./exercise26_ble_rssi_plotly.pl 'receiver=BOB&value=avg_rssi' > /tmp/rssi.html +# ./exercise26_ble_rssi_plotly.pl receiver=BOB value=avg_rssi > /tmp/rssi.html +# +# Purpose: +# Emit a Plotly HTML graph from an Exercise 26 BLE Discovery SQLite database. +# X axis is receiver observation time. Y axis is RSSI or avg_rssi. +# +# Database selection: +# 1. DB_BLE_RSSI_PLOTLY environment variable +# 2. DB_BLE environment variable +# 3. hard-coded default below +# +# Required Perl modules: +# DBI +# DBD::SQLite +# JSON::PP +# + +use strict; +use warnings; + +use DBI; +use JSON::PP qw(encode_json); + +my $DEFAULT_DB_FILE = '/home/jlpoole/work/tbeam/ble/ble_fieldtest_20260526_1859.sqlite'; +my $DB_FILE = $ENV{DB_BLE_RSSI_PLOTLY} || $ENV{DB_BLE} || $DEFAULT_DB_FILE; + +# Prefer a locally staged Plotly file so the graph works without depending on a CDN. +# Override in Apache or shell with, for example: +# export PLOTLY_JS_URL=/lib/plotly-2.35.2.min.js +my $PLOTLY_JS_URL = $ENV{PLOTLY_JS_URL} || '/lib/plotly.min.js'; + +my @UNITS = qw(AMY BOB CY DAN ED FLO GUY); +my %VALID_UNIT = map { $_ => 1 } @UNITS; + +my %q = parse_query_string(); + +my $receiver = uc($q{receiver} // ''); +my $heard = uc($q{heard} // ''); +my $pairs_text = uc($q{pairs} // ''); +my $value = lc($q{value} // 'rssi'); +my $gps_max = $q{gps_max_ms}; +my $gps_valid = $q{gps_valid}; +my $clock_valid = $q{clock_valid}; +my $bucket_s = $q{bucket_s}; +my $limit = $q{limit}; + +if ($receiver ne '' && !$VALID_UNIT{$receiver}) { + emit_error(400, "Bad receiver value"); +} + +if ($heard ne '' && !$VALID_UNIT{$heard}) { + emit_error(400, "Bad heard value"); +} + +if ($value !~ /^(?:rssi|avg_rssi)$/) { + emit_error(400, "value must be rssi or avg_rssi"); +} + +if (defined $gps_max && $gps_max ne '' && $gps_max !~ /^\d+$/) { + emit_error(400, "gps_max_ms must be an integer"); +} + +for my $flag_name (qw(gps_valid clock_valid)) { + my $v = $flag_name eq 'gps_valid' ? $gps_valid : $clock_valid; + next unless defined $v && $v ne ''; + if ($v !~ /^[01]$/) { + emit_error(400, "$flag_name must be 0 or 1"); + } +} + +if (defined $bucket_s && $bucket_s ne '' && $bucket_s !~ /^\d+$/) { + emit_error(400, "bucket_s must be an integer number of seconds"); +} +$bucket_s = 0 unless defined $bucket_s && $bucket_s ne ''; +$bucket_s = int($bucket_s); +if ($bucket_s < 0 || $bucket_s > 3600) { + emit_error(400, "bucket_s must be between 0 and 3600"); +} + +if (defined $limit && $limit ne '' && $limit !~ /^\d+$/) { + emit_error(400, "limit must be an integer"); +} +$limit = 0 unless defined $limit && $limit ne ''; +$limit = int($limit); +if ($limit < 0 || $limit > 1_000_000) { + emit_error(400, "limit must be between 0 and 1000000"); +} + +my @pairs = parse_pairs($pairs_text); + +if (!-f $DB_FILE) { + emit_error(500, "Database not found: $DB_FILE"); +} + +my $dbh = DBI->connect( + "dbi:SQLite:dbname=$DB_FILE", + "", + "", + { + RaiseError => 1, + AutoCommit => 1, + sqlite_unicode => 1, + } +); + +my $traces = fetch_rssi_traces( + $dbh, + receiver => $receiver, + heard => $heard, + pairs => \@pairs, + value => $value, + gps_max => $gps_max, + gps_valid => $gps_valid, + clock_valid => $clock_valid, + bucket_s => $bucket_s, + limit => $limit, +); + +my $summary = fetch_summary( + $dbh, + receiver => $receiver, + heard => $heard, + pairs => \@pairs, + value => $value, + gps_max => $gps_max, + gps_valid => $gps_valid, + clock_valid => $clock_valid, +); + +$dbh->disconnect; + +emit_html($traces, $summary); +exit 0; + +sub fetch_rssi_traces { + my ($dbh, %arg) = @_; + + my @where = ( + 'rx_epoch_ms IS NOT NULL', + 'receiver IS NOT NULL', + 'heard IS NOT NULL', + "$arg{value} IS NOT NULL", + ); + my @bind; + + if ($arg{receiver} ne '') { + push @where, 'receiver = ?'; + push @bind, $arg{receiver}; + } + + if ($arg{heard} ne '') { + push @where, 'heard = ?'; + push @bind, $arg{heard}; + } + + if (@{ $arg{pairs} }) { + my @pair_terms; + for my $p (@{ $arg{pairs} }) { + push @pair_terms, '(receiver = ? AND heard = ?)'; + push @bind, $p->{receiver}, $p->{heard}; + } + push @where, '(' . join(' OR ', @pair_terms) . ')'; + } + + if (defined $arg{gps_max} && $arg{gps_max} ne '') { + push @where, 'gps_fix_age_ms <= ?'; + push @bind, int($arg{gps_max}); + } + + if (defined $arg{gps_valid} && $arg{gps_valid} ne '') { + push @where, 'gps_valid = ?'; + push @bind, int($arg{gps_valid}); + } + + if (defined $arg{clock_valid} && $arg{clock_valid} ne '') { + push @where, 'clock_valid = ?'; + push @bind, int($arg{clock_valid}); + } + + my $select_sql; + my $order_by; + + if ($arg{bucket_s}) { + my $bucket_ms = $arg{bucket_s} * 1000; + $select_sql = qq{ + SELECT + receiver, + heard, + CAST((rx_epoch_ms / $bucket_ms) AS INTEGER) * $bucket_ms AS plot_epoch_ms, + AVG($arg{value}) AS plot_rssi, + COUNT(*) AS row_count, + MIN(rssi) AS min_rssi, + MAX(rssi) AS max_rssi, + MIN(vbat_mv) AS min_vbat_mv, + MAX(gps_fix_age_ms) AS max_gps_fix_age_ms, + MIN(clock_valid) AS min_clock_valid, + MIN(gps_valid) AS min_gps_valid + FROM ble_observation + WHERE } . join(' AND ', @where) . qq{ + GROUP BY receiver, heard, plot_epoch_ms + }; + $order_by = ' ORDER BY receiver, heard, plot_epoch_ms'; + } + else { + # Important: do not mix aggregate functions such as COUNT/MIN/MAX with + # non-aggregate row columns here. SQLite will otherwise collapse the + # result to one arbitrary row, which looks like a single blue dot. + $select_sql = qq{ + SELECT + receiver, + heard, + rx_epoch_ms AS plot_epoch_ms, + $arg{value} AS plot_rssi, + 1 AS row_count, + rssi AS min_rssi, + rssi AS max_rssi, + vbat_mv AS min_vbat_mv, + gps_fix_age_ms AS max_gps_fix_age_ms, + clock_valid AS min_clock_valid, + gps_valid AS min_gps_valid + FROM ble_observation + WHERE } . join(' AND ', @where) . qq{ + }; + $order_by = ' ORDER BY receiver, heard, rx_epoch_ms, obs_id'; + } + + my $sql = $select_sql . $order_by; + + if ($arg{limit}) { + $sql .= ' LIMIT ?'; + push @bind, $arg{limit}; + } + + my $sth = $dbh->prepare($sql); + $sth->execute(@bind); + + my %series; + while (my $r = $sth->fetchrow_hashref) { + my $key = $r->{receiver} . ' hears ' . $r->{heard}; + + push @{ $series{$key}{x_ms} }, 0 + $r->{plot_epoch_ms}; + push @{ $series{$key}{y} }, numeric($r->{plot_rssi}); + push @{ $series{$key}{customdata} }, [ + $r->{receiver}, + $r->{heard}, + $r->{row_count}, + $r->{min_rssi}, + $r->{max_rssi}, + $r->{min_vbat_mv}, + $r->{max_gps_fix_age_ms}, + $r->{min_clock_valid}, + $r->{min_gps_valid}, + ]; + $series{$key}{receiver} = $r->{receiver}; + $series{$key}{heard} = $r->{heard}; + } + + my @traces; + for my $key (sort keys %series) { + push @traces, { + name => $key, + mode => 'lines+markers', + type => 'scatter', + x_ms => $series{$key}{x_ms}, + y => $series{$key}{y}, + customdata => $series{$key}{customdata}, + hovertemplate => join('', + '%{customdata[0]} hears %{customdata[1]}
', + 'time=%{x}
', + 'RSSI=%{y:.1f} dBm
', + 'rows=%{customdata[2]}
', + 'rssi range=%{customdata[3]} to %{customdata[4]}
', + 'min vbat=%{customdata[5]} mV
', + 'max gps age=%{customdata[6]} ms
', + 'clock_valid=%{customdata[7]} gps_valid=%{customdata[8]}', + '', + ), + }; + } + + return \@traces; +} + +sub fetch_summary { + my ($dbh, %arg) = @_; + + my @where = ( + 'rx_epoch_ms IS NOT NULL', + 'receiver IS NOT NULL', + 'heard IS NOT NULL', + "$arg{value} IS NOT NULL", + ); + my @bind; + + if ($arg{receiver} ne '') { + push @where, 'receiver = ?'; + push @bind, $arg{receiver}; + } + + if ($arg{heard} ne '') { + push @where, 'heard = ?'; + push @bind, $arg{heard}; + } + + if (@{ $arg{pairs} }) { + my @pair_terms; + for my $p (@{ $arg{pairs} }) { + push @pair_terms, '(receiver = ? AND heard = ?)'; + push @bind, $p->{receiver}, $p->{heard}; + } + push @where, '(' . join(' OR ', @pair_terms) . ')'; + } + + if (defined $arg{gps_max} && $arg{gps_max} ne '') { + push @where, 'gps_fix_age_ms <= ?'; + push @bind, int($arg{gps_max}); + } + + if (defined $arg{gps_valid} && $arg{gps_valid} ne '') { + push @where, 'gps_valid = ?'; + push @bind, int($arg{gps_valid}); + } + + if (defined $arg{clock_valid} && $arg{clock_valid} ne '') { + push @where, 'clock_valid = ?'; + push @bind, int($arg{clock_valid}); + } + + my $sql = qq{ + SELECT + COUNT(*) AS n_rows, + MIN(rx_epoch_ms) AS first_rx_epoch_ms, + MAX(rx_epoch_ms) AS last_rx_epoch_ms, + MIN($arg{value}) AS min_plot_rssi, + AVG($arg{value}) AS avg_plot_rssi, + MAX($arg{value}) AS max_plot_rssi, + MIN(vbat_mv) AS min_vbat_mv, + MAX(vbat_mv) AS max_vbat_mv, + MAX(gps_fix_age_ms) AS max_gps_fix_age_ms + FROM ble_observation + WHERE } . join(' AND ', @where); + + my $r = $dbh->selectrow_hashref($sql, undef, @bind) || {}; + $r->{db_file} = $DB_FILE; + $r->{value} = $arg{value}; + $r->{bucket_s} = $arg{bucket_s}; + $r->{receiver_filter} = $arg{receiver}; + $r->{heard_filter} = $arg{heard}; + $r->{pairs_filter} = join(',', map { $_->{receiver} . ':' . $_->{heard} } @{ $arg{pairs} }); + $r->{gps_max_ms_filter} = defined $arg{gps_max} ? $arg{gps_max} : ''; + $r->{gps_valid_filter} = defined $arg{gps_valid} ? $arg{gps_valid} : ''; + $r->{clock_valid_filter} = defined $arg{clock_valid} ? $arg{clock_valid} : ''; + + return $r; +} + +sub parse_pairs { + my ($pairs_text) = @_; + return () unless defined $pairs_text && $pairs_text ne ''; + + my @pairs; + for my $pair (split /,/, $pairs_text) { + next if $pair eq ''; + my ($receiver, $heard) = split /:/, $pair, 2; + $receiver //= ''; + $heard //= ''; + $receiver =~ s/^\s+|\s+$//g; + $heard =~ s/^\s+|\s+$//g; + + if (!$VALID_UNIT{$receiver} || !$VALID_UNIT{$heard}) { + emit_error(400, "Bad pairs value: $pair"); + } + + push @pairs, { + receiver => $receiver, + heard => $heard, + }; + } + + return @pairs; +} + +sub emit_html { + my ($traces, $summary) = @_; + + my $json = JSON::PP->new->canonical(1)->encode({ + traces => $traces, + summary => $summary, + }); + + my $title = 'Exercise 26 BLE RSSI vs Time'; + + print "Content-Type: text/html; charset=utf-8\r\n"; + print "Cache-Control: no-cache\r\n"; + print "\r\n"; + + print <<"HTML"; + + + + + +$title + + + + +
+

$title

+
+
+
+ + + +HTML +} + +sub parse_query_string { + my $qs = $ENV{QUERY_STRING} // ''; + + # Console convenience: + # ./exercise26_ble_rssi_plotly.pl 'receiver=BOB&value=avg_rssi' + # ./exercise26_ble_rssi_plotly.pl receiver=BOB value=avg_rssi + # CGI still uses QUERY_STRING. + if ($qs eq '' && @ARGV) { + if (@ARGV == 1 && $ARGV[0] =~ /[&=]/) { + $qs = $ARGV[0]; + } + else { + $qs = join('&', @ARGV); + } + } + + my %out; + + for my $pair (split /&/, $qs) { + next if $pair eq ''; + + my ($k, $v) = split /=/, $pair, 2; + $k = url_decode($k // ''); + $v = url_decode($v // ''); + + next if $k eq ''; + + $out{$k} = $v; + } + + return %out; +} + +sub url_decode { + my ($s) = @_; + + $s =~ tr/+/ /; + $s =~ s/%([0-9A-Fa-f]{2})/chr(hex($1))/eg; + + return $s; +} + +sub numeric { + my ($v) = @_; + + return undef unless defined $v; + return 0 + $v; +} + +sub emit_error { + my ($status, $msg) = @_; + + my %status_text = ( + 400 => 'Bad Request', + 500 => 'Internal Server Error', + ); + + my $text = $status_text{$status} // 'Error'; + + print "Status: $status $text\r\n"; + print "Content-Type: application/json\r\n"; + print "Cache-Control: no-cache\r\n"; + print "\r\n"; + print encode_json({ + error => $text, + status => $status, + detail => $msg, + }); + print "\n"; + + exit 0; +}