After trial upon which import_exercise_26_ble_log.pl & schema were created.

This commit is contained in:
John Poole 2026-05-25 14:10:58 -07:00
commit aaa765adf8
6 changed files with 1378 additions and 70 deletions

View file

@ -67,7 +67,7 @@ Functional requirements:
- today's date
- ChatGPT/Codex generated
- Subversion keywords: $Id$ and $HeadURL$
18. Add logging on the SD card using YYYYMMDD_HHMISS_[UNIT NAME]_ble_seach.log. Logging should include date + time, both epoch high precision and regular human readable (not high precision), GPS coordinates of current receiving unit, the advertisement received, the RSSI strength of the received transmission.
18. Add logging on the SD card using YYYYMMDD_HHMISS_[UNIT NAME]_ble_search.log. Logging should include date + time, both epoch high precision and regular human readable (not high precision), GPS coordinates of current receiving unit, the advertisement received, the RSSI strength of the received transmission.
19. Include web interface such as in Exercise 18, or later -- I cannot remember for sure, that allows downloading of the log files and deletion of the log files. We had a system where each unit had a unique IP for it, use that.
Implementation details:

View file

@ -26,12 +26,13 @@ pio device monitor -b 115200 -p /dev/ttytBOB
## Behavior
- Advertises manufacturer data in this format: `TBMSND|1|NODE|seq|uptime`.
- Accepts only advertisements with prefix `TBMSND`, version `1`, and node name in `AMY, BOB, CY, DAN, ED, FLO, GUY`.
- Advertises manufacturer data in this format: `B2|NODE|seq|tx_epoch_ms`.
- Accepts current `B2` advertisements and legacy `TBMSND|1|NODE|seq|uptime` advertisements from known nodes in `AMY, BOB, CY, DAN, ED, FLO, GUY`.
- Displays heard nodes sorted by rolling RSSI average, strongest first.
- Drops stale entries after 20 seconds.
- Logs accepted advertisements to SD as `/logs/YYYYMMDD_HHMISS_NODE_ble_seach.log`.
- Starts a WiFi AP for log access when SD is available.
- Logs accepted advertisements to SD as `/logs/YYYYMMDD_HHMISS_NODE_ble_search.log`.
- Refreshes the receiver GPS position at 1 Hz and logs the last known receiver coordinates plus GPS fix age.
- Starts a WiFi AP and HTTP web service during boot. This does not wait for GPS.
Default web addresses:
@ -78,25 +79,25 @@ pio device monitor -b 115200 -p /dev/ttytAMY | tee logs/ble_discovery_AMY_YYYYMM
Here are two logs from ED and FLO which were activated in the field.
```bash
jlpoole@jp ~/work/tbeam/logs $ ls -lath ed/*_16*
-rw-r--r-- 1 jlpoole jlpoole 1.1M May 25 09:53 ed/20260525_162217_ED_ble_seach.log
-rw-r--r-- 1 jlpoole jlpoole 1.1M May 25 09:53 ed/20260525_162217_ED_ble_search.log
jlpoole@jp ~/work/tbeam/logs $ ls -lath flo/*_16*
-rw-r--r-- 1 jlpoole jlpoole 1.1M May 25 09:53 flo/20260525_162213_FLO_ble_seach.log
-rw-r--r-- 1 jlpoole jlpoole 1.1M May 25 09:53 flo/20260525_162213_FLO_ble_search.log
jlpoole@jp ~/work/tbeam/logs $
```
Here are start and end samples from both:
Here are start and end samples from an earlier trial. These show the previous 12-column schema; current firmware uses the 14-column schema described below.
```bash
jlpoole@jp ~/work/tbeam/logs $ cat -n ed/20260525_162217_ED_ble_seach.log|head -n 3
jlpoole@jp ~/work/tbeam/logs $ cat -n ed/20260525_162217_ED_ble_search.log|head -n 3
1 human_time,epoch_ms,receiver,lat,lon,heard,rssi,avg_rssi,age_s,count,seq,payload
2 2026-05-25 16:22:18,1779726138897,ED,44.9364577,-123.0218702,FLO,-56,-56,0,1,1,TBMSND|1|FLO|0001|0775
3 2026-05-25 16:22:18,1779726138938,ED,44.9364577,-123.0218702,FLO,-51,-54,0,2,1,TBMSND|1|FLO|0001|0775
jlpoole@jp ~/work/tbeam/logs $ cat -n ed/20260525_162217_ED_ble_seach.log|tail -n 1
jlpoole@jp ~/work/tbeam/logs $ cat -n ed/20260525_162217_ED_ble_search.log|tail -n 1
10277 2026-05-25 16:47:42,1779727662217,ED,44.9364577,-123.0218702,FLO,-42,-42,0,10276,611,TBMSND|1|FLO|0611|2300
jlpoole@jp ~/work/tbeam/logs $ cat -n flo/20260525_162213_FLO_ble_seach.log|head -n 3
jlpoole@jp ~/work/tbeam/logs $ cat -n flo/20260525_162213_FLO_ble_search.log|head -n 3
1 human_time,epoch_ms,receiver,lat,lon,heard,rssi,avg_rssi,age_s,count,seq,payload
2 2026-05-25 16:22:16,1779726136737,FLO,44.9365132,-123.0218183,ED,-52,-52,0,1,0,TBMSND|1|ED|0000|0805
3 2026-05-25 16:22:16,1779726136829,FLO,44.9365132,-123.0218183,ED,-51,-52,0,2,0,TBMSND|1|ED|0000|0805
jlpoole@jp ~/work/tbeam/logs $ cat -n flo/20260525_162213_FLO_ble_seach.log|tail -n 1
jlpoole@jp ~/work/tbeam/logs $ cat -n flo/20260525_162213_FLO_ble_search.log|tail -n 1
10121 2026-05-25 16:47:37,1779727657219,FLO,44.9365132,-123.0218183,ED,-41,-40,0,10120,608,TBMSND|1|ED|0608|2325
jlpoole@jp ~/work/tbeam/logs $
```
@ -104,61 +105,54 @@ The header represents:
| Column | Header | Explanation |
| ---: | --- | --- |
| 1 | `human_time` | Receiver timestamp in human-readable UTC form based on Greenwich Mean Time ("G<T") (default for T-Beam units). |
| 2 | `epoch_ms` | Receiver timestamp as Unix epoch milliseconds GMT. |
| 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 | `lat` | Receiver GPS latitude at the time of the row. |
| 5 | `lon` | Receiver GPS longitude at the time of the row. |
| 6 | `heard` | Remote unit heard in the BLE advertisement. |
| 7 | `rssi` | RSSI measured by the receiver for this advertisement. |
| 8 | `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.Defined in main.cpp at:
static constexpr uint8_t kRssiWindow = 5;
|
| 9 | `age_s` | Age in seconds of the displayed/heard entry. |
| 10 | `count` | Number of accepted advertisements from that heard unit. |
| 11 | `seq` | Sequence number advertised by the heard unit. |
| 12 | `payload` | (See section Payload Definition below.)|
| 4 | `rx_lat` | Receiver GPS latitude from the latest valid local GPS fix. |
| 5 | `rx_lon` | Receiver GPS longitude from the latest valid local GPS fix. |
| 6 | `rx_gps_age_ms` | Age of the receiver GPS fix in milliseconds when the row was written. If GPS becomes unavailable, the last known coordinates remain and this age grows. |
| 7 | `heard` | Remote unit heard in the BLE advertisement. |
| 8 | `rssi` | RSSI measured by the receiver for this advertisement. |
| 9 | `avg_rssi` | Receiver-calculated arithmetic mean of the most recent RSSI measurements for this heard unit, using up to the last 5 accepted advertisements and rounded to the nearest integer. The window size is `kRssiWindow = 5` in `main.cpp`. |
| 10 | `age_s` | Age in seconds of the displayed/heard entry. |
| 11 | `count` | Number of accepted advertisements from that heard unit. |
| 12 | `seq` | Sequence number advertised by the heard unit. |
| 13 | `tx_epoch_ms` | Sender timestamp from the BLE payload as Unix epoch milliseconds, when available. Legacy v1 payloads report `0` here. |
| 14 | `payload` | Raw BLE manufacturer-data string. See Payload Definition below. |
## Payload definition
`payload` is the exact BLE manufacturer-data string received from the other unit. It is currently defined in [main.cpp](/usr/local/src/microreticulum/microReticulumTbeam/exercises/26_Bluetooth_discover/src/main.cpp:389) as:
```cpp
"%s|%u|%s|%04lu|%04lu"
```
So the format is:
`payload` is the exact BLE manufacturer-data string received from the other unit. Current firmware advertises a compact v2 text payload:
```text
TBMSND|1|NODE|SEQ|UPTIME
B2|NODE|SEQ|TX_EPOCH_MS
```
Example:
```text
TBMSND|1|FLO|0611|2300
B2|FLO|0611|1779727662217
```
Meaning:
| Part | Example | Meaning |
| --- | --- | --- |
| `TBMSND` | `TBMSND` | Exercise/project BLE prefix. Receiver ignores payloads without this prefix. |
| `1` | `1` | Payload protocol version. Receiver accepts only version `1`. |
| `B2` | `B2` | Compact Exercise 26 payload prefix and version. |
| `NODE` | `FLO` | Sending unit name. Receiver accepts only known units and ignores itself. |
| `SEQ` | `0611` | Senders advertisement sequence number, zero-padded, wraps every 10,000 advertisements. |
| `UPTIME` | `2300` | Sender uptime in seconds, zero-padded, modulo 10,000 seconds. |
| `TX_EPOCH_MS` | `1779727662217` | Sender timestamp as Unix epoch milliseconds, derived from the disciplined local clock. |
The numbers you are seeing are the last two fields:
- `0611`: the senders advertisement sequence counter.
- `2300`: the senders uptime seconds modulo 10,000.
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 equal `TBMSND`.
- Version must equal `1`.
- 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 receivers own name.
- The receiver parses `SEQ`, but currently does not parse or use `UPTIME`; it just preserves the full raw payload in the log.
-
- Current `B2` payloads provide `SEQ` and `TX_EPOCH_MS`; legacy v1 payloads provide `SEQ` only and log `tx_epoch_ms` as `0`.

View file

@ -0,0 +1,379 @@
-- $Id$
-- $HeadURL$
--
-- Example:
-- sqlite3 ble_fieldtest_20260525_1945_ed_flo.sqlite < create_exercise_26_ble_schema.sql
--
-- SQLite schema for Exercise 26 BLE Discovery field-test logs.
--
-- Design goals:
-- 1. Preserve the raw log rows.
-- 2. Preserve per-log manifest metadata.
-- 3. Normalize parsed BLE observations.
-- 4. Keep one portable SQLite database per field trial.
-- 5. Support later export to GeoJSON / MapLibre / GIS tools.
--
-- Current Exercise 26 log format:
--
-- human_time,rx_epoch_ms,receiver,rx_lat,rx_lon,rx_gps_age_ms,
-- heard,rssi,avg_rssi,age_s,count,seq,tx_epoch_ms,payload
--
-- Current payload format:
--
-- B2|NODE|SEQ|TX_EPOCH_MS
--
-- Legacy payload format:
--
-- TBMSND|1|NODE|SEQ|UPTIME
--
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_ed_flo_20260525_1945
-- ---------------------------------------------------------------------------
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.
--
-- This table is intentionally sparse. It allows later enrichment with
-- hardware-specific observations such as GNSS chip, SD card problems,
-- antenna notes, case color, battery notes, etc.
-- ---------------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS unit (
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.
--
-- The sha256 column allows repeatable provenance checks and prevents accidental
-- duplicate ingestion of the same log.
-- ---------------------------------------------------------------------------
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.
--
-- This preserves metadata exactly as supplied outside the CSV log.
-- The importer also copies selected keys into trial/log_file columns.
-- ---------------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS log_manifest_kv (
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 CSV log.
--
-- This table is meant to preserve the CSV fields as received, with minimal
-- interpretation. Parsed/normalized fields live in ble_observation.
-- ---------------------------------------------------------------------------
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,
rx_gps_age_ms INTEGER,
heard TEXT,
rssi INTEGER,
avg_rssi INTEGER,
age_s INTEGER,
count INTEGER,
seq INTEGER,
tx_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);
-- ---------------------------------------------------------------------------
-- ble_observation
--
-- Parsed and normalized BLE observation table.
--
-- This table keeps the important analytical values in typed columns.
-- rx_tx_delta_ms is the receiver timestamp minus the sender timestamp.
--
-- rx_tx_delta_ms is not a pure radio propagation delay. It includes BLE
-- advertising/scanning timing, firmware scheduling, clock error, and logging
-- latency. It is still useful for clock-alignment diagnostics.
-- ---------------------------------------------------------------------------
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_epoch_ms INTEGER,
tx_epoch_s REAL,
rx_tx_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,
rx_gps_age_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_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);
-- ---------------------------------------------------------------------------
-- v_log_file_summary
--
-- Per-log 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 summary in degrees.
--
-- Distance conversion is intentionally left outside SQLite because plain SQLite
-- may not have trigonometric math functions compiled in.
-- ---------------------------------------------------------------------------
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(rx_gps_age_ms) AS min_rx_gps_age_ms,
AVG(rx_gps_age_ms) AS avg_rx_gps_age_ms,
MAX(rx_gps_age_ms) AS max_rx_gps_age_ms
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
FROM ble_observation
GROUP BY trial_id, receiver, heard;
-- ---------------------------------------------------------------------------
-- v_rx_tx_timing_summary
--
-- Per receiver/heard timing-delta summary.
--
-- rx_tx_delta_ms is useful for detecting clock disagreement, restarts, and
-- logging/scan oddities. It should not be interpreted as RF travel time.
-- ---------------------------------------------------------------------------
CREATE VIEW IF NOT EXISTS v_rx_tx_timing_summary AS
SELECT
trial_id,
receiver,
heard,
COUNT(*) AS observation_count,
MIN(rx_tx_delta_ms) AS min_rx_tx_delta_ms,
AVG(rx_tx_delta_ms) AS avg_rx_tx_delta_ms,
MAX(rx_tx_delta_ms) AS max_rx_tx_delta_ms
FROM ble_observation
WHERE tx_epoch_ms IS NOT NULL
AND tx_epoch_ms > 0
GROUP BY trial_id, receiver, heard;
COMMIT;

View file

@ -0,0 +1,141 @@
#!/usr/bin/awk -f
# $Id$
# $HeadURL$
#
# Example:
# awk -f exercise_26_smoke_test.awk "$FLO_LOG"
# awk -f exercise_26_smoke_test.awk "$ED_LOG"
#
# BLE Exercise 26 GPS smoke test.
BEGIN {
FS = ","
# Approximate feet per degree latitude.
# Good enough for a smoke test around Salem, Oregon.
FEET_PER_DEG_LAT = 364000
}
NR == 1 { next }
$4 != "" && $5 != "" {
n++
lat = $4 + 0
lon = $5 + 0
gps_age = $6 + 0
if (n == 1) {
first_time = $1
first_lat = lat
first_lon = lon
min_lat = max_lat = lat
min_lon = max_lon = lon
min_gps_age = max_gps_age = gps_age
}
dlat_ft = (lat - first_lat) * FEET_PER_DEG_LAT
pi = atan2(0, -1)
first_lat_rad = first_lat * pi / 180
feet_per_deg_lon_here = FEET_PER_DEG_LAT * cos(first_lat_rad)
dlon_ft = (lon - first_lon) * feet_per_deg_lon_here
dist_from_start_ft = sqrt(dlat_ft * dlat_ft + dlon_ft * dlon_ft)
if (dist_from_start_ft > max_dist_from_start_ft) {
max_dist_from_start_ft = dist_from_start_ft
max_dist_time = $1
max_dist_lat = lat
max_dist_lon = lon
}
last_time = $1
last_lat = lat
last_lon = lon
if (n % 2 == 0) {
mid_time = $1
mid_lat = lat
mid_lon = lon
}
key = sprintf("%.7f,%.7f", lat, lon)
seen[key]++
sum_lat += lat
sum_lon += lon
sum2_lat += lat * lat
sum2_lon += lon * lon
sum_gps_age += gps_age
if (gps_age < min_gps_age) min_gps_age = gps_age
if (gps_age > max_gps_age) max_gps_age = gps_age
if (lat < min_lat) min_lat = lat
if (lat > max_lat) max_lat = lat
if (lon < min_lon) min_lon = lon
if (lon > max_lon) max_lon = lon
}
END {
if (n < 2) {
print "Not enough coordinate rows"
exit
}
mean_lat = sum_lat / n
mean_lon = sum_lon / n
sd_lat = sqrt((sum2_lat - n * mean_lat * mean_lat) / (n - 1))
sd_lon = sqrt((sum2_lon - n * mean_lon * mean_lon) / (n - 1))
lat_range_deg = max_lat - min_lat
lon_range_deg = max_lon - min_lon
pi = atan2(0, -1)
mean_lat_rad = mean_lat * pi / 180
feet_per_deg_lon = FEET_PER_DEG_LAT * cos(mean_lat_rad)
lat_range_ft = lat_range_deg * FEET_PER_DEG_LAT
lon_range_ft = lon_range_deg * feet_per_deg_lon
sd_lat_ft = sd_lat * FEET_PER_DEG_LAT
sd_lon_ft = sd_lon * feet_per_deg_lon
diag_ft = sqrt(lat_range_ft * lat_range_ft + lon_range_ft * lon_range_ft)
printf "rows: %d\n", n
printf "distinct coordinate pairs: %d\n", length(seen)
printf "\n"
printf "first: %s %.7f, %.7f\n", first_time, first_lat, first_lon
printf "mid: %s %.7f, %.7f\n", mid_time, mid_lat, mid_lon
printf "last: %s %.7f, %.7f\n", last_time, last_lat, last_lon
printf "\n"
printf "mean_lat: %.8f\n", mean_lat
printf "mean_lon: %.8f\n", mean_lon
printf "sd_lat: %.10f degrees %.2f ft\n", sd_lat, sd_lat_ft
printf "sd_lon: %.10f degrees %.2f ft\n", sd_lon, sd_lon_ft
printf "\n"
printf "min_lat: %.8f\n", min_lat
printf "max_lat: %.8f\n", max_lat
printf "min_lon: %.8f\n", min_lon
printf "max_lon: %.8f\n", max_lon
printf "\n"
printf "lat_range: %.10f degrees %.2f ft north/south\n", lat_range_deg, lat_range_ft
printf "lon_range: %.10f degrees %.2f ft east/west\n", lon_range_deg, lon_range_ft
printf "bbox_diagonal: %.2f ft\n", diag_ft
printf "\n"
printf "gps_age_ms min/avg/max: %.0f / %.1f / %.0f\n", min_gps_age, sum_gps_age / n, max_gps_age
printf "max_dist_from_start: %.2f ft\n", max_dist_from_start_ft
printf "max_dist_time: %s\n", max_dist_time
printf "max_dist_coord: %.7f, %.7f\n", max_dist_lat, max_dist_lon
}

View file

@ -0,0 +1,732 @@
#!/usr/bin/env perl
# $Id$
# $HeadURL$
#
# Example:
#
# sqlite3 ble_fieldtest_20260525_1945_ed_flo.sqlite < create_exercise_26_ble_schema.sql
#
# ./import_exercise_26_ble_log.pl \
# --db ble_fieldtest_20260525_1945_ed_flo.sqlite \
# --log "$FLO_LOG"
#
# ./import_exercise_26_ble_log.pl \
# --db ble_fieldtest_20260525_1945_ed_flo.sqlite \
# --log "$ED_LOG"
#
# Manifest:
#
# The importer expects a manifest file with the same name as the log plus
# ".manifest" appended.
#
# Example:
# flo/20260525_194537_FLO_ble_search.log.manifest
#
# Simple key=value format:
#
# trial_name=Peck Cottage ED FLO BLE walk test 20260525_1945
# trial_label=peck_cottage_ed_flo_20260525_1945
# firmware_exercise=26_Bluetooth_discover
# firmware_git_commit=unknown
# field_site=Peck Cottage steps
# operator=jlpoole
# receiver_role=moving
# receiver_start_description=upside-down plastic pot at Peck Cottage steps
# receiver_notes=FLO was hand-carried north and across the street, then returned to base.
# test_notes=Two-unit BLE discovery test. ED stationary, FLO moving.
#
# Purpose:
#
# Import Exercise 26 BLE Discovery logs into SQLite.
#
# Required Perl modules:
#
# DBI
# DBD::SQLite
# Digest::SHA
# Getopt::Long
# File::Basename
#
# Gentoo package hints:
#
# emerge --ask dev-perl/DBI dev-perl/DBD-SQLite
#
use strict;
use warnings;
use DBI;
use Digest::SHA qw(sha256_hex);
use File::Basename qw(basename);
use Getopt::Long qw(GetOptions);
my $db_file;
my $log_file;
my $help;
GetOptions(
'db=s' => \$db_file,
'log=s' => \$log_file,
'help' => \$help,
) or die usage();
if ($help) {
print usage();
exit 0;
}
die usage() unless defined $db_file && defined $log_file;
die "Database file does not exist: $db_file\n"
unless -f $db_file;
die "Log file does not exist: $log_file\n"
unless -f $log_file;
my $manifest_file = $log_file . ".manifest";
die "Manifest file does not exist: $manifest_file\n"
unless -f $manifest_file;
my %manifest = read_manifest($manifest_file);
my $trial_label = required_manifest(\%manifest, 'trial_label');
my $trial_name = required_manifest(\%manifest, 'trial_name');
my $sha256 = file_sha256($log_file);
my $byte_count = -s $log_file;
my $base_name = basename($log_file);
my $dbh = DBI->connect(
"dbi:SQLite:dbname=$db_file",
"",
"",
{
RaiseError => 1,
AutoCommit => 0,
sqlite_unicode => 1,
}
);
$dbh->do('PRAGMA foreign_keys = ON');
eval {
my $existing = $dbh->selectrow_array(
'SELECT log_file_id FROM log_file WHERE sha256 = ?',
undef,
$sha256
);
if (defined $existing) {
die "This log already appears to be imported: log_file_id=$existing sha256=$sha256\n";
}
my $trial_id = upsert_trial($dbh, \%manifest);
my $log_stats = scan_log_for_stats($log_file);
my $receiver = $log_stats->{receiver}
or die "Could not determine receiver from log: $log_file\n";
upsert_unit($dbh, $receiver);
my $log_file_id = insert_log_file(
$dbh,
trial_id => $trial_id,
receiver => $receiver,
path => $log_file,
basename => $base_name,
manifest_path => $manifest_file,
sha256 => $sha256,
byte_count => $byte_count,
source_row_count => $log_stats->{source_row_count},
first_rx_epoch_ms => $log_stats->{first_rx_epoch_ms},
last_rx_epoch_ms => $log_stats->{last_rx_epoch_ms},
receiver_role => $manifest{receiver_role},
receiver_start_description => $manifest{receiver_start_description},
receiver_notes => $manifest{receiver_notes},
test_notes => $manifest{test_notes},
);
insert_manifest_kv($dbh, $log_file_id, \%manifest);
import_log_rows(
$dbh,
trial_id => $trial_id,
log_file_id => $log_file_id,
log_file => $log_file,
);
update_trial_time_bounds($dbh, $trial_id);
$dbh->commit;
print "Imported log successfully\n";
print " database: $db_file\n";
print " log: $log_file\n";
print " manifest: $manifest_file\n";
print " trial_id: $trial_id\n";
print " log_file_id: $log_file_id\n";
print " receiver: $receiver\n";
print " rows: $log_stats->{source_row_count}\n";
print " sha256: $sha256\n";
};
if ($@) {
my $err = $@;
eval { $dbh->rollback };
die $err;
}
$dbh->disconnect;
exit 0;
sub usage {
return <<"EOF";
Usage:
$0 --db DATABASE.sqlite --log path/to/log.csv
Example:
$0 --db ble_fieldtest_20260525_1945_ed_flo.sqlite --log "\$FLO_LOG"
The importer expects a manifest beside the log:
path/to/log.csv.manifest
EOF
}
sub required_manifest {
my ($manifest_ref, $key) = @_;
die "Manifest is missing required key: $key\n"
unless exists $manifest_ref->{$key}
&& defined $manifest_ref->{$key}
&& $manifest_ref->{$key} ne '';
return $manifest_ref->{$key};
}
sub read_manifest {
my ($path) = @_;
open my $fh, '<', $path
or die "Could not open manifest $path: $!\n";
my %manifest;
while (my $line = <$fh>) {
chomp $line;
$line =~ s/\r\z//;
next if $line =~ /^\s*$/;
next if $line =~ /^\s*#/;
die "Bad manifest line, expected key=value: $line\n"
unless $line =~ /^\s*([^=]+?)\s*=\s*(.*?)\s*$/;
my ($key, $value) = ($1, $2);
$key =~ s/^\s+|\s+$//g;
$value =~ s/^\s+|\s+$//g;
die "Empty manifest key in line: $line\n"
if $key eq '';
$manifest{$key} = $value;
}
close $fh;
return %manifest;
}
sub file_sha256 {
my ($path) = @_;
open my $fh, '<:raw', $path
or die "Could not open $path for SHA-256: $!\n";
my $ctx = Digest::SHA->new(256);
$ctx->addfile($fh);
close $fh;
return $ctx->hexdigest;
}
sub scan_log_for_stats {
my ($path) = @_;
open my $fh, '<', $path
or die "Could not open log $path: $!\n";
my $header = <$fh>;
die "Empty log file: $path\n" unless defined $header;
chomp $header;
$header =~ s/\r\z//;
my @cols = split /,/, $header, -1;
validate_header(@cols);
my $row_count = 0;
my $receiver;
my $first_rx_epoch_ms;
my $last_rx_epoch_ms;
while (my $line = <$fh>) {
chomp $line;
$line =~ s/\r\z//;
next if $line =~ /^\s*$/;
my @f = split /,/, $line, -1;
next unless @f >= 14;
$row_count++;
my $rx_epoch_ms = to_int_or_undef($f[1]);
my $rx_receiver = $f[2];
$receiver //= $rx_receiver;
if (defined $rx_epoch_ms) {
$first_rx_epoch_ms //= $rx_epoch_ms;
$last_rx_epoch_ms = $rx_epoch_ms;
}
}
close $fh;
return {
receiver => $receiver,
source_row_count => $row_count,
first_rx_epoch_ms => $first_rx_epoch_ms,
last_rx_epoch_ms => $last_rx_epoch_ms,
};
}
sub validate_header {
my (@cols) = @_;
my @expected = qw(
human_time
rx_epoch_ms
receiver
rx_lat
rx_lon
rx_gps_age_ms
heard
rssi
avg_rssi
age_s
count
seq
tx_epoch_ms
payload
);
die "Unexpected column count in header. Expected 14, got " . scalar(@cols) . "\n"
unless @cols == @expected;
for my $i (0 .. $#expected) {
die "Unexpected header column " . ($i + 1) . ". Expected '$expected[$i]', got '$cols[$i]'\n"
unless $cols[$i] eq $expected[$i];
}
}
sub upsert_trial {
my ($dbh, $manifest_ref) = @_;
my $trial_label = required_manifest($manifest_ref, 'trial_label');
my $trial_name = required_manifest($manifest_ref, 'trial_name');
my $existing = $dbh->selectrow_array(
'SELECT trial_id FROM trial WHERE trial_label = ?',
undef,
$trial_label
);
return $existing if defined $existing;
my $sth = $dbh->prepare(q{
INSERT INTO trial (
trial_label,
trial_name,
field_site,
operator,
firmware_exercise,
firmware_git_commit,
notes
)
VALUES (?, ?, ?, ?, ?, ?, ?)
});
$sth->execute(
$trial_label,
$trial_name,
$manifest_ref->{field_site},
$manifest_ref->{operator},
$manifest_ref->{firmware_exercise},
$manifest_ref->{firmware_git_commit},
$manifest_ref->{test_notes},
);
return $dbh->sqlite_last_insert_rowid;
}
sub upsert_unit {
my ($dbh, $unit_name) = @_;
return unless defined $unit_name && $unit_name ne '';
my $sth = $dbh->prepare(q{
INSERT OR IGNORE INTO unit (unit_name)
VALUES (?)
});
$sth->execute($unit_name);
}
sub insert_log_file {
my ($dbh, %arg) = @_;
my $sth = $dbh->prepare(q{
INSERT INTO log_file (
trial_id,
receiver,
path,
basename,
manifest_path,
sha256,
byte_count,
source_row_count,
first_rx_epoch_ms,
last_rx_epoch_ms,
receiver_role,
receiver_start_description,
receiver_notes,
test_notes
)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
});
$sth->execute(
$arg{trial_id},
$arg{receiver},
$arg{path},
$arg{basename},
$arg{manifest_path},
$arg{sha256},
$arg{byte_count},
$arg{source_row_count},
$arg{first_rx_epoch_ms},
$arg{last_rx_epoch_ms},
$arg{receiver_role},
$arg{receiver_start_description},
$arg{receiver_notes},
$arg{test_notes},
);
return $dbh->sqlite_last_insert_rowid;
}
sub insert_manifest_kv {
my ($dbh, $log_file_id, $manifest_ref) = @_;
my $sth = $dbh->prepare(q{
INSERT INTO log_manifest_kv (log_file_id, key, value)
VALUES (?, ?, ?)
});
for my $key (sort keys %{$manifest_ref}) {
$sth->execute($log_file_id, $key, $manifest_ref->{$key});
}
}
sub import_log_rows {
my ($dbh, %arg) = @_;
my $trial_id = $arg{trial_id};
my $log_file_id = $arg{log_file_id};
my $log_file = $arg{log_file};
open my $fh, '<', $log_file
or die "Could not open log $log_file: $!\n";
my $header = <$fh>;
die "Empty log file: $log_file\n" unless defined $header;
chomp $header;
$header =~ s/\r\z//;
validate_header(split /,/, $header, -1);
my $raw_sth = $dbh->prepare(q{
INSERT INTO ble_observation_raw (
trial_id,
log_file_id,
source_line_no,
human_time,
rx_epoch_ms,
receiver,
rx_lat,
rx_lon,
rx_gps_age_ms,
heard,
rssi,
avg_rssi,
age_s,
count,
seq,
tx_epoch_ms,
payload
)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
});
my $obs_sth = $dbh->prepare(q{
INSERT INTO ble_observation (
raw_id,
trial_id,
log_file_id,
source_line_no,
rx_epoch_ms,
rx_epoch_s,
tx_epoch_ms,
tx_epoch_s,
rx_tx_delta_ms,
receiver,
heard,
rx_lat,
rx_lon,
rx_gps_age_ms,
rssi,
avg_rssi,
age_s,
receiver_count,
receiver_seq_field,
payload_raw,
payload_kind,
payload_node,
payload_seq,
payload_tx_epoch_ms,
payload_legacy_uptime,
parse_warning
)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
});
my $line_no = 1;
my $data_rows = 0;
while (my $line = <$fh>) {
$line_no++;
chomp $line;
$line =~ s/\r\z//;
next if $line =~ /^\s*$/;
my @f = split /,/, $line, -1;
die "Line $line_no has too few columns. Expected 14, got " . scalar(@f) . "\n"
unless @f >= 14;
my $human_time = $f[0];
my $rx_epoch_ms = to_int_or_undef($f[1]);
my $receiver = $f[2];
my $rx_lat = to_num_or_undef($f[3]);
my $rx_lon = to_num_or_undef($f[4]);
my $rx_gps_age_ms = to_int_or_undef($f[5]);
my $heard = $f[6];
my $rssi = to_int_or_undef($f[7]);
my $avg_rssi = to_int_or_undef($f[8]);
my $age_s = to_int_or_undef($f[9]);
my $count = to_int_or_undef($f[10]);
my $seq = to_int_or_undef($f[11]);
my $tx_epoch_ms = to_int_or_undef($f[12]);
my $payload = $f[13];
die "Line $line_no has no rx_epoch_ms\n"
unless defined $rx_epoch_ms;
die "Line $line_no has no receiver\n"
unless defined $receiver && $receiver ne '';
die "Line $line_no has no heard unit\n"
unless defined $heard && $heard ne '';
upsert_unit($dbh, $receiver);
upsert_unit($dbh, $heard);
$raw_sth->execute(
$trial_id,
$log_file_id,
$line_no,
$human_time,
$rx_epoch_ms,
$receiver,
$rx_lat,
$rx_lon,
$rx_gps_age_ms,
$heard,
$rssi,
$avg_rssi,
$age_s,
$count,
$seq,
$tx_epoch_ms,
$payload,
);
my $raw_id = $dbh->sqlite_last_insert_rowid;
my $parsed = parse_payload($payload);
my $rx_epoch_s = $rx_epoch_ms / 1000.0;
my $tx_epoch_s;
my $rx_tx_delta_ms;
if (defined $tx_epoch_ms && $tx_epoch_ms > 0) {
$tx_epoch_s = $tx_epoch_ms / 1000.0;
$rx_tx_delta_ms = $rx_epoch_ms - $tx_epoch_ms;
}
$obs_sth->execute(
$raw_id,
$trial_id,
$log_file_id,
$line_no,
$rx_epoch_ms,
$rx_epoch_s,
$tx_epoch_ms,
$tx_epoch_s,
$rx_tx_delta_ms,
$receiver,
$heard,
$rx_lat,
$rx_lon,
$rx_gps_age_ms,
$rssi,
$avg_rssi,
$age_s,
$count,
$seq,
$payload,
$parsed->{payload_kind},
$parsed->{payload_node},
$parsed->{payload_seq},
$parsed->{payload_tx_epoch_ms},
$parsed->{payload_legacy_uptime},
$parsed->{parse_warning},
);
$data_rows++;
}
close $fh;
return $data_rows;
}
sub parse_payload {
my ($payload) = @_;
my %p = (
payload_kind => undef,
payload_node => undef,
payload_seq => undef,
payload_tx_epoch_ms => undef,
payload_legacy_uptime => undef,
parse_warning => undef,
);
if (!defined $payload || $payload eq '') {
$p{parse_warning} = 'empty payload';
return \%p;
}
my @parts = split /\|/, $payload, -1;
if (@parts == 4 && $parts[0] eq 'B2') {
$p{payload_kind} = 'B2';
$p{payload_node} = $parts[1];
$p{payload_seq} = to_int_or_undef($parts[2]);
$p{payload_tx_epoch_ms} = to_int_or_undef($parts[3]);
if (!defined $p{payload_seq}) {
$p{parse_warning} = 'B2 payload seq was not numeric';
}
elsif (!defined $p{payload_tx_epoch_ms}) {
$p{parse_warning} = 'B2 payload tx_epoch_ms was not numeric';
}
return \%p;
}
if (@parts == 5 && $parts[0] eq 'TBMSND' && $parts[1] eq '1') {
$p{payload_kind} = 'TBMSND1';
$p{payload_node} = $parts[2];
$p{payload_seq} = to_int_or_undef($parts[3]);
$p{payload_legacy_uptime} = to_int_or_undef($parts[4]);
if (!defined $p{payload_seq}) {
$p{parse_warning} = 'legacy payload seq was not numeric';
}
elsif (!defined $p{payload_legacy_uptime}) {
$p{parse_warning} = 'legacy payload uptime was not numeric';
}
return \%p;
}
$p{parse_warning} = 'unrecognized payload format';
return \%p;
}
sub update_trial_time_bounds {
my ($dbh, $trial_id) = @_;
$dbh->do(q{
UPDATE trial
SET trial_start_epoch_ms = (
SELECT MIN(first_rx_epoch_ms)
FROM log_file
WHERE trial_id = ?
),
trial_end_epoch_ms = (
SELECT MAX(last_rx_epoch_ms)
FROM log_file
WHERE trial_id = ?
)
WHERE trial_id = ?
}, undef, $trial_id, $trial_id, $trial_id);
}
sub to_int_or_undef {
my ($value) = @_;
return undef unless defined $value;
return undef if $value eq '';
$value =~ s/^\s+|\s+$//g;
return undef unless $value =~ /^-?\d+$/;
return int($value);
}
sub to_num_or_undef {
my ($value) = @_;
return undef unless defined $value;
return undef if $value eq '';
$value =~ s/^\s+|\s+$//g;
return undef unless $value =~ /^-?(?:\d+(?:\.\d*)?|\.\d+)$/;
return 0 + $value;
}

View file

@ -65,13 +65,15 @@ using field_qa::GnssSample;
static constexpr const char* kAppTitle = "BLE Discovery";
static constexpr const char* kProjectPrefix = "TBMSND";
static constexpr uint8_t kProtocolVersion = 1;
static constexpr const char* kTimePayloadPrefix = "B2";
static constexpr uint32_t kStaleMs = 20000;
static constexpr uint32_t kDisplayPeriodMs = 1000;
static constexpr uint32_t kAdvertisePeriodMs = 2000;
static constexpr uint32_t kAdvertisePeriodMs = 1000;
static constexpr uint32_t kScanPeriodMs = 2500;
static constexpr uint32_t kScanWindowSeconds = 2;
static constexpr uint32_t kLogFlushPeriodMs = 5000;
static constexpr uint32_t kStartupStatusPeriodMs = 1000;
static constexpr uint32_t kGpsRefreshPeriodMs = 1000;
static constexpr uint8_t kRssiWindow = 5;
struct NodeState {
@ -87,6 +89,7 @@ struct NodeState {
uint32_t seenCount = 0;
uint32_t lastSeenMs = 0;
uint32_t lastSeq = 0;
int64_t lastTxEpochMs = 0;
};
XPowersLibInterface* g_pmu = nullptr;
@ -116,17 +119,22 @@ bool g_buttonWasPressed = false;
uint8_t g_displayMode = 0;
uint32_t g_sequence = 0;
volatile uint32_t g_ppsEdgeCount = 0;
volatile uint32_t g_lastPpsMs = 0;
uint32_t g_lastAdvertiseMs = 0;
uint32_t g_lastScanMs = 0;
uint32_t g_lastDisplayMs = 0;
uint32_t g_lastFlushMs = 0;
uint32_t g_lastGpsRefreshMs = 0;
int64_t g_epochBase = 0;
uint32_t g_epochBaseMs = 0;
double g_latitude = 0.0;
double g_longitude = 0.0;
uint32_t g_gpsFixMs = 0;
bool g_hasLocation = false;
char g_logPath[128] = {};
void IRAM_ATTR onPpsEdge() {
g_lastPpsMs = millis();
++g_ppsEdgeCount;
}
@ -168,11 +176,39 @@ bool freshEnough(const NodeState& node, uint32_t now) {
return node.heard && (uint32_t)(now - node.lastSeenMs) <= kStaleMs;
}
int64_t currentEpochMs();
int64_t currentEpoch() {
return currentEpochMs() / 1000LL;
}
int64_t currentEpochMs() {
if (!g_disciplined || g_epochBase <= 0) {
return 0;
}
return g_epochBase + (int64_t)((millis() - g_epochBaseMs) / 1000UL);
return (g_epochBase * 1000LL) + (int64_t)(millis() - g_epochBaseMs);
}
uint32_t gpsAgeMs(uint32_t now) {
if (!g_hasLocation || g_gpsFixMs == 0) {
return UINT32_MAX;
}
return now - g_gpsFixMs;
}
void refreshGpsPosition(bool force = false) {
const uint32_t now = millis();
if (!force && (uint32_t)(now - g_lastGpsRefreshMs) < kGpsRefreshPeriodMs) {
return;
}
g_lastGpsRefreshMs = now;
const GnssSample sample = g_gnss.makeSample();
if (sample.validLocation && sample.validFix) {
g_latitude = sample.latitude;
g_longitude = sample.longitude;
g_gpsFixMs = sample.sampleMillis;
g_hasLocation = true;
}
}
void formatDateTime(int64_t epoch, char* out, size_t outSize) {
@ -311,9 +347,12 @@ bool disciplineStartupClock() {
++attemptCount;
if (g_clock.disciplineFromGnss(sample, waitForPps, nullptr, disciplined, hadPriorRtc, driftSeconds)) {
g_epochBase = ClockDiscipline::toEpochSeconds(disciplined);
g_epochBaseMs = millis();
g_epochBaseMs = g_lastPpsMs > 0 ? g_lastPpsMs : millis();
g_latitude = sample.latitude;
g_longitude = sample.longitude;
g_gpsFixMs = sample.sampleMillis;
g_hasLocation = true;
g_lastGpsRefreshMs = millis();
char iso[32];
ClockDiscipline::formatIsoUtc(disciplined, iso, sizeof(iso));
Serial.printf("clock_disciplined node=%s utc=%s prior_rtc=%u drift_s=%lld lat=%.7f lon=%.7f\n",
@ -338,7 +377,7 @@ bool openDatedLog() {
}
char stamp[24];
formatCompactDateTime(currentEpoch(), stamp, sizeof(stamp));
snprintf(g_logPath, sizeof(g_logPath), "/logs/%s_%s_ble_seach.log", stamp, NODE_NAME);
snprintf(g_logPath, sizeof(g_logPath), "/logs/%s_%s_ble_search.log", stamp, NODE_NAME);
if (!g_storage.ensureDirRecursive("/logs")) {
Serial.printf("sd_log_dir_failed err=%s\n", g_storage.lastError());
return false;
@ -347,7 +386,7 @@ bool openDatedLog() {
Serial.printf("sd_log_open_failed path=%s err=%s\n", g_logPath, g_storage.lastError());
return false;
}
g_storage.println("human_time,epoch_ms,receiver,lat,lon,heard,rssi,avg_rssi,age_s,count,seq,payload");
g_storage.println("human_time,rx_epoch_ms,receiver,rx_lat,rx_lon,rx_gps_age_ms,heard,rssi,avg_rssi,age_s,count,seq,tx_epoch_ms,payload");
g_storage.flush();
Serial.printf("sd_log_open path=%s\n", g_logPath);
g_logReady = true;
@ -387,15 +426,14 @@ void startStorageAndWeb() {
}
void updateAdvertisement() {
char payload[30];
char payload[32];
snprintf(payload,
sizeof(payload),
"%s|%u|%s|%04lu|%04lu",
kProjectPrefix,
(unsigned)kProtocolVersion,
"%s|%s|%04lu|%lld",
kTimePayloadPrefix,
NODE_NAME,
(unsigned long)(g_sequence % 10000UL),
(unsigned long)((millis() / 1000UL) % 10000UL));
(long long)currentEpochMs());
BLEAdvertising* advertising = BLEDevice::getAdvertising();
BLEAdvertisementData data;
@ -410,12 +448,14 @@ void updateAdvertisement() {
g_lastAdvertiseMs = millis();
}
bool parsePayload(const std::string& data, char* outName, size_t outNameSize, uint32_t& outSeq, char* outPayload, size_t outPayloadSize) {
bool parsePayload(const std::string& data, char* outName, size_t outNameSize, uint32_t& outSeq, int64_t& outTxEpochMs, char* outPayload, size_t outPayloadSize) {
if (outNameSize == 0 || outPayloadSize == 0) {
return false;
}
outName[0] = '\0';
outPayload[0] = '\0';
outSeq = 0;
outTxEpochMs = 0;
if (data.empty() || data.size() >= outPayloadSize) {
return false;
}
@ -426,13 +466,26 @@ bool parsePayload(const std::string& data, char* outName, size_t outNameSize, ui
strlcpy(work, outPayload, sizeof(work));
char* save = nullptr;
const char* prefix = strtok_r(work, "|", &save);
const char* version = strtok_r(nullptr, "|", &save);
const char* name = strtok_r(nullptr, "|", &save);
const char* seq = strtok_r(nullptr, "|", &save);
if (!prefix || !version || !name || !seq) {
if (!prefix) {
return false;
}
if (strcmp(prefix, kProjectPrefix) != 0 || atoi(version) != kProtocolVersion) {
const char* name = nullptr;
const char* seq = nullptr;
const char* txEpochMs = nullptr;
if (strcmp(prefix, kTimePayloadPrefix) == 0) {
name = strtok_r(nullptr, "|", &save);
seq = strtok_r(nullptr, "|", &save);
txEpochMs = strtok_r(nullptr, "|", &save);
} else {
const char* version = strtok_r(nullptr, "|", &save);
name = strtok_r(nullptr, "|", &save);
seq = strtok_r(nullptr, "|", &save);
if (strcmp(prefix, kProjectPrefix) != 0 || !version || atoi(version) != kProtocolVersion) {
return false;
}
}
if (!name || !seq) {
return false;
}
if (nodeIndexFor(name) < 0 || isSelfName(name)) {
@ -440,19 +493,22 @@ bool parsePayload(const std::string& data, char* outName, size_t outNameSize, ui
}
strlcpy(outName, name, outNameSize);
outSeq = (uint32_t)strtoul(seq, nullptr, 10);
if (txEpochMs && txEpochMs[0]) {
outTxEpochMs = (int64_t)strtoll(txEpochMs, nullptr, 10);
}
return true;
}
void logAcceptedAdvertisement(const NodeState& node, const char* payload) {
const int avg = averageRssi(node);
const uint32_t age = ageSeconds(node, millis());
const int64_t epoch = currentEpoch();
const int64_t epochMs = currentEpochMs();
const int64_t epoch = epochMs / 1000LL;
const uint32_t fixAge = gpsAgeMs(millis());
char human[32];
formatDateTime(epoch, human, sizeof(human));
const uint32_t epochMsPart = millis() % 1000UL;
const long long epochMs = (long long)(epoch * 1000LL + epochMsPart);
Serial.printf("%lu,%s,%s,%d,%d,%lu,%lu,%lu,%s\n",
Serial.printf("%lu,%s,%s,%d,%d,%lu,%lu,%lu,%lld,%s\n",
(unsigned long)millis(),
NODE_NAME,
node.name,
@ -461,30 +517,33 @@ void logAcceptedAdvertisement(const NodeState& node, const char* payload) {
(unsigned long)age,
(unsigned long)node.seenCount,
(unsigned long)node.lastSeq,
(long long)node.lastTxEpochMs,
payload);
if (g_logReady && g_storage.isLogOpen()) {
char line[224];
char line[256];
snprintf(line,
sizeof(line),
"%s,%lld,%s,%.7f,%.7f,%s,%d,%d,%lu,%lu,%lu,%s",
"%s,%lld,%s,%.7f,%.7f,%lu,%s,%d,%d,%lu,%lu,%lu,%lld,%s",
human,
epochMs,
(long long)epochMs,
NODE_NAME,
g_latitude,
g_longitude,
(unsigned long)fixAge,
node.name,
node.lastRssi,
avg,
(unsigned long)age,
(unsigned long)node.seenCount,
(unsigned long)node.lastSeq,
(long long)node.lastTxEpochMs,
payload);
g_storage.println(line);
}
}
void acceptAdvertisement(const char* name, int rssi, uint32_t seq, const char* payload) {
void acceptAdvertisement(const char* name, int rssi, uint32_t seq, int64_t txEpochMs, const char* payload) {
const int idx = nodeIndexFor(name);
if (idx < 0) {
return;
@ -500,6 +559,7 @@ void acceptAdvertisement(const char* name, int rssi, uint32_t seq, const char* p
++node.seenCount;
node.lastSeenMs = millis();
node.lastSeq = seq;
node.lastTxEpochMs = txEpochMs;
logAcceptedAdvertisement(node, payload);
}
@ -512,10 +572,11 @@ class DiscoveryAdvertisedCallbacks : public BLEAdvertisedDeviceCallbacks {
char name[8];
char payload[48];
uint32_t seq = 0;
if (!parsePayload(advertisedDevice.getManufacturerData(), name, sizeof(name), seq, payload, sizeof(payload))) {
int64_t txEpochMs = 0;
if (!parsePayload(advertisedDevice.getManufacturerData(), name, sizeof(name), seq, txEpochMs, payload, sizeof(payload))) {
return;
}
acceptAdvertisement(name, advertisedDevice.getRSSI(), seq, payload);
acceptAdvertisement(name, advertisedDevice.getRSSI(), seq, txEpochMs, payload);
}
};
@ -689,6 +750,7 @@ void setup() {
void loop() {
g_gnss.poll();
refreshGpsPosition();
pollButton();
pollStorageWeb();
if (g_bleStarted) {