mirror of
https://github.com/Kpa-clawbot/meshcore-analyzer.git
synced 2026-11-12 06:17:50 +00:00
Part of #1869. ## Problem `observers.radio` keeps only the **last** `MHz,kHz,SF,CR` an observer reported. Once an observer is retuned (say from SF7 to the EU narrow SF8 setting to listen to a neighbouring mesh), every packet it ever heard reads as heard on the new setting, and there is no way to tell an SF7 packet from an SF8 one. ## What this does As #1869 proposes, the packet data model is untouched: the setting of an observation is resolved from its observer and time. **Ingestor** (all schema in `dbschema.Apply`) - `observer_radios`: one row per stretch of time an observer, and each radio of a multi-radio node, stayed on one setting. Every live status extends the current row and a change starts a new one; retained replays never extend a row. On the first run it is seeded with every observer's current radio, which is what the UI already showed for the past. - Settings are stored canonical through `dbschema.CanonicalRadio`, so `869.4320068,62.5,7,5` and `869.432,62.50,7,5` are one setting. - `observation_radios` (`observation_id → rx_radio_id`): multi-radio openHop nodes tag each packet with `rx_radio_id` and list `radios: [{id, radio}]` in their status, so their observations resolve per radio. - The table is sparse: single-radio nodes never send the field, so `observations` and every query that scans it stay untouched. - The row id comes from the observation upsert via `RETURNING id`. - Rows are pruned with their transmissions, and a purged observer takes its history along. **Server** (read-only) - No `AssertReady` change: an absent table reads as empty and is probed again later, the same rule as `advert_route_evidence`. - The history is held in memory. The live poller checks it on every tick (two primary-key lookups), so a retune or a new `rx_radio_id` is known before the observation is broadcast. A full reload runs every 10 min. - `radio` is added to observations, packets, grouped rows (taken from the header observation) and the live feed. - `GET /api/packets?radio=MHz,kHz,SF,CR` filters by setting. - Combined with `observer=`, it means *that* observer heard it on that setting. - A radio query is served from memory instead of falling back to SQL, which would return the window unfiltered. - `GET /api/radios` (named struct) and `radio_history` on `GET /api/observers/{id}`. **Frontend** - Packet filter fields `radio`, `freq`, `bw`, `sf`, `cr`, e.g. `sf == 8` or `freq > 869.5 && bw == 62.5`. - A Radio row in the packet detail and a Radio column in its observations table (only when the server reports one). - A radio history table on the observer page once there is more than one stretch. - `RfParams.radioText` formats the setting. It escapes all five characters and returns non-numeric input as it came, escaped. ## Performance `txMatchesRadio` runs once per observation inside `filterPackets`. - Each observation costs one map lookup by observer id. - Observers with a single stretch (nearly all) answer from that lookup directly. - Retuned observers add a binary search over their few stretches. - Multi-radio observers add one lookup by observation id. - The snapshot is loaded once per filter call. `BenchmarkTxMatchesRadio`, 30K packets / 150K observations (20 observers, 2 of them retuned): ``` BenchmarkTxMatchesRadio-24 602 2056924 ns/op 0 B/op 0 allocs/op ``` Memory is one entry per stretch plus one per multi-radio observation; nothing is added per observation otherwise. ## Tests - **dbschema:** seed runs once, canonical spellings, the tables exist without `observers.radio`. - **ingestor:** - stretches extend and split; canonical spellings do not split - two radios of one node hearing one transmission over two paths each keep their own radio - a retained status does not write history - prune and purge - `rx_radio_id` reaches `PacketData` - **server:** - resolution before, between and after stretches, and with an unknown radio id - `observer` + `radio`, and grouped rows carry `radio` - incremental refresh, `/api/radios`, `radio_history`, missing tables - the benchmark above - **frontend:** the new filter fields in `tests/unit/test-packet-filter.js`, and XSS cases through the real `RfParams.radioText` and the history renderer. - **E2E:** `tests/e2e/test-radio-setting-1869-e2e.js` checks the grouped rows' radio, `sf ==` on the packets page, the detail Radio row and the observer history. It is added to `scripts/non-unit-tests.json` and to shard 2. `go test -race` in `cmd/server`, `cmd/ingestor` and `internal/dbschema` passes, along with `sh test-all.sh`, `eslint@8` (0 errors) and the XSS diff gate. The new E2E test and `test-e2e-playwright.js` pass against the fixture. ## Browser validation This runs on a live instance with six observers, one of them retuned to 869.618/62.5/SF8/CR8: - The packet detail shows `Radio: 869.432 MHz · SF7 · BW 62.5 kHz · CR 4/5`. - `sf == 7` shows 106 of 106 grouped rows and `sf == 8` shows 0 of 107; the SF8 observer has not heard anything yet. - `/api/radios` lists both settings. - No page errors. ## Notes - **The customizer (rule 8)** gets nothing: the only constants are server-side refresh intervals. - **Attribution timing:** packets heard between a retune and the observer's next status message (minutes) are attributed to the previous setting, because a stretch starts when the status is handled. This is documented at `RecordObserverRadios`. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
149 lines
5.2 KiB
Go
149 lines
5.2 KiB
Go
package dbschema
|
|
|
|
import (
|
|
"database/sql"
|
|
"errors"
|
|
"fmt"
|
|
"math"
|
|
"strconv"
|
|
"strings"
|
|
)
|
|
|
|
// Radio settings an observer listened on, over time (#1869).
|
|
//
|
|
// observers.radio holds only the LAST "MHz,kHz,SF,CR" an observer reported,
|
|
// so once an observer is retuned every packet it ever heard reads as heard
|
|
// on the new setting. observer_radios keeps one row per stretch of time an
|
|
// observer (and, on a multi-radio node, one of its radios) stayed on one
|
|
// setting: the row is extended while the setting holds and a new row starts
|
|
// when it changes. Settings are stored canonical (CanonicalRadio), so two
|
|
// spellings of one setting never open a new row.
|
|
//
|
|
// radio_id is "" for the observer's main `radio` field. A multi-radio
|
|
// openHop node also reports `radios: [{id, radio}]` in its status and tags
|
|
// each packet with `rx_radio_id`; those ids are operator-chosen and unique
|
|
// only within one node, hence the (observer_id, radio_id) key.
|
|
//
|
|
// observation_radios carries that per-packet rx_radio_id, keyed by the
|
|
// observation row. It is sparse -- written only for packets that came with
|
|
// the field, which a single-radio node never sends -- so the observations
|
|
// table and every query that scans it stay untouched.
|
|
//
|
|
// Neither table is required by AssertReady: a server that comes up before
|
|
// the ingestor created them shows no radio history and probes again on its
|
|
// next refresh, the same rule as advert_route_evidence.
|
|
//
|
|
// PREFLIGHT: async=false reason="two small tables; the one-time seed copies one row per observer from observers"
|
|
func ensureObserverRadios(rw *sql.DB, logf Logger) error {
|
|
if logf == nil {
|
|
logf = func(string, ...interface{}) {}
|
|
}
|
|
if err := ensureMigrationsTable(rw); err != nil {
|
|
return err
|
|
}
|
|
if _, err := rw.Exec(`
|
|
CREATE TABLE IF NOT EXISTS observer_radios (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
observer_id TEXT NOT NULL,
|
|
radio_id TEXT NOT NULL DEFAULT '',
|
|
radio TEXT NOT NULL,
|
|
first_seen TEXT NOT NULL,
|
|
last_seen TEXT NOT NULL
|
|
);
|
|
CREATE INDEX IF NOT EXISTS idx_observer_radios_obs
|
|
ON observer_radios(observer_id, radio_id, first_seen);
|
|
CREATE TABLE IF NOT EXISTS observation_radios (
|
|
observation_id INTEGER PRIMARY KEY,
|
|
radio_id TEXT NOT NULL
|
|
);
|
|
`); err != nil {
|
|
return fmt.Errorf("create observer radio tables: %w", err)
|
|
}
|
|
|
|
// Seed: every observer that already reports a radio gets one row
|
|
// covering its whole known life. Until now the only record was the
|
|
// current setting, so that is the best statement about the past there
|
|
// is -- and the one the UI made all along.
|
|
var done int
|
|
err := rw.QueryRow(`SELECT 1 FROM _migrations WHERE name = 'observer_radios_seed_v1'`).Scan(&done)
|
|
if err == nil {
|
|
return nil
|
|
}
|
|
if !errors.Is(err, sql.ErrNoRows) {
|
|
return err
|
|
}
|
|
hasRadio, err := TableHasColumn(rw, "observers", "radio")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if hasRadio {
|
|
n, err := seedObserverRadios(rw)
|
|
if err != nil {
|
|
return fmt.Errorf("seed observer_radios: %w", err)
|
|
}
|
|
if n > 0 {
|
|
logf("[dbschema] seeded observer_radios with the current radio of %d observers (#1869)", n)
|
|
}
|
|
}
|
|
_, err = rw.Exec(`INSERT OR IGNORE INTO _migrations (name) VALUES ('observer_radios_seed_v1')`)
|
|
return err
|
|
}
|
|
|
|
func seedObserverRadios(rw *sql.DB) (int, error) {
|
|
type row struct{ id, radio, first, last string }
|
|
rows, err := rw.Query(`
|
|
SELECT id, radio, COALESCE(first_seen, last_seen), COALESCE(last_seen, first_seen)
|
|
FROM observers
|
|
WHERE radio IS NOT NULL AND TRIM(radio) != ''
|
|
AND COALESCE(first_seen, last_seen) IS NOT NULL
|
|
AND NOT EXISTS (SELECT 1 FROM observer_radios r WHERE r.observer_id = observers.id)`)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
var seed []row
|
|
for rows.Next() {
|
|
var r row
|
|
if err := rows.Scan(&r.id, &r.radio, &r.first, &r.last); err != nil {
|
|
rows.Close()
|
|
return 0, err
|
|
}
|
|
seed = append(seed, r)
|
|
}
|
|
rows.Close()
|
|
if err := rows.Err(); err != nil {
|
|
return 0, err
|
|
}
|
|
for _, r := range seed {
|
|
if _, err := rw.Exec(`INSERT INTO observer_radios (observer_id, radio_id, radio, first_seen, last_seen)
|
|
VALUES (?, '', ?, ?, ?)`, r.id, CanonicalRadio(r.radio), r.first, r.last); err != nil {
|
|
return 0, err
|
|
}
|
|
}
|
|
return len(seed), nil
|
|
}
|
|
|
|
// CanonicalRadio normalises an observer's "MHz,kHz,SF,CR" so that one setting
|
|
// reads the same from every client: "869.4320068,62.5,7,5" (a float32
|
|
// frequency printed in full) and "869.432,62.50,7,5" both become
|
|
// "869.432,62.5,7,5". Anything that does not parse as four finite numbers is
|
|
// returned trimmed, as is. Shared by the ingestor (what it stores) and the
|
|
// server (what a filter asks for).
|
|
func CanonicalRadio(s string) string {
|
|
s = strings.TrimSpace(s)
|
|
parts := strings.Split(s, ",")
|
|
if len(parts) != 4 {
|
|
return s
|
|
}
|
|
freq, err1 := strconv.ParseFloat(strings.TrimSpace(parts[0]), 64)
|
|
bw, err2 := strconv.ParseFloat(strings.TrimSpace(parts[1]), 64)
|
|
sf, err3 := strconv.Atoi(strings.TrimSpace(parts[2]))
|
|
cr, err4 := strconv.Atoi(strings.TrimSpace(parts[3]))
|
|
if err1 != nil || err2 != nil || err3 != nil || err4 != nil ||
|
|
math.IsInf(freq, 0) || math.IsNaN(freq) || math.IsInf(bw, 0) || math.IsNaN(bw) {
|
|
return s
|
|
}
|
|
return strconv.FormatFloat(math.Round(freq*1000)/1000, 'f', -1, 64) + "," +
|
|
strconv.FormatFloat(math.Round(bw*100)/100, 'f', -1, 64) + "," +
|
|
strconv.Itoa(sf) + "," + strconv.Itoa(cr)
|
|
}
|