Untested, after completion of first round

This commit is contained in:
John Poole 2026-05-24 15:40:50 -07:00
commit d38c646553
4 changed files with 975 additions and 0 deletions

View file

@ -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 AMYs 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 Ill 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.

View file

@ -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
```

View file

@ -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

View file

@ -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 <Arduino.h>
#include <BLEAdvertisedDevice.h>
#include <BLEDevice.h>
#include <BLEScan.h>
#include <BLEUtils.h>
#include <SD.h>
#include <TBeamDisplay.h>
#include <TBeamStorage.h>
#include <TBeamWeb.h>
#include <Wire.h>
#include <XPowersLib.h>
#include <algorithm>
#include <ctype.h>
#include <math.h>
#include <stdarg.h>
#include <stdio.h>
#include <string.h>
#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();
}