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(); +}