mirror of
https://github.com/Kpa-clawbot/meshcore-analyzer.git
synced 2026-11-11 11:57:57 +00:00
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>
406 lines
12 KiB
Go
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:
|
|
}
|
|
}
|
|
}()
|
|
}
|