Untested, after completion of first round
This commit is contained in:
parent
49cead9636
commit
d38c646553
4 changed files with 975 additions and 0 deletions
136
exercises/26_Bluetooth_discover/Codex_1_initial_prompt.md
Normal file
136
exercises/26_Bluetooth_discover/Codex_1_initial_prompt.md
Normal 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 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.
|
||||
74
exercises/26_Bluetooth_discover/README.md
Normal file
74
exercises/26_Bluetooth_discover/README.md
Normal 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
|
||||
```
|
||||
109
exercises/26_Bluetooth_discover/platformio.ini
Normal file
109
exercises/26_Bluetooth_discover/platformio.ini
Normal 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
|
||||
656
exercises/26_Bluetooth_discover/src/main.cpp
Normal file
656
exercises/26_Bluetooth_discover/src/main.cpp
Normal 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();
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue