Files
meshcore-analyzer/cmd/server/observer_airtime.go
T
efitenandClaude Opus 5.5 1ae4c74913 feat(observers): rolling 1 h TX airtime and a duty-cycle warning (#661) (#2208)
Fixes #661 (the narrowed scope from the 2026-10-14 comment).

## What changes

- `GET /api/observers/metrics/summary` adds `tx_airtime_pct_1h` and
`rx_airtime_pct_1h` per observer: the airtime share over the hour before
now, whatever `window` is, from the cumulative `tx_air_secs` /
`rx_air_secs` counters in `observer_metrics`.
- A counter that goes down marks a reboot; only the stretch after the
last reset counts.
- Under 15 minutes of samples (one sample, or a reboot late in the hour)
returns `null`.
  - Clamped to 0..100, rounded to 2 decimals.
- The response also carries `duty_cycle_warn_pct`, from the new config
key `dutyCycleWarnPct` (default 10, the EU 868 MHz g3 sub-band limit).
Added to `config.example.json`.
- RF Health tiles show `TX 1h: x%`; the observer list gets a sortable
numeric `TX 1h` column. Both use the shared `public/observer-airtime.js`
helper and flag values at or over the threshold with `.airtime-over`
(`var(--status-red-text)`).
- The handler now returns a named `MetricsSummaryResponse` struct
instead of `map[string]interface{}` (one occurrence fewer).

## Why the warning matters

Repeater and room-server firmware default to `airtime_factor = 1.0`
(`examples/simple_repeater/MyMesh.cpp:890`), which `Dispatcher.cpp:26`
turns into a 50% TX budget per 1 h window. The device therefore does not
keep itself under the EU 10% limit; this view is where an operator can
see it.

## Cost

- Server: one extra query per summary request, `SELECT observer_id,
timestamp, tx_air_secs, rx_air_secs FROM observer_metrics WHERE
timestamp >= now-1h`, on the existing `idx_observer_metrics_timestamp`,
then one O(rows) pass. On our instance that is 35 observers x about 12
samples (5 min interval).
- Observer list: one extra bulk call `summary?window=1h` per load
(client TTL 30 s, failure ignored), not a call per observer.

## Tests

- Go: `TestRollingAirtimePct` (steady, reboot mid-hour, single sample,
span under 15 min, reboot leaving under 15 min, nil counters, clamp,
rounding), `TestGetMetricsSummaryAirtime1h` (samples older than 1 h
ignored inside a 24 h window, single-sample observer gets null),
`TestMetricsSummaryDutyCycleWarnPct` (default 10, config override,
`observers` is `[]` not `null`). Full `cmd/server` suite passes locally
(skipping `TestSaveGeoFilterPreservesFileMode`, which fails on Windows
on clean master too).
- Frontend unit: `tests/unit/test-issue-661-observer-airtime.js` (8
assertions, real file in `vm`), added to `test-all.sh`.
`test-observers-headings.js` updated for the new column. Full
`test-all.sh` passes locally.
- E2E: `tests/e2e/test-issue-661-airtime-e2e.js`, registered in
`deploy.yml` and `scripts/non-unit-tests.json`. The fixture DB has no
`observer_metrics` table, so the summary is served through `page.route`
with known values for the fixture's own observer ids. It checks the
column header, over/under/unknown cells, sort order and the RF Health
tile. 4/4 locally. It caught a real bug during development:
`loadRFHealthData` declares a local `const window`, so the tile calls
the helper as the global `ObserverAirtime`.

## Validation

Deployed to a staging instance with live MQTT data (35 observers with
metrics) and checked in the browser:

- The observer list shows the column for 42 rows (33 with a value, 9
without enough samples). Sorting puts the highest share (5.3%) first.
- RF Health shows `TX 1h` on all 35 tiles. With a fourth stat the line
wrapped inside items on narrow tiles; the stats row now wraps between
items only.
- No observer was over 10% at the time, so the red state was checked by
rendering one cell client-side at 12.5% (computed colour `rgb(185, 28,
28)` in light mode), and by the E2E test.

## Not done

- The threshold is config-only; exposing it in the customizer is a
follow-up.
- No airtime comparison across observers (that is #671) and no
per-packet airtime (Relay Airtime Share covers it).
- Dark mode was not checked in the browser; `--status-red-text` is
defined for both themes.

---------

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

82 lines
2.7 KiB
Go

package main
import (
"math"
"time"
)
// Rolling 1 h airtime per observer (#661). The firmware enforces its own
// duty-cycle budget over a 1 h window (Dispatcher.cpp, duty_cycle_window_ms),
// but repeaters default to airtime_factor 1.0, i.e. a 50% budget, so the EU
// 10% limit is not enforced on the device. The summary therefore reports the
// TX share of the last hour and the threshold the UI warns at.
// airtimeWindow is the span the rolling figure covers, matching the
// firmware's duty-cycle window.
const airtimeWindow = time.Hour
// airtimeMinSpan is the shortest stretch of samples that yields a figure;
// shorter (one sample, or a reboot late in the hour) reads as unknown. The
// ingestor rounds sample times to its sample interval (5 min by default), so
// the span can be off by up to one interval: about 17% at 30 min, 8% at 1 h.
const airtimeMinSpan = 30 * time.Minute
// airtimeMaxAge is how old the newest sample may be; an observer that has
// gone quiet longer than this reads as unknown rather than "last hour".
const airtimeMaxAge = 15 * time.Minute
// defaultDutyCycleWarnPct is the EU 868 MHz g3 sub-band duty-cycle limit.
const defaultDutyCycleWarnPct = 10.0
// airtimePoint is one sample of a cumulative airtime counter (seconds since
// boot); Secs is nil when the observer did not report it.
type airtimePoint struct {
At time.Time
Secs *int
}
// rollingAirtimePct returns the airtime share, in percent, over the points
// given in time order. A counter that goes down marks a reboot: only the
// stretch after the last reset counts. Returns nil when that stretch is
// shorter than airtimeMinSpan, or the newest sample is older than
// airtimeMaxAge at now.
func rollingAirtimePct(pts []airtimePoint, now time.Time) *float64 {
var start, last *airtimePoint
for i := range pts {
p := &pts[i]
if p.Secs == nil {
continue
}
if start == nil || *p.Secs < *last.Secs {
start = p
}
last = p
}
if start == nil || now.Sub(last.At) > airtimeMaxAge {
return nil
}
span := last.At.Sub(start.At)
if span < airtimeMinSpan {
return nil
}
pct := float64(*last.Secs-*start.Secs) / span.Seconds() * 100
pct = math.Min(math.Max(pct, 0), 100)
pct = math.Round(pct*100) / 100
return &pct
}
// DutyCycleWarnPctOrDefault returns the configured duty-cycle warning
// threshold in percent, or the EU 10% default.
func (c *Config) DutyCycleWarnPctOrDefault() float64 {
if c.DutyCycleWarnPct > 0 {
return c.DutyCycleWarnPct
}
return defaultDutyCycleWarnPct
}
// MetricsSummaryResponse is the body of GET /api/observers/metrics/summary.
type MetricsSummaryResponse struct {
Observers []MetricsSummaryRow `json:"observers"`
DutyCycleWarnPct float64 `json:"duty_cycle_warn_pct"`
}