Files
meshcore-analyzer/internal/dbschema/observer_radios.go
T
TeTeHackoandClaude Opus 5.5 77a5f14925 feat(observers): radio setting per observation from the observer's radio history (#1869) (#2292)
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>
2026-10-26 21:08:40 +01:00

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)
}