Files
meshcore-analyzer/cmd/server/rf_params_cache.go
T
efitenandClaude Opus 5.5 0e3ce48cff feat(nodes): flag nodes whose LoRa settings differ from nearby nodes (#1268) (#2210)
Refs #1268. #2209 is merged as `cab4f71c`, and master is merged into
this branch (`47ce8264`), so the diff against master is this PR's own
change: `ea4d1130` plus the fix in `5cbbe5d5`.

## What it does

Two nodes hear each other only when SF and bandwidth match and their
frequencies are close enough. `/api/rf-params` now also returns
`deviations`: nodes whose setting differs from a majority of the nodes
around them on the same band.

- **Peers:** nodes with GPS and RF params within
`rfParams.compareRadiusKm` (default 50) whose frequency is within 30
MHz.
- **Same channel:** frequencies within a quarter of the bandwidth count
as one channel (15.625 kHz at BW 62.5). The SX1262 tolerates a
transmitter/receiver offset of up to 25% of BW without sensitivity loss,
for SF5 to SF12 and all bandwidths. Source: the `FERR_L` row in the
[S62F module
datasheet](https://lora-alliance.org/wp-content/uploads/2020/07/S62F_SPEC_Ver.B.pdf),
an SX1262 module; I did not find the figure in a Semtech document
itself. That table also lists -50/-100/-200 ppm for SF12/SF11/SF10,
which at 869 MHz is wider than BW/4 at 62.5 and 125 kHz, so BW/4 is the
tighter bound there.
- **Flagged when:** a setting the node cannot hear is shared by more
than half of the group, the node itself counted, with at least 3 peers
and no other incompatible setting at the same support. So 5 against 5
flags nobody, and 2 of 4 does not count as a majority. Each deviation
carries the majority, how many peers are compatible with it, the peer
count, and which of freq/sf/bw differ.
- **Coding rate is not compared.** RadioLib starts SX126x radios with an
explicit LoRa header (`SX126x::begin` sets
`RADIOLIB_SX126X_LORA_HEADER_EXPLICIT` at `6d89348`, the commit the
firmware pins in `platformio.ini`), and nothing in `firmware/src`
switches to implicit. The header carries the CR, so a CR mismatch still
decodes. I checked SX126x only, not SX127x or LR11x0.

UI:
- Nodes list: a badge such as `LoRa SF7` next to the name of a deviating
node (the majority in its tooltip), and a `LoRa: differs from nearby
(N)` filter kept in the URL as `?lora=differs`.
- Node detail: one line, for example "Differs from 12 of 14 nearby nodes
on this band, which use 869.618 MHz · SF8 · BW 62.5 kHz".
- Badge and filter only exist while the server has map data.

## Why same-band only

On our instance (1,110 nodes with params and GPS, measured with the
first version of the rule), a plain neighbourhood majority flagged
exactly 15 nodes, all of them a deliberate 433 MHz network in Limburg
next to the 868 MHz one (names like `NL-ZAY-RP01 | 433 X 868`). The
same-band comparison flags none there. On our network this PR shows
nothing today. The flags are for instances where a node really is set to
a different SF, bandwidth or channel than the nodes around it.

## Cost

- Points are bucketed on a radius-sized grid (rows of radius/110.57
degrees, longitude columns widened by 1/cos(lat)) and compared with a
squared equirectangular distance.
- `BenchmarkRfDeviations1k` (1,100 nodes over Belgium and the southern
Netherlands): 4.5 ms. `BenchmarkRfDeviations10k` (10,000 nodes in the
same box): 388 ms, 1.3 MB. Both on the first version.
- After `5cbbe5d5`, on a slower machine, 3 runs each: 1k 4.4 to 9.5 ms,
10k 821 to 852 ms, 1.8 MB. On that machine the first version measured
7.6 to 9.3 ms and 819 to 861 ms, so the majority step adds no measurable
time. It is one pass over the distinct settings around a node, and those
are few (one per preset in use).
- Inside the radius the work is close to O(n²): 10,000 nodes in one
dense box means about 2,000 neighbours per node, and a request after the
5 minute TTL waits for that computation.
- The result is cached per map snapshot for 5 minutes; concurrent
requests wait for one computation.
- On staging `/api/perf` reports `/api/rf-params` at 9.9 ms average, 26
ms max (8 requests).

## Tests

- Go: deviation cases (SF, freq within band, BW, CR ignored, other band
ignored, no GPS, no params, far away), split vote and too few peers,
radius, a peer in another longitude column at 60°N, the cache (same
snapshot within TTL, new snapshot, after TTL), the route with four
inserted nodes, plus the two benchmarks.
- Go, added in `5cbbe5d5`: a 5/5 split and 2 of 4 flag nobody; a 1 and a
15 kHz offset are not deviations while 32 kHz is, and offset peers add
up into one majority; the BW/4 boundary; peers two grid columns away at
60°N; a peer at the north edge of the radius. Each fails on the first
version. The two-column test also fails with a ±1 column search, which
the existing longitude test did not catch.
- Full `cmd/server` suite and `test-all.sh` pass locally on `5cbbe5d5`.
- Frontend unit: 6 new assertions for `deviationBadge` and the detail
line (13 in the file).
- E2E: 4 new checks: badge only on the deviating node, filter count and
result, `?lora=differs` written and restored, detail line. The disabled
check now also asserts no filter and no badges. 8/8 locally.

## Validation

On staging with live map data, with the first version: 1,115 nodes
served, `deviations: {}`, and the filter shows `LoRa: differs from
nearby (0)` with no badges. Because no real deviation exists there, the
badge, filter and detail line were checked by serving one simulated
deviation through Playwright route interception against the staging
page.

## Not done

- No map marker for deviating nodes.
- `compareRadiusKm` is config-only; the customizer is a later step. The
other values are hardcoded and belong in that step too: at least 3
peers, the 30 MHz band, the more-than-half rule, BW/4 and the 5 minute
TTL.
- Not re-run on staging after `5cbbe5d5`, and the E2E suite was not
re-run locally. The fix changes only the server's choice of nodes, not
the UI.
- The detail line says the peers "use" the majority setting. With the
BW/4 tolerance, some of them may be a few kHz off it.
- Companions and room servers are compared too, not only repeaters.

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 15:18:14 +02:00

406 lines
12 KiB
Go

package main
// LoRa RF parameters per node from the MeshCore map (issue #1268).
//
// Frequency, spreading factor, bandwidth and coding rate are per-device
// settings that never travel in a packet; the public map
// (map.meshcore.io/api/v1/nodes) is the only source. OPT-IN: an empty
// rfParams.nodesUrl starts no fetch and /api/rf-params serves an empty,
// disabled response. Same shape as the known-channels cache (#1323):
// in-memory atomic snapshot, background refresh, fail-soft to the last good
// snapshot, no DB and no disk.
//
// The map payload is ~50 MB of JSON for ~66k nodes, so it is decoded as a
// stream, capped, validated field by field, and only entries whose advert
// is within maxAgeDays are kept.
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"math"
"math/rand"
"net/http"
"regexp"
"strconv"
"strings"
"sync/atomic"
"time"
)
const (
defaultRfRefresh = 6 * time.Hour
defaultRfMaxAge = 7 * 24 * time.Hour
// maxRfNodesBytes caps the decoded map payload (about 50 MB in
// October 2026), so a runaway or hostile upstream cannot exhaust memory.
maxRfNodesBytes = 128 << 20
maxRfPresetsBytes = 1 << 20
// rfStartupJitter spreads the first fetch of many instances that start
// together (a fleet redeploy) over a minute.
rfStartupJitter = time.Minute
// rfAdvertFutureSkew is how far ahead of now an advert may be dated.
// last_advert comes from the node's own clock: a few minutes fast is
// drift and stays, a wrong time zone (whole hours) or a wrong year
// (about 20% of the map in October 2026, some dated 2096) does not.
rfAdvertFutureSkew = 10 * time.Minute
// rfRetryBase and rfRetryMax bound the retry after a failed fetch while
// there is no data yet: 1, 2, 4, 8, 16 min, then every 30 min.
rfRetryBase = time.Minute
rfRetryMax = 30 * time.Minute
)
// RfParamsConfig is the rfParams block in config.json.
type RfParamsConfig struct {
NodesURL string `json:"nodesUrl"`
PresetsURL string `json:"presetsUrl"`
RefreshHours int `json:"refreshHours"`
MaxAgeDays int `json:"maxAgeDays"`
// CompareRadiusKm: nodes within this distance on the same band are
// the peers a node's settings are compared with. 0/missing => 50.
CompareRadiusKm float64 `json:"compareRadiusKm"`
}
func (c *RfParamsConfig) enabled() bool { return c != nil && c.NodesURL != "" }
func (c *RfParamsConfig) refresh() time.Duration {
if c != nil && c.RefreshHours > 0 {
return time.Duration(c.RefreshHours) * time.Hour
}
return defaultRfRefresh
}
func (c *RfParamsConfig) maxAge() time.Duration {
if c != nil && c.MaxAgeDays > 0 {
return time.Duration(c.MaxAgeDays) * 24 * time.Hour
}
return defaultRfMaxAge
}
// RfParams is one node's radio settings as last advertised to the map.
type RfParams struct {
Freq float64 `json:"freq"`
SF int `json:"sf"`
BW float64 `json:"bw"`
CR int `json:"cr"`
LastAdvert string `json:"last_advert"`
}
// RfPreset is a named radio setting. Presets that share all four values
// are merged into one entry with their titles joined.
type RfPreset struct {
Title string `json:"title"`
Freq float64 `json:"freq"`
SF int `json:"sf"`
BW float64 `json:"bw"`
CR int `json:"cr"`
}
type rfParamsSnapshot struct {
FetchedAt time.Time
Source string
Nodes map[string]RfParams // lower-case pubkey
Presets []RfPreset
}
// RfParamsResponse is the body of GET /api/rf-params.
type RfParamsResponse struct {
Enabled bool `json:"enabled"`
FetchedAt *time.Time `json:"fetched_at"`
Source string `json:"source,omitempty"`
Presets []RfPreset `json:"presets"`
Nodes map[string]RfParams `json:"nodes"`
// Deviations lists the nodes whose frequency, SF or bandwidth differs
// from most same-band nodes around them (rf_params_deviation.go).
Deviations map[string]RfDeviation `json:"deviations"`
}
// loraBandwidthsKHz are the bandwidths SX126x/SX127x radios support.
var loraBandwidthsKHz = []float64{7.8, 10.4, 15.6, 20.8, 31.25, 41.7, 62.5, 125, 250, 500}
var pubkeyHex = regexp.MustCompile(`^[0-9a-f]{64}$`)
// rfAdvertFresh reports whether an advert dated at is no older than maxAge
// and not dated more than rfAdvertFutureSkew ahead of now.
func rfAdvertFresh(at, now time.Time, maxAge time.Duration) bool {
return !at.After(now.Add(rfAdvertFutureSkew)) && now.Sub(at) <= maxAge
}
func validLoRa(freq float64, sf int, bw float64, cr int) bool {
if freq < 100 || freq > 1100 || sf < 5 || sf > 12 || cr < 5 || cr > 8 {
return false
}
for _, b := range loraBandwidthsKHz {
if math.Abs(bw-b) < 0.01 {
return true
}
}
return false
}
var errRfTooLarge = errors.New("rf params payload exceeds size cap")
// capReader hands out at most max bytes and fails with errRfTooLarge when
// asked for more while the source still has data, so an oversize payload
// is refused instead of being cut into something that might still parse.
type capReader struct {
r io.Reader
left int64
tripped bool
}
func newCapReader(r io.Reader, max int64) *capReader { return &capReader{r: r, left: max} }
func (c *capReader) Read(p []byte) (int, error) {
if c.tripped {
return 0, errRfTooLarge
}
if c.left <= 0 {
var one [1]byte
n, err := c.r.Read(one[:])
if n > 0 {
c.tripped = true
return 0, errRfTooLarge
}
return 0, err
}
if int64(len(p)) > c.left {
p = p[:c.left]
}
n, err := c.r.Read(p)
c.left -= int64(n)
return n, err
}
type mapNode struct {
PublicKey string `json:"public_key"`
LastAdvert string `json:"last_advert"`
Params *struct {
Freq float64 `json:"freq"`
BW float64 `json:"bw"`
CR int `json:"cr"`
SF int `json:"sf"`
} `json:"params"`
}
// parseRfNodes stream-decodes the map's node array. Entries that do not
// decode, lack params, have a malformed key, out-of-range values or an
// advert that is not fresh (rfAdvertFresh) are skipped; a payload that is
// not a well-formed array is an error.
func parseRfNodes(r io.Reader, now time.Time, maxAge time.Duration) (map[string]RfParams, error) {
dec := json.NewDecoder(r)
tok, err := dec.Token()
if err != nil {
return nil, fmt.Errorf("decode map nodes: %w", err)
}
if d, ok := tok.(json.Delim); !ok || d != '[' {
return nil, errors.New("decode map nodes: not a JSON array")
}
out := make(map[string]RfParams)
for dec.More() {
var raw json.RawMessage
if err := dec.Decode(&raw); err != nil {
return nil, fmt.Errorf("decode map node: %w", err)
}
var n mapNode
if err := json.Unmarshal(raw, &n); err != nil || n.Params == nil {
continue
}
pk := strings.ToLower(n.PublicKey)
if !pubkeyHex.MatchString(pk) {
continue
}
p := n.Params
if !validLoRa(p.Freq, p.SF, p.BW, p.CR) {
continue
}
at, err := time.Parse(time.RFC3339, n.LastAdvert)
if err != nil || !rfAdvertFresh(at, now, maxAge) {
continue
}
out[pk] = RfParams{Freq: p.Freq, SF: p.SF, BW: p.BW, CR: p.CR, LastAdvert: at.UTC().Format(time.RFC3339)}
}
if _, err := dec.Token(); err != nil {
return nil, fmt.Errorf("decode map nodes: %w", err)
}
return out, nil
}
type presetsPayload struct {
Config struct {
SuggestedRadioSettings struct {
Entries []struct {
Title string `json:"title"`
Frequency string `json:"frequency"`
SpreadingFactor string `json:"spreading_factor"`
Bandwidth string `json:"bandwidth"`
CodingRate string `json:"coding_rate"`
} `json:"entries"`
} `json:"suggested_radio_settings"`
} `json:"config"`
}
// parseRfPresets reads the suggested radio settings from the MeshCore app
// config (api.meshcore.nz/api/v1/config). Invalid or untitled entries are
// dropped; entries with identical values are merged, first title first.
func parseRfPresets(r io.Reader) ([]RfPreset, error) {
var p presetsPayload
if err := json.NewDecoder(r).Decode(&p); err != nil {
return nil, fmt.Errorf("decode presets: %w", err)
}
out := []RfPreset{}
index := map[string]int{}
for _, e := range p.Config.SuggestedRadioSettings.Entries {
title := strings.TrimSpace(e.Title)
freq, err1 := strconv.ParseFloat(e.Frequency, 64)
sf, err2 := strconv.Atoi(e.SpreadingFactor)
bw, err3 := strconv.ParseFloat(e.Bandwidth, 64)
cr, err4 := strconv.Atoi(e.CodingRate)
if title == "" || err1 != nil || err2 != nil || err3 != nil || err4 != nil || !validLoRa(freq, sf, bw, cr) {
continue
}
key := fmt.Sprintf("%.3f/%d/%g/%d", freq, sf, bw, cr)
if i, ok := index[key]; ok {
out[i].Title += " / " + title
continue
}
index[key] = len(out)
out = append(out, RfPreset{Title: title, Freq: freq, SF: sf, BW: bw, CR: cr})
}
return out, nil
}
type rfParamsCache struct {
ptr atomic.Pointer[rfParamsSnapshot]
cfg RfParamsConfig
client *http.Client
now func() time.Time
// lastLogged is only touched by the run goroutine.
lastLogged time.Time
deviations rfDeviationCache
}
func newRfParamsCache(cfg *RfParamsConfig) *rfParamsCache {
return &rfParamsCache{
cfg: *cfg,
client: &http.Client{Timeout: 2 * time.Minute},
now: func() time.Time { return time.Now().UTC() },
}
}
func (c *rfParamsCache) load() *rfParamsSnapshot { return c.ptr.Load() }
func (c *rfParamsCache) get(ctx context.Context, url string) (*http.Response, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return nil, err
}
req.Header.Set("User-Agent", "CoreScope-RfParams/1.0 (+https://github.com/Kpa-clawbot/CoreScope)")
resp, err := c.client.Do(req)
if err != nil {
return nil, err
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
resp.Body.Close()
return nil, fmt.Errorf("%s: upstream status %s", url, resp.Status)
}
return resp, nil
}
func (c *rfParamsCache) fetchNodes(ctx context.Context, now time.Time) (map[string]RfParams, error) {
resp, err := c.get(ctx, c.cfg.NodesURL)
if err != nil {
return nil, err
}
defer resp.Body.Close()
return parseRfNodes(newCapReader(resp.Body, maxRfNodesBytes), now, c.cfg.maxAge())
}
func (c *rfParamsCache) fetchPresets(ctx context.Context) ([]RfPreset, error) {
resp, err := c.get(ctx, c.cfg.PresetsURL)
if err != nil {
return nil, err
}
defer resp.Body.Close()
return parseRfPresets(newCapReader(resp.Body, maxRfPresetsBytes))
}
// fetchOnce refreshes the snapshot. A failed node fetch keeps the previous
// snapshot whole; a failed preset fetch keeps the previous presets.
func (c *rfParamsCache) fetchOnce(ctx context.Context) error {
now := c.now()
nodes, err := c.fetchNodes(ctx, now)
if err != nil {
return fmt.Errorf("nodes fetch failed, keeping previous data: %w", err)
}
prev := c.load()
var presets []RfPreset
if prev != nil {
presets = prev.Presets
}
var presetErr error
if c.cfg.PresetsURL != "" {
if p, err := c.fetchPresets(ctx); err == nil {
presets = p
} else {
presetErr = fmt.Errorf("presets fetch failed, keeping previous presets: %w", err)
}
}
c.ptr.Store(&rfParamsSnapshot{FetchedAt: now, Source: c.cfg.NodesURL, Nodes: nodes, Presets: presets})
return presetErr
}
// rfNextWait is the wait before the next fetch: the refresh interval once
// there is data, otherwise a backoff after the failures-th failed attempt in
// a row, from rfRetryBase doubling up to rfRetryMax and never past refresh.
func rfNextWait(hasData bool, failures int, refresh time.Duration) time.Duration {
if hasData || failures < 1 {
return refresh
}
wait := rfRetryMax
if failures <= 5 {
wait = rfRetryBase << (failures - 1)
}
if wait > refresh {
wait = refresh
}
return wait
}
// run starts the background refresh loop after a random startup delay.
// Until the first fetch succeeds it retries with a backoff (rfNextWait).
// Never blocks the caller.
func (c *rfParamsCache) run(ctx context.Context, logf func(string, ...interface{})) {
go func() {
select {
case <-ctx.Done():
return
case <-time.After(time.Duration(rand.Int63n(int64(rfStartupJitter)))):
}
failures := 0
for {
err := c.fetchOnce(ctx)
if err != nil {
logf("[rf-params] %v", err)
}
if c.load() == nil {
failures++
} else {
failures = 0
}
if s := c.load(); s != nil && !s.FetchedAt.Equal(c.lastLogged) {
c.lastLogged = s.FetchedAt
logf("[rf-params] %d nodes within max age, %d presets", len(s.Nodes), len(s.Presets))
}
t := time.NewTimer(rfNextWait(c.load() != nil, failures, c.cfg.refresh()))
select {
case <-ctx.Done():
t.Stop()
return
case <-t.C:
}
}
}()
}