diff --git a/exercises/26_Bluetooth_discover/.gitignore b/exercises/26_Bluetooth_discover/.gitignore
new file mode 100644
index 0000000..47e0292
--- /dev/null
+++ b/exercises/26_Bluetooth_discover/.gitignore
@@ -0,0 +1,2 @@
+*.sqlite
+
diff --git a/exercises/26_Bluetooth_discover/Codex_1_initial_prompt.md b/exercises/26_Bluetooth_discover/Codex_1_initial_prompt.md
new file mode 100644
index 0000000..581dc7d
--- /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_search.log. Logging should include date + time, both epoch high precision and regular human readable (not high precision), GPS coordinates of current receiving unit, the advertisement received, the RSSI strength of the received transmission.
+19. Include web interface such as in Exercise 18, or later -- I cannot remember for sure, that allows downloading of the log files and deletion of the log files. We had a system where each unit had a unique IP for it, use that.
+
+Implementation details:
+- 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/Codex_2_timing_prompt.md b/exercises/26_Bluetooth_discover/Codex_2_timing_prompt.md
new file mode 100644
index 0000000..703a725
--- /dev/null
+++ b/exercises/26_Bluetooth_discover/Codex_2_timing_prompt.md
@@ -0,0 +1,24 @@
+4. Clarification on runtime clock validity:
+
+Do not require continuous GPS visibility to keep the clock valid. Use a holdover model.
+
+At startup:
+- Require GPS/PPS/RTC discipline before entering BLE survey mode.
+- Set:
+ g_clockValid = true
+ g_lastDisciplineEpochMs = currentEpochMs()
+ g_lastDisciplineMillis = millis()
+
+During runtime:
+- clock_valid remains true while:
+ millis() - g_lastDisciplineMillis <= kMaxDisciplineAgeMs
+- gps_valid is evaluated separately from the latest GPS fix age:
+ rx_gps_age_ms <= kMaxGpsFixAgeMs
+
+If the unit later moves under trees or near buildings and loses satellite visibility:
+- Do not immediately invalidate clock_valid.
+- Let gps_valid become false when rx_gps_age_ms exceeds kMaxGpsFixAgeMs.
+- Continue OLED BLE display if possible.
+- Do not write normal observation rows unless both clock_valid and gps_valid are true, or else write rows with explicit validity fields so analysis can filter them.
+
+The important point is: a temporary GPS outage should stale the coordinates, not necessarily the disciplined clock.
diff --git a/exercises/26_Bluetooth_discover/README.md b/exercises/26_Bluetooth_discover/README.md
new file mode 100644
index 0000000..7ac5993
--- /dev/null
+++ b/exercises/26_Bluetooth_discover/README.md
@@ -0,0 +1,193 @@
+# Exercise 26: BLE Discovery
+
+Plain BLE neighborhood discovery for LilyGO T-Beam SUPREME ESP32-S3 units. This exercise is not Reticulum and does not use LoRa.
+
+Each unit displays `Take me outside` at startup, waits for GPS UTC, GPS coordinates, PPS-backed RTC discipline, and then starts BLE advertising/scanning. AMY may run without SD logging because its card reader is defective; other units should mount SD and write logs.
+
+## Build
+
+```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 --upload-port /dev/ttytBOB
+pio device monitor -b 115200 -p /dev/ttytBOB
+```
+
+## Behavior
+
+- Advertises manufacturer data in this format: `B2|NODE|seq|tx_payload_epoch_ms`.
+- Accepts current `B2` advertisements and legacy `TBMSND|1|NODE|seq|uptime` advertisements from known nodes in `AMY, BOB, CY, DAN, ED, FLO, GUY`.
+- Displays heard nodes sorted by rolling RSSI average, strongest first.
+- Drops stale entries after 20 seconds.
+- Logs valid accepted advertisements to SD as `/logs/YYYYMMDD_HHMISS_NODE_ble_search.log`.
+- Refreshes the receiver GPS position at 1 Hz and logs the last known receiver coordinates plus GPS fix age. Observation rows are written while the holdover clock is valid; stale GPS is recorded with `gps_valid=0` instead of suppressing the row.
+- Writes periodic `# STATUS` heartbeat lines so a quiet BLE interval can be distinguished from a reset, hang, SD fault, or power interruption.
+- Starts a WiFi AP and HTTP web service during boot. This does not wait for GPS.
+
+Default web addresses:
+
+- AMY: SSID `TBEAM-AMY`, `http://192.168.23.1/`
+- BOB: SSID `TBEAM-BOB`, `http://192.168.24.1/`
+- CY: SSID `TBEAM-CY`, `http://192.168.25.1/`
+- DAN: SSID `TBEAM-DAN`, `http://192.168.26.1/`
+- ED: SSID `TBEAM-ED`, `http://192.168.27.1/`
+- FLO: SSID `TBEAM-FLO`, `http://192.168.28.1/`
+- GUY: SSID `TBEAM-GUY`, `http://192.168.29.1/`
+
+The T-Beam hosts the WiFi access point and web page itself. No service needs to run on the workstation. To use the page, connect the workstation WiFi interface to the unit SSID, for example `TBEAM-ED`, then browse to that unit address, for example `http://192.168.27.1/`. A Panda USB WiFi adapter is useful only as the workstation WiFi interface used to join the T-Beam AP. The root page is a lightweight status page; click `Files` when you want the SD directory listing.
+
+## Field Procedure
+
+1. Flash all units with this BLE Discovery image, each with its own environment.
+2. Start all units where they can see sky and wait until each passes GPS/RTC startup discipline.
+3. Place one unit near the intended origin, preferably central to a star topology.
+4. Carry another unit and watch the OLED.
+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_discovery_AMY_YYYYMMDD_HHMMSS.log
+```
+## Logs
+Here are two logs from ED and FLO which were activated in the field.
+```bash
+jlpoole@jp ~/work/tbeam/logs $ ls -lath ed/*_16*
+-rw-r--r-- 1 jlpoole jlpoole 1.1M May 25 09:53 ed/20260525_162217_ED_ble_search.log
+jlpoole@jp ~/work/tbeam/logs $ ls -lath flo/*_16*
+-rw-r--r-- 1 jlpoole jlpoole 1.1M May 25 09:53 flo/20260525_162213_FLO_ble_search.log
+jlpoole@jp ~/work/tbeam/logs $
+```
+
+Here are start and end samples from an earlier trial. These show the previous 12-column schema; current firmware uses the 19-column schema described below.
+```bash
+jlpoole@jp ~/work/tbeam/logs $ cat -n ed/20260525_162217_ED_ble_search.log|head -n 3
+ 1 human_time,epoch_ms,receiver,lat,lon,heard,rssi,avg_rssi,age_s,count,seq,payload
+ 2 2026-05-25 16:22:18,1779726138897,ED,44.9364577,-123.0218702,FLO,-56,-56,0,1,1,TBMSND|1|FLO|0001|0775
+ 3 2026-05-25 16:22:18,1779726138938,ED,44.9364577,-123.0218702,FLO,-51,-54,0,2,1,TBMSND|1|FLO|0001|0775
+jlpoole@jp ~/work/tbeam/logs $ cat -n ed/20260525_162217_ED_ble_search.log|tail -n 1
+ 10277 2026-05-25 16:47:42,1779727662217,ED,44.9364577,-123.0218702,FLO,-42,-42,0,10276,611,TBMSND|1|FLO|0611|2300
+jlpoole@jp ~/work/tbeam/logs $ cat -n flo/20260525_162213_FLO_ble_search.log|head -n 3
+ 1 human_time,epoch_ms,receiver,lat,lon,heard,rssi,avg_rssi,age_s,count,seq,payload
+ 2 2026-05-25 16:22:16,1779726136737,FLO,44.9365132,-123.0218183,ED,-52,-52,0,1,0,TBMSND|1|ED|0000|0805
+ 3 2026-05-25 16:22:16,1779726136829,FLO,44.9365132,-123.0218183,ED,-51,-52,0,2,0,TBMSND|1|ED|0000|0805
+jlpoole@jp ~/work/tbeam/logs $ cat -n flo/20260525_162213_FLO_ble_search.log|tail -n 1
+ 10121 2026-05-25 16:47:37,1779727657219,FLO,44.9365132,-123.0218183,ED,-41,-40,0,10120,608,TBMSND|1|ED|0608|2325
+jlpoole@jp ~/work/tbeam/logs $
+```
+The header represents:
+
+| Column | Header | Explanation |
+| ---: | --- | --- |
+| 1 | `human_time` | Receiver timestamp in human-readable UTC form. |
+| 2 | `rx_epoch_ms` | Receiver timestamp as Unix epoch milliseconds. |
+| 3 | `receiver` | Unit that wrote the log row. |
+| 4 | `rx_lat` | Receiver GPS latitude from the latest valid local GPS fix. |
+| 5 | `rx_lon` | Receiver GPS longitude from the latest valid local GPS fix. |
+| 6 | `gps_fix_age_ms` | Age of the receiver GPS fix in milliseconds when the row was written. If GPS becomes unavailable, the last known coordinates remain and this age grows. |
+| 7 | `clock_valid` | `1` when the startup-disciplined clock is still inside the configured holdover window. |
+| 8 | `gps_valid` | `1` when the receiver GPS fix age is inside the configured freshness window. |
+| 9 | `discipline_age_ms` | Milliseconds since the startup GPS/PPS/RTC discipline event. |
+| 10 | `last_discipline_epoch_ms` | Epoch milliseconds assigned to the PPS edge used for startup discipline. |
+| 11 | `heard` | Remote unit heard in the BLE advertisement. |
+| 12 | `rssi` | RSSI measured by the receiver for this advertisement. |
+| 13 | `avg_rssi` | Receiver-calculated arithmetic mean of the most recent RSSI measurements for this heard unit, using up to the last 5 accepted advertisements and rounded to the nearest integer. The window size is `kRssiWindow = 5` in `main.cpp`. |
+| 14 | `age_s` | Age in seconds of the displayed/heard entry. |
+| 15 | `count` | Number of accepted advertisements from that heard unit. |
+| 16 | `seq` | Sequence number advertised by the heard unit. |
+| 17 | `tx_payload_epoch_ms` | Sender payload generation timestamp from the BLE payload as Unix epoch milliseconds, when available. Legacy v1 payloads report `0` here. |
+| 18 | `payload` | Raw BLE manufacturer-data string. See Payload Definition below. |
+| 19 | `vbat_mv` | Battery voltage reported by the PMU, in millivolts. `-1` means the PMU value was unavailable. |
+
+## Payload definition
+
+`payload` is the exact BLE manufacturer-data string received from the other unit. Current firmware advertises a compact v2 text payload:
+
+```text
+B2|NODE|SEQ|TX_PAYLOAD_EPOCH_MS
+```
+
+Example:
+
+```text
+B2|FLO|0611|1779727662217
+```
+
+Meaning:
+
+| Part | Example | Meaning |
+| --- | --- | --- |
+| `B2` | `B2` | Compact Exercise 26 payload prefix and version. |
+| `NODE` | `FLO` | Sending unit name. Receiver accepts only known units and ignores itself. |
+| `SEQ` | `0611` | Sender’s advertisement sequence number, zero-padded, wraps every 10,000 advertisements. |
+| `TX_PAYLOAD_EPOCH_MS` | `1779727662217` | Sender payload generation timestamp as Unix epoch milliseconds, derived from the disciplined local clock. This is not a per-advertisement RF transmit timestamp. |
+
+The receiver also accepts the legacy v1 payload used by earlier Exercise 26 firmware:
+
+```text
+TBMSND|1|NODE|SEQ|UPTIME
+```
+
+Constraints currently enforced by the receiver:
+- Payload must fit in the receive buffer, currently less than 48 bytes.
+- It must be pipe-delimited.
+- Prefix must be current `B2` or legacy `TBMSND` version `1`.
+- Node must be one of `AMY, BOB, CY, DAN, ED, FLO, GUY`.
+- Node must not be the receiver’s own name.
+- Current `B2` payloads provide `SEQ` and `TX_PAYLOAD_EPOCH_MS`; legacy v1 payloads provide `SEQ` only and log `tx_payload_epoch_ms` as `0`.
+
+## Clock Discipline and Timestamp Semantics
+
+BLE survey advertising starts only after startup GPS UTC, GPS coordinates, and PPS-backed RTC discipline have succeeded. Before that point the OLED remains on a blocking GPS/clock status page and the unit does not enter active BLE survey mode.
+
+Observation logging requires `clock_valid=1`. The clock uses a holdover model: startup discipline sets `last_discipline_epoch_ms`, and `clock_valid` remains true until `discipline_age_ms` exceeds the configured holdover limit. Temporary GPS loss does not immediately invalidate the clock, and it does not suppress observation rows. GPS validity is tracked separately with `gps_fix_age_ms` and `gps_valid`, so stale-coordinate rows can be filtered later.
+
+`rx_epoch_ms` is the receiver's local disciplined-clock time at the moment it writes the observation row. `tx_payload_epoch_ms` is the sender's local disciplined-clock time when it generated and installed that BLE advertising payload. A single payload may be observed many times before the sender replaces it, so `tx_payload_epoch_ms` is useful for correlating logs but is not an RF transmit timestamp.
+
+`rx_epoch_ms - tx_payload_epoch_ms` is diagnostic for clock alignment, scheduling, payload age, and repeated-observation behavior. It is not BLE propagation delay. The status fields that prove a row was written under valid survey conditions are `clock_valid`, `gps_valid`, `gps_fix_age_ms`, `discipline_age_ms`, and `last_discipline_epoch_ms`.
+
+## Event Lines
+
+Comment-style event lines begin with `# EVENT` and are not normal CSV observation rows. They preserve diagnostic transitions without complicating the ordinary CSV row format. Current event names are:
+
+- `boot`
+- `log_opened`
+- `log_closed`
+- `gps_stale`
+- `gps_fresh`
+- `clock_stale`
+- `sd_removed`
+- `sd_remounted`
+- `sd_write_short`
+- `sd_write_ok`
+
+## Status Lines
+
+Comment-style status lines begin with `# STATUS` and are written about every 10 seconds while the log is open. They are not normal CSV observation rows. They report the unit's clock/GPS state, SD/log state, rows written, heard-node counts, and `vbat_mv`. These heartbeat lines are intended to show whether the firmware was still alive during periods with no BLE observations.
diff --git a/exercises/26_Bluetooth_discover/platformio.ini b/exercises/26_Bluetooth_discover/platformio.ini
new file mode 100644
index 0000000..6088ac0
--- /dev/null
+++ b/exercises/26_Bluetooth_discover/platformio.ini
@@ -0,0 +1,109 @@
+; Exercise 26: plain Bluetooth discovery 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/scripts/create_exercise_26_ble_schema.sql b/exercises/26_Bluetooth_discover/scripts/create_exercise_26_ble_schema.sql
new file mode 100644
index 0000000..7dfa192
--- /dev/null
+++ b/exercises/26_Bluetooth_discover/scripts/create_exercise_26_ble_schema.sql
@@ -0,0 +1,536 @@
+-- $Id$
+-- $HeadURL$
+--
+-- Example:
+-- sqlite3 ble_fieldtest_YYYYMMDD_HHMM.sqlite < create_exercise_26_ble_schema.sql
+--
+-- SQLite schema for Exercise 26 BLE Discovery field-test logs.
+--
+-- This schema targets the current 18-column Exercise 26 log format only.
+--
+-- Current Exercise 26 log format:
+--
+-- human_time,rx_epoch_ms,receiver,rx_lat,rx_lon,gps_fix_age_ms,
+-- clock_valid,gps_valid,discipline_age_ms,last_discipline_epoch_ms,
+-- heard,rssi,avg_rssi,age_s,count,seq,tx_payload_epoch_ms,payload
+--
+-- Current payload format:
+--
+-- B2|NODE|SEQ|TX_PAYLOAD_EPOCH_MS
+--
+-- Timestamp semantics:
+--
+-- rx_epoch_ms is the receiver's disciplined-clock time when the observation
+-- row is written.
+--
+-- tx_payload_epoch_ms is the sender's disciplined-clock time when the BLE
+-- advertising payload was generated/configured. It is not a per-RF-packet
+-- transmit timestamp.
+--
+-- rx_tx_payload_delta_ms is diagnostic only. It is useful for clock,
+-- scheduling, payload-age, and repeated-observation analysis. It is not
+-- BLE propagation delay.
+--
+
+PRAGMA foreign_keys = ON;
+
+BEGIN TRANSACTION;
+
+-- ---------------------------------------------------------------------------
+-- trial
+--
+-- One row per field experiment.
+--
+-- A trial may contain multiple logs, usually one log per T-Beam receiver.
+-- Example:
+-- trial_label = peck_cottage_four_unit_20260526_1430
+-- ---------------------------------------------------------------------------
+
+CREATE TABLE IF NOT EXISTS trial (
+ trial_id INTEGER PRIMARY KEY AUTOINCREMENT,
+
+ trial_label TEXT NOT NULL UNIQUE,
+ trial_name TEXT NOT NULL,
+
+ field_site TEXT,
+ operator TEXT,
+
+ firmware_exercise TEXT,
+ firmware_git_commit TEXT,
+
+ trial_start_epoch_ms INTEGER,
+ trial_end_epoch_ms INTEGER,
+
+ notes TEXT,
+
+ created_at_utc TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
+);
+
+-- ---------------------------------------------------------------------------
+-- unit
+--
+-- One row per named T-Beam unit encountered or described.
+-- ---------------------------------------------------------------------------
+
+CREATE TABLE IF NOT EXISTS unit (
+ unit_name TEXT PRIMARY KEY,
+
+ gnss_chip TEXT,
+ case_description TEXT,
+ antenna_notes TEXT,
+ battery_notes TEXT,
+ sd_notes TEXT,
+ hardware_notes TEXT,
+
+ created_at_utc TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
+);
+
+-- ---------------------------------------------------------------------------
+-- log_file
+--
+-- One row per imported physical log file.
+--
+-- sha256 prevents accidental duplicate ingestion.
+-- ---------------------------------------------------------------------------
+
+CREATE TABLE IF NOT EXISTS log_file (
+ log_file_id INTEGER PRIMARY KEY AUTOINCREMENT,
+
+ trial_id INTEGER NOT NULL REFERENCES trial(trial_id) ON DELETE CASCADE,
+ receiver TEXT NOT NULL REFERENCES unit(unit_name),
+
+ path TEXT NOT NULL,
+ basename TEXT NOT NULL,
+ manifest_path TEXT,
+
+ sha256 TEXT NOT NULL UNIQUE,
+ byte_count INTEGER,
+ source_row_count INTEGER,
+
+ first_rx_epoch_ms INTEGER,
+ last_rx_epoch_ms INTEGER,
+
+ receiver_role TEXT,
+ receiver_start_description TEXT,
+ receiver_notes TEXT,
+ test_notes TEXT,
+
+ imported_at_utc TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
+);
+
+CREATE INDEX IF NOT EXISTS idx_log_file_trial_id
+ON log_file(trial_id);
+
+CREATE INDEX IF NOT EXISTS idx_log_file_receiver
+ON log_file(receiver);
+
+-- ---------------------------------------------------------------------------
+-- log_manifest_kv
+--
+-- Raw key/value manifest entries.
+-- ---------------------------------------------------------------------------
+
+CREATE TABLE IF NOT EXISTS log_manifest_kv (
+ manifest_kv_id INTEGER PRIMARY KEY AUTOINCREMENT,
+
+ log_file_id INTEGER NOT NULL REFERENCES log_file(log_file_id) ON DELETE CASCADE,
+
+ key TEXT NOT NULL,
+ value TEXT,
+
+ UNIQUE(log_file_id, key)
+);
+
+-- ---------------------------------------------------------------------------
+-- ble_observation_raw
+--
+-- Faithful row-level import from the current 18-column CSV log.
+-- ---------------------------------------------------------------------------
+
+CREATE TABLE IF NOT EXISTS ble_observation_raw (
+ raw_id INTEGER PRIMARY KEY AUTOINCREMENT,
+
+ trial_id INTEGER NOT NULL REFERENCES trial(trial_id) ON DELETE CASCADE,
+ log_file_id INTEGER NOT NULL REFERENCES log_file(log_file_id) ON DELETE CASCADE,
+
+ source_line_no INTEGER NOT NULL,
+
+ human_time TEXT,
+ rx_epoch_ms INTEGER,
+ receiver TEXT,
+ rx_lat REAL,
+ rx_lon REAL,
+ gps_fix_age_ms INTEGER,
+
+ clock_valid INTEGER,
+ gps_valid INTEGER,
+ discipline_age_ms INTEGER,
+ last_discipline_epoch_ms INTEGER,
+
+ heard TEXT,
+ rssi INTEGER,
+ avg_rssi INTEGER,
+ age_s INTEGER,
+ count INTEGER,
+ seq INTEGER,
+ tx_payload_epoch_ms INTEGER,
+ payload TEXT,
+
+ UNIQUE(log_file_id, source_line_no)
+);
+
+CREATE INDEX IF NOT EXISTS idx_raw_trial_time
+ON ble_observation_raw(trial_id, rx_epoch_ms);
+
+CREATE INDEX IF NOT EXISTS idx_raw_receiver_heard_time
+ON ble_observation_raw(receiver, heard, rx_epoch_ms);
+
+CREATE INDEX IF NOT EXISTS idx_raw_rx_position
+ON ble_observation_raw(rx_lat, rx_lon);
+
+CREATE INDEX IF NOT EXISTS idx_raw_validity
+ON ble_observation_raw(clock_valid, gps_valid, gps_fix_age_ms);
+
+-- ---------------------------------------------------------------------------
+-- ble_observation
+--
+-- Parsed and normalized BLE observation table.
+--
+-- rx_tx_payload_delta_ms is the receiver row timestamp minus the sender
+-- payload-generation timestamp. It is diagnostic only.
+-- ---------------------------------------------------------------------------
+
+CREATE TABLE IF NOT EXISTS ble_observation (
+ obs_id INTEGER PRIMARY KEY AUTOINCREMENT,
+
+ raw_id INTEGER NOT NULL UNIQUE REFERENCES ble_observation_raw(raw_id) ON DELETE CASCADE,
+
+ trial_id INTEGER NOT NULL REFERENCES trial(trial_id) ON DELETE CASCADE,
+ log_file_id INTEGER NOT NULL REFERENCES log_file(log_file_id) ON DELETE CASCADE,
+
+ source_line_no INTEGER NOT NULL,
+
+ rx_epoch_ms INTEGER NOT NULL,
+ rx_epoch_s REAL NOT NULL,
+
+ tx_payload_epoch_ms INTEGER,
+ tx_payload_epoch_s REAL,
+
+ rx_tx_payload_delta_ms INTEGER,
+
+ receiver TEXT NOT NULL REFERENCES unit(unit_name),
+ heard TEXT NOT NULL REFERENCES unit(unit_name),
+
+ rx_lat REAL,
+ rx_lon REAL,
+ gps_fix_age_ms INTEGER,
+
+ clock_valid INTEGER,
+ gps_valid INTEGER,
+ discipline_age_ms INTEGER,
+ last_discipline_epoch_ms INTEGER,
+
+ rssi INTEGER,
+ avg_rssi INTEGER,
+ age_s INTEGER,
+ receiver_count INTEGER,
+
+ receiver_seq_field INTEGER,
+
+ payload_raw TEXT,
+ payload_kind TEXT,
+ payload_node TEXT,
+ payload_seq INTEGER,
+ payload_tx_payload_epoch_ms INTEGER,
+ payload_legacy_uptime INTEGER,
+
+ parse_warning TEXT
+);
+
+CREATE INDEX IF NOT EXISTS idx_obs_trial_time
+ON ble_observation(trial_id, rx_epoch_ms);
+
+CREATE INDEX IF NOT EXISTS idx_obs_receiver_heard_time
+ON ble_observation(receiver, heard, rx_epoch_ms);
+
+CREATE INDEX IF NOT EXISTS idx_obs_heard_time
+ON ble_observation(heard, rx_epoch_ms);
+
+CREATE INDEX IF NOT EXISTS idx_obs_position
+ON ble_observation(rx_lat, rx_lon);
+
+CREATE INDEX IF NOT EXISTS idx_obs_rssi
+ON ble_observation(receiver, heard, rssi);
+
+CREATE INDEX IF NOT EXISTS idx_obs_validity
+ON ble_observation(clock_valid, gps_valid, gps_fix_age_ms);
+
+CREATE INDEX IF NOT EXISTS idx_obs_payload_time
+ON ble_observation(tx_payload_epoch_ms, rx_tx_payload_delta_ms);
+
+-- ---------------------------------------------------------------------------
+-- v_log_file_summary
+-- ---------------------------------------------------------------------------
+
+CREATE VIEW IF NOT EXISTS v_log_file_summary AS
+SELECT
+ lf.log_file_id,
+ t.trial_label,
+ lf.receiver,
+ lf.basename,
+ lf.receiver_role,
+ lf.source_row_count,
+ lf.first_rx_epoch_ms,
+ lf.last_rx_epoch_ms,
+ ROUND((lf.last_rx_epoch_ms - lf.first_rx_epoch_ms) / 1000.0, 3) AS duration_s,
+ lf.sha256,
+ lf.imported_at_utc
+FROM log_file lf
+JOIN trial t ON t.trial_id = lf.trial_id;
+
+-- ---------------------------------------------------------------------------
+-- v_receiver_gps_summary
+--
+-- Per receiver GPS spread and validity summary in degrees.
+-- Distance conversion remains outside SQLite.
+-- ---------------------------------------------------------------------------
+
+CREATE VIEW IF NOT EXISTS v_receiver_gps_summary AS
+SELECT
+ trial_id,
+ log_file_id,
+ receiver,
+
+ COUNT(*) AS row_count,
+ COUNT(DISTINCT printf('%.7f,%.7f', rx_lat, rx_lon)) AS distinct_coordinate_pairs,
+
+ MIN(rx_epoch_ms) AS first_rx_epoch_ms,
+ MAX(rx_epoch_ms) AS last_rx_epoch_ms,
+
+ AVG(rx_lat) AS mean_rx_lat,
+ AVG(rx_lon) AS mean_rx_lon,
+
+ MIN(rx_lat) AS min_rx_lat,
+ MAX(rx_lat) AS max_rx_lat,
+ MIN(rx_lon) AS min_rx_lon,
+ MAX(rx_lon) AS max_rx_lon,
+
+ MAX(rx_lat) - MIN(rx_lat) AS lat_range_deg,
+ MAX(rx_lon) - MIN(rx_lon) AS lon_range_deg,
+
+ MIN(gps_fix_age_ms) AS min_gps_fix_age_ms,
+ AVG(gps_fix_age_ms) AS avg_gps_fix_age_ms,
+ MAX(gps_fix_age_ms) AS max_gps_fix_age_ms,
+
+ MIN(clock_valid) AS min_clock_valid,
+ MAX(clock_valid) AS max_clock_valid,
+ MIN(gps_valid) AS min_gps_valid,
+ MAX(gps_valid) AS max_gps_valid,
+
+ MIN(discipline_age_ms) AS min_discipline_age_ms,
+ AVG(discipline_age_ms) AS avg_discipline_age_ms,
+ MAX(discipline_age_ms) AS max_discipline_age_ms,
+
+ MIN(last_discipline_epoch_ms) AS min_last_discipline_epoch_ms,
+ MAX(last_discipline_epoch_ms) AS max_last_discipline_epoch_ms
+FROM ble_observation
+GROUP BY trial_id, log_file_id, receiver;
+
+-- ---------------------------------------------------------------------------
+-- v_link_rssi_summary
+--
+-- Per receiver/heard link RSSI summary.
+-- ---------------------------------------------------------------------------
+
+CREATE VIEW IF NOT EXISTS v_link_rssi_summary AS
+SELECT
+ trial_id,
+ receiver,
+ heard,
+
+ COUNT(*) AS observation_count,
+
+ MIN(rx_epoch_ms) AS first_rx_epoch_ms,
+ MAX(rx_epoch_ms) AS last_rx_epoch_ms,
+ ROUND((MAX(rx_epoch_ms) - MIN(rx_epoch_ms)) / 1000.0, 3) AS duration_s,
+
+ MIN(rssi) AS min_rssi,
+ AVG(rssi) AS avg_rssi_raw,
+ MAX(rssi) AS max_rssi,
+
+ MIN(avg_rssi) AS min_rolling_avg_rssi,
+ AVG(avg_rssi) AS avg_rolling_avg_rssi,
+ MAX(avg_rssi) AS max_rolling_avg_rssi,
+
+ MIN(payload_seq) AS min_payload_seq,
+ MAX(payload_seq) AS max_payload_seq,
+
+ MIN(clock_valid) AS min_clock_valid,
+ MIN(gps_valid) AS min_gps_valid
+FROM ble_observation
+GROUP BY trial_id, receiver, heard;
+
+-- ---------------------------------------------------------------------------
+-- v_rx_tx_payload_timing_summary
+--
+-- Per receiver/heard timing-delta summary.
+--
+-- rx_tx_payload_delta_ms is useful for detecting clock disagreement,
+-- scheduling, repeated payload observations, and payload-age behavior.
+-- It is not RF propagation delay.
+-- ---------------------------------------------------------------------------
+
+CREATE VIEW IF NOT EXISTS v_rx_tx_payload_timing_summary AS
+SELECT
+ trial_id,
+ receiver,
+ heard,
+
+ COUNT(*) AS observation_count,
+
+ MIN(rx_tx_payload_delta_ms) AS min_rx_tx_payload_delta_ms,
+ AVG(rx_tx_payload_delta_ms) AS avg_rx_tx_payload_delta_ms,
+ MAX(rx_tx_payload_delta_ms) AS max_rx_tx_payload_delta_ms
+FROM ble_observation
+WHERE tx_payload_epoch_ms IS NOT NULL
+ AND tx_payload_epoch_ms > 0
+GROUP BY trial_id, receiver, heard;
+
+-- ---------------------------------------------------------------------------
+-- v_rx_tx_payload_delta_by_seq
+-- ---------------------------------------------------------------------------
+
+CREATE VIEW IF NOT EXISTS v_rx_tx_payload_delta_by_seq AS
+SELECT
+ trial_id,
+ receiver,
+ heard,
+ payload_seq,
+ COUNT(*) AS observations,
+ MIN(rx_tx_payload_delta_ms) AS min_delta_ms,
+ AVG(rx_tx_payload_delta_ms) AS avg_delta_ms,
+ MAX(rx_tx_payload_delta_ms) AS max_delta_ms,
+ MIN(rx_epoch_ms) AS first_rx_epoch_ms,
+ MAX(rx_epoch_ms) AS last_rx_epoch_ms
+FROM ble_observation
+WHERE tx_payload_epoch_ms IS NOT NULL
+ AND tx_payload_epoch_ms > 0
+GROUP BY trial_id, receiver, heard, payload_seq;
+
+-- ---------------------------------------------------------------------------
+-- v_payload_seq_summary
+-- ---------------------------------------------------------------------------
+
+CREATE VIEW IF NOT EXISTS v_payload_seq_summary AS
+SELECT
+ trial_id,
+ receiver,
+ heard,
+ MIN(payload_seq) AS min_payload_seq,
+ MAX(payload_seq) AS max_payload_seq,
+ COUNT(DISTINCT payload_seq) AS distinct_payload_seq,
+ COUNT(*) AS observations,
+ COUNT(*) - COUNT(DISTINCT payload_seq) AS repeated_seq_observations
+FROM ble_observation
+GROUP BY trial_id, receiver, heard;
+
+-- ---------------------------------------------------------------------------
+-- v_payload_seq_receive_spread
+--
+-- Shows how long the same advertised payload sequence was observed by a
+-- receiver. Useful for confirming repeated observation of one payload.
+-- ---------------------------------------------------------------------------
+
+CREATE VIEW IF NOT EXISTS v_payload_seq_receive_spread AS
+SELECT
+ trial_id,
+ receiver,
+ heard,
+ payload_seq,
+ COUNT(*) AS observations,
+ MIN(rx_epoch_ms) AS first_rx_epoch_ms,
+ MAX(rx_epoch_ms) AS last_rx_epoch_ms,
+ MAX(rx_epoch_ms) - MIN(rx_epoch_ms) AS rx_spread_ms,
+ MIN(rx_tx_payload_delta_ms) AS min_delta_ms,
+ AVG(rx_tx_payload_delta_ms) AS avg_delta_ms,
+ MAX(rx_tx_payload_delta_ms) AS max_delta_ms
+FROM ble_observation
+WHERE tx_payload_epoch_ms IS NOT NULL
+ AND tx_payload_epoch_ms > 0
+GROUP BY trial_id, receiver, heard, payload_seq;
+
+-- ---------------------------------------------------------------------------
+-- v_payload_tx_epoch_step
+--
+-- Shows elapsed sender payload-generation time between successive payload
+-- sequence values as observed by each receiver/heard pair.
+-- ---------------------------------------------------------------------------
+
+CREATE VIEW IF NOT EXISTS v_payload_tx_epoch_step AS
+WITH x AS (
+ SELECT
+ trial_id,
+ receiver,
+ heard,
+ payload_seq,
+ MIN(payload_tx_payload_epoch_ms) AS payload_tx_payload_epoch_ms
+ FROM ble_observation
+ WHERE payload_tx_payload_epoch_ms IS NOT NULL
+ AND payload_tx_payload_epoch_ms > 0
+ GROUP BY trial_id, receiver, heard, payload_seq
+),
+y AS (
+ SELECT
+ x.*,
+ LAG(payload_tx_payload_epoch_ms) OVER (
+ PARTITION BY trial_id, receiver, heard
+ ORDER BY payload_seq
+ ) AS prev_payload_tx_payload_epoch_ms
+ FROM x
+)
+SELECT
+ *,
+ payload_tx_payload_epoch_ms - prev_payload_tx_payload_epoch_ms AS tx_payload_epoch_step_ms
+FROM y
+WHERE prev_payload_tx_payload_epoch_ms IS NOT NULL;
+
+-- ---------------------------------------------------------------------------
+-- v_map_observation_points
+--
+-- Mapping-oriented observation view.
+-- ---------------------------------------------------------------------------
+
+CREATE VIEW IF NOT EXISTS v_map_observation_points AS
+SELECT
+ obs_id,
+ trial_id,
+ log_file_id,
+ source_line_no,
+ receiver,
+ heard,
+ rx_epoch_ms,
+ rx_epoch_s,
+ rx_lat,
+ rx_lon,
+ gps_fix_age_ms,
+ clock_valid,
+ gps_valid,
+ discipline_age_ms,
+ last_discipline_epoch_ms,
+ rssi,
+ avg_rssi,
+ payload_seq,
+ tx_payload_epoch_ms,
+ rx_tx_payload_delta_ms
+FROM ble_observation
+WHERE rx_lat IS NOT NULL
+ AND rx_lon IS NOT NULL;
+
+CREATE VIEW IF NOT EXISTS v_map_good_gps_points AS
+SELECT *
+FROM v_map_observation_points
+WHERE clock_valid = 1
+ AND gps_valid = 1
+ AND gps_fix_age_ms <= 5000;
+
+COMMIT;
diff --git a/exercises/26_Bluetooth_discover/scripts/exercise26_ble_rssi_plotly.pl b/exercises/26_Bluetooth_discover/scripts/exercise26_ble_rssi_plotly.pl
new file mode 100644
index 0000000..e9e5e79
--- /dev/null
+++ b/exercises/26_Bluetooth_discover/scripts/exercise26_ble_rssi_plotly.pl
@@ -0,0 +1,626 @@
+#!/usr/bin/env perl
+# 20260526 ChatGPT generated
+# $Id$
+# $HeadURL$
+#
+# sudo ln -s /home/jlpoole/work/perl/exercise26_ble_rssi_plotly.pl /var/www/localhost/cgi-bin/exercise26_ble_rssi_plotly.pl
+# chmod +x /home/jlpoole/work/perl/exercise26_ble_rssi_plotly.pl
+#
+# Example browser URLs:
+# /cgi-bin/exercise26_ble_rssi_plotly.pl
+# /cgi-bin/exercise26_ble_rssi_plotly.pl?receiver=BOB
+# /cgi-bin/exercise26_ble_rssi_plotly.pl?receiver=BOB&value=avg_rssi
+# /cgi-bin/exercise26_ble_rssi_plotly.pl?pairs=BOB:CY,CY:BOB,ED:FLO,FLO:ED
+# /cgi-bin/exercise26_ble_rssi_plotly.pl?pairs=BOB:CY,CY:BOB,ED:FLO,FLO:ED&value=avg_rssi&gps_valid=1&clock_valid=1
+# /cgi-bin/exercise26_ble_rssi_plotly.pl?receiver=FLO&gps_max_ms=5000&bucket_s=5
+#
+# Console test:
+# REQUEST_METHOD=GET QUERY_STRING='receiver=BOB&value=avg_rssi' ./exercise26_ble_rssi_plotly.pl > /tmp/rssi.html
+# ./exercise26_ble_rssi_plotly.pl 'receiver=BOB&value=avg_rssi' > /tmp/rssi.html
+# ./exercise26_ble_rssi_plotly.pl receiver=BOB value=avg_rssi > /tmp/rssi.html
+#
+# Purpose:
+# Emit a Plotly HTML graph from an Exercise 26 BLE Discovery SQLite database.
+# X axis is receiver observation time. Y axis is RSSI or avg_rssi.
+#
+# Database selection:
+# 1. DB_BLE_RSSI_PLOTLY environment variable
+# 2. DB_BLE environment variable
+# 3. hard-coded default below
+#
+# Required Perl modules:
+# DBI
+# DBD::SQLite
+# JSON::PP
+#
+
+use strict;
+use warnings;
+
+use DBI;
+use JSON::PP qw(encode_json);
+
+my $DEFAULT_DB_FILE = '/home/jlpoole/work/tbeam/ble/ble_fieldtest_20260526_1859.sqlite';
+my $DB_FILE = $ENV{DB_BLE_RSSI_PLOTLY} || $ENV{DB_BLE} || $DEFAULT_DB_FILE;
+
+# Prefer a locally staged Plotly file so the graph works without depending on a CDN.
+# Override in Apache or shell with, for example:
+# export PLOTLY_JS_URL=/lib/plotly-2.35.2.min.js
+my $PLOTLY_JS_URL = $ENV{PLOTLY_JS_URL} || '/lib/plotly.min.js';
+
+my @UNITS = qw(AMY BOB CY DAN ED FLO GUY);
+my %VALID_UNIT = map { $_ => 1 } @UNITS;
+
+my %q = parse_query_string();
+
+my $receiver = uc($q{receiver} // '');
+my $heard = uc($q{heard} // '');
+my $pairs_text = uc($q{pairs} // '');
+my $value = lc($q{value} // 'rssi');
+my $gps_max = $q{gps_max_ms};
+my $gps_valid = $q{gps_valid};
+my $clock_valid = $q{clock_valid};
+my $bucket_s = $q{bucket_s};
+my $limit = $q{limit};
+
+if ($receiver ne '' && !$VALID_UNIT{$receiver}) {
+ emit_error(400, "Bad receiver value");
+}
+
+if ($heard ne '' && !$VALID_UNIT{$heard}) {
+ emit_error(400, "Bad heard value");
+}
+
+if ($value !~ /^(?:rssi|avg_rssi)$/) {
+ emit_error(400, "value must be rssi or avg_rssi");
+}
+
+if (defined $gps_max && $gps_max ne '' && $gps_max !~ /^\d+$/) {
+ emit_error(400, "gps_max_ms must be an integer");
+}
+
+for my $flag_name (qw(gps_valid clock_valid)) {
+ my $v = $flag_name eq 'gps_valid' ? $gps_valid : $clock_valid;
+ next unless defined $v && $v ne '';
+ if ($v !~ /^[01]$/) {
+ emit_error(400, "$flag_name must be 0 or 1");
+ }
+}
+
+if (defined $bucket_s && $bucket_s ne '' && $bucket_s !~ /^\d+$/) {
+ emit_error(400, "bucket_s must be an integer number of seconds");
+}
+$bucket_s = 0 unless defined $bucket_s && $bucket_s ne '';
+$bucket_s = int($bucket_s);
+if ($bucket_s < 0 || $bucket_s > 3600) {
+ emit_error(400, "bucket_s must be between 0 and 3600");
+}
+
+if (defined $limit && $limit ne '' && $limit !~ /^\d+$/) {
+ emit_error(400, "limit must be an integer");
+}
+$limit = 0 unless defined $limit && $limit ne '';
+$limit = int($limit);
+if ($limit < 0 || $limit > 1_000_000) {
+ emit_error(400, "limit must be between 0 and 1000000");
+}
+
+my @pairs = parse_pairs($pairs_text);
+
+if (!-f $DB_FILE) {
+ emit_error(500, "Database not found: $DB_FILE");
+}
+
+my $dbh = DBI->connect(
+ "dbi:SQLite:dbname=$DB_FILE",
+ "",
+ "",
+ {
+ RaiseError => 1,
+ AutoCommit => 1,
+ sqlite_unicode => 1,
+ }
+);
+
+my $traces = fetch_rssi_traces(
+ $dbh,
+ receiver => $receiver,
+ heard => $heard,
+ pairs => \@pairs,
+ value => $value,
+ gps_max => $gps_max,
+ gps_valid => $gps_valid,
+ clock_valid => $clock_valid,
+ bucket_s => $bucket_s,
+ limit => $limit,
+);
+
+my $summary = fetch_summary(
+ $dbh,
+ receiver => $receiver,
+ heard => $heard,
+ pairs => \@pairs,
+ value => $value,
+ gps_max => $gps_max,
+ gps_valid => $gps_valid,
+ clock_valid => $clock_valid,
+);
+
+$dbh->disconnect;
+
+emit_html($traces, $summary);
+exit 0;
+
+sub fetch_rssi_traces {
+ my ($dbh, %arg) = @_;
+
+ my @where = (
+ 'rx_epoch_ms IS NOT NULL',
+ 'receiver IS NOT NULL',
+ 'heard IS NOT NULL',
+ "$arg{value} IS NOT NULL",
+ );
+ my @bind;
+
+ if ($arg{receiver} ne '') {
+ push @where, 'receiver = ?';
+ push @bind, $arg{receiver};
+ }
+
+ if ($arg{heard} ne '') {
+ push @where, 'heard = ?';
+ push @bind, $arg{heard};
+ }
+
+ if (@{ $arg{pairs} }) {
+ my @pair_terms;
+ for my $p (@{ $arg{pairs} }) {
+ push @pair_terms, '(receiver = ? AND heard = ?)';
+ push @bind, $p->{receiver}, $p->{heard};
+ }
+ push @where, '(' . join(' OR ', @pair_terms) . ')';
+ }
+
+ if (defined $arg{gps_max} && $arg{gps_max} ne '') {
+ push @where, 'gps_fix_age_ms <= ?';
+ push @bind, int($arg{gps_max});
+ }
+
+ if (defined $arg{gps_valid} && $arg{gps_valid} ne '') {
+ push @where, 'gps_valid = ?';
+ push @bind, int($arg{gps_valid});
+ }
+
+ if (defined $arg{clock_valid} && $arg{clock_valid} ne '') {
+ push @where, 'clock_valid = ?';
+ push @bind, int($arg{clock_valid});
+ }
+
+ my $select_sql;
+ my $order_by;
+
+ if ($arg{bucket_s}) {
+ my $bucket_ms = $arg{bucket_s} * 1000;
+ $select_sql = qq{
+ SELECT
+ receiver,
+ heard,
+ CAST((rx_epoch_ms / $bucket_ms) AS INTEGER) * $bucket_ms AS plot_epoch_ms,
+ AVG($arg{value}) AS plot_rssi,
+ COUNT(*) AS row_count,
+ MIN(rssi) AS min_rssi,
+ MAX(rssi) AS max_rssi,
+ MIN(vbat_mv) AS min_vbat_mv,
+ MAX(gps_fix_age_ms) AS max_gps_fix_age_ms,
+ MIN(clock_valid) AS min_clock_valid,
+ MIN(gps_valid) AS min_gps_valid
+ FROM ble_observation
+ WHERE } . join(' AND ', @where) . qq{
+ GROUP BY receiver, heard, plot_epoch_ms
+ };
+ $order_by = ' ORDER BY receiver, heard, plot_epoch_ms';
+ }
+ else {
+ # Important: do not mix aggregate functions such as COUNT/MIN/MAX with
+ # non-aggregate row columns here. SQLite will otherwise collapse the
+ # result to one arbitrary row, which looks like a single blue dot.
+ $select_sql = qq{
+ SELECT
+ receiver,
+ heard,
+ rx_epoch_ms AS plot_epoch_ms,
+ $arg{value} AS plot_rssi,
+ 1 AS row_count,
+ rssi AS min_rssi,
+ rssi AS max_rssi,
+ vbat_mv AS min_vbat_mv,
+ gps_fix_age_ms AS max_gps_fix_age_ms,
+ clock_valid AS min_clock_valid,
+ gps_valid AS min_gps_valid
+ FROM ble_observation
+ WHERE } . join(' AND ', @where) . qq{
+ };
+ $order_by = ' ORDER BY receiver, heard, rx_epoch_ms, obs_id';
+ }
+
+ my $sql = $select_sql . $order_by;
+
+ if ($arg{limit}) {
+ $sql .= ' LIMIT ?';
+ push @bind, $arg{limit};
+ }
+
+ my $sth = $dbh->prepare($sql);
+ $sth->execute(@bind);
+
+ my %series;
+ while (my $r = $sth->fetchrow_hashref) {
+ my $key = $r->{receiver} . ' hears ' . $r->{heard};
+
+ push @{ $series{$key}{x_ms} }, 0 + $r->{plot_epoch_ms};
+ push @{ $series{$key}{y} }, numeric($r->{plot_rssi});
+ push @{ $series{$key}{customdata} }, [
+ $r->{receiver},
+ $r->{heard},
+ $r->{row_count},
+ $r->{min_rssi},
+ $r->{max_rssi},
+ $r->{min_vbat_mv},
+ $r->{max_gps_fix_age_ms},
+ $r->{min_clock_valid},
+ $r->{min_gps_valid},
+ ];
+ $series{$key}{receiver} = $r->{receiver};
+ $series{$key}{heard} = $r->{heard};
+ }
+
+ my @traces;
+ for my $key (sort keys %series) {
+ push @traces, {
+ name => $key,
+ mode => 'lines+markers',
+ type => 'scatter',
+ x_ms => $series{$key}{x_ms},
+ y => $series{$key}{y},
+ customdata => $series{$key}{customdata},
+ hovertemplate => join('',
+ '%{customdata[0]} hears %{customdata[1]}
',
+ 'time=%{x}
',
+ 'RSSI=%{y:.1f} dBm
',
+ 'rows=%{customdata[2]}
',
+ 'rssi range=%{customdata[3]} to %{customdata[4]}
',
+ 'min vbat=%{customdata[5]} mV
',
+ 'max gps age=%{customdata[6]} ms
',
+ 'clock_valid=%{customdata[7]} gps_valid=%{customdata[8]}',
+ '
SSID: "); body += htmlEscape(ssid_); body += F("
IP: "); body += htmlEscape(ip_.toString()); body += F("
SD: "); - body += (storage_ && storage_->ready()) ? F("mounted") : F("not mounted"); + body += sdReady ? F("mounted") : F("not mounted"); + body += F("
Log entries: "); + if (sdReady) { + body += String((unsigned long)logsCount); + if (countTruncated) { + body += F("+"); + } + } else { + body += F("unavailable"); + } body += F("
Stations: "); body += String(stationCount()); body += F("
Files "); @@ -154,7 +167,7 @@ void TBeamWeb::handleFiles() { body += F("
Path: "); body += htmlEscape(path); body += F("