Skip to content

Add rtl88x2eu Spectrum Scanner screen (System > Receiver) - #148

Open
Lupinixx wants to merge 3 commits into
OpenIPC:masterfrom
Lupinixx:feature/spectrum-scanner
Open

Lupinixx wants to merge 3 commits into
OpenIPC:masterfrom
Lupinixx:feature/spectrum-scanner

Conversation

@Lupinixx

@Lupinixx Lupinixx commented Sep 15, 2026

Copy link
Copy Markdown
Contributor

Reimplements tipoman9/Spectrum's 8812eu.py channel-utilization scanner in C/C++ (no Python/pip dependency) as a native LVGL screen, for GS-side rtl88x2eu-family USB WiFi adapters used for the video link.

What's new

  • src/spectrum_scanner.{hpp,cpp} — background scanner:

    • Enumerates /proc/net/rtl*/<iface>/chan_info (any rtl88x2eu-family adapter exposing it).
    • Drives iw <iface> scan passive via fork/execlp (no shell, no Python).
    • Maps channel → frequency via iw dev <iface> info + iw phy <phy> info.
    • Each discovered adapter gets its own scan thread, so one adapter's multi-second scan round doesn't freeze another adapter's display.
    • Re-reads chan_info every ~150ms while a scan is in flight, so channels update progressively as the radio visits them (matching how a full passive scan is inherently sequential per-channel) instead of only refreshing once the whole scan finishes.
    • Exposes a thread-safe snapshot API (spectrum_scanner_snapshot()) — the caller always gets a copy, never a pointer into data the worker thread could mutate concurrently.
  • src/gsmenu/spectrum_screen.{h,c} — a dedicated full-screen LVGL view, reached from System → Receiver → Spectrum Scanner:

    • Single-screen, no-scroll bar graph — the GS only has a 4-way joystick, so nothing here requires scrolling.
    • 5GHz channels only (2.4GHz is rarely usable for FPV video and was dropped to keep every channel visible at once).
    • One color-coded bar per channel (green/yellow/red by utilization%), plus a small quality-tier dot and the channel's frequency.
    • A "recommended channel" line (lowest utilization, tie-broken by highest quality).
    • "Next Adapter" button to cycle between multiple rtl88x2eu-family cards.

Testing

  • Standalone compiles of the new files (gcc/g++) and full cmake --build (both -DUSE_SIMULATOR=ON and the real target's LIB_SOURCE_FILES list).
  • Live runs in the SDL simulator (SDL_VIDEODRIVER=x11 + synthetic xdotool keypresses) navigating System → Receiver → Spectrum Scanner — stable across the empty-adapter state and general navigation.
  • Iterated on layout/behavior against a real rtl88x2eu adapter (wlxdc840328d590) on an actual ground station with feedback from screenshots.

Reimplements github.com/tipoman9/Spectrum's 8812eu.py channel-utilization
scanner in C/C++ (no Python/pip) for GS-side rtl88x2eu-family adapters:

- src/spectrum_scanner.{hpp,cpp}: background scanner. Enumerates
  /proc/net/rtl*/<iface>/chan_info, drives `iw <iface> scan passive`
  via fork/execlp (no shell), maps channel->frequency via `iw dev`/
  `iw phy info`, and exposes a thread-safe snapshot to the UI. Each
  discovered adapter gets its own scan thread so one adapter's several-
  second scan round doesn't stall another's display, and each thread
  loops continuously (no inter-round pause). chan_info is re-read every
  ~150ms while a scan is in flight so channels update as the radio
  visits them instead of only once the whole scan finishes.

- src/gsmenu/spectrum_screen.{h,c}: a dedicated full-screen LVGL view
  (System > Receiver > Spectrum Scanner) — a single-screen, no-scroll
  bar graph (5GHz only) since the GS's 4-way joystick can't scroll a
  list. One color-coded bar per channel (green/yellow/red by
  utilization), a quality dot, and a recommended-channel line (lowest
  utilization, tie-broken by quality).

- lvgl (submodule): pulls in the local fix/sdl-keypad-indev-null-driver-data
  commit — a NULL-driver-data guard in lv_sdl_keyboard_handler() that fixed
  a simulator segfault-on-any-keypress unrelated to this feature but hit
  while building it (input.cpp's custom KEYPAD indev has no driver data,
  which lv_sdl_window's SDL event pump assumed every KEYPAD indev has).
@qodo-free-for-open-source-projects

Copy link
Copy Markdown

PR Summary by Qodo

Add Native rtl88x2eu Spectrum Scanner Screen

✨ Enhancement 🐞 Bug fix 🕐 40+ Minutes

Grey Divider

AI Description

• Adds native multi-adapter rtl88x2eu channel-utilization scanning without Python dependencies.
• Adds a joystick-friendly 5GHz LVGL graph with recommendations and adapter switching.
• Integrates navigation and updates LVGL for simulator keypad safety.
Diagram

graph TD
  MENU["Receiver Menu"] -->|opens| SCREEN["Spectrum Screen"] -->|start and snapshot| API["Scanner API"] -->|manages| SUP["Worker Supervisor"] -->|spawns| WORKERS["Adapter Workers"] -->|executes| IW["iw Utility"] -->|scans| DRIVER["Realtek Driver"] -->|publishes| PROC["/proc chan_info"]
  WORKERS -->|reads| PROC
  WORKERS -->|publishes copies| API
Loading
High-Level Assessment

The following are alternative approaches to this PR:

1. Direct nl80211 integration
  • ➕ Avoids spawning and monitoring external iw processes
  • ➕ Provides structured scan events instead of parsing command output
  • ➕ Could offer tighter cancellation and error reporting
  • ➖ Adds libnl or custom netlink implementation complexity
  • ➖ Still requires parsing the driver-specific chan_info interface
  • ➖ Increases dependency and compatibility burden for embedded targets
2. Standalone scanner daemon
  • ➕ Isolates scanning privileges and process management from the LVGL application
  • ➕ Allows scanner results to be reused by other consumers
  • ➖ Introduces IPC, service packaging, and deployment complexity
  • ➖ Complicates screen-driven start and stop behavior
  • ➖ Is excessive for a single local UI consumer

Recommendation: Keep the in-process scanner and direct iw execution for this scoped embedded feature: it removes Python without introducing another library or service, while per-interface workers preserve responsiveness. Direct nl80211 would become preferable only if scanning expands into a shared subsystem or stronger cancellation and structured error handling justify the added complexity.

Files changed (8) +763 / -2

Enhancement (6) +754 / -1
colmenu_pages.cAdd Spectrum Scanner receiver menu action +16/-1

Add Spectrum Scanner receiver menu action

• Adds the Spectrum Scanner under System → Receiver. The action switches screens, preserves the previous input group, and focuses the scanner controls.

src/gsmenu/colmenu_pages.c

spectrum_screen.cImplement the LVGL spectrum visualization +300/-0

Implement the LVGL spectrum visualization

• Introduces a no-scroll 5GHz utilization graph with quality indicators, frequency labels, best-channel recommendations, empty states, and multi-adapter switching. Screen lifecycle callbacks start and stop scanning while a timer refreshes thread-safe snapshots.

src/gsmenu/spectrum_screen.c

spectrum_screen.hDeclare the spectrum screen initializer +14/-0

Declare the spectrum screen initializer

• Defines the C-compatible initialization entry point for constructing the dedicated LVGL screen.

src/gsmenu/spectrum_screen.h

menu.cCreate the dedicated spectrum screen +6/-0

Create the dedicated spectrum screen

• Declares and initializes the Spectrum Scanner as a standalone LVGL screen alongside DVR and TX Profiles.

src/menu.c

spectrum_scanner.cppImplement concurrent Realtek spectrum scanning +367/-0

Implement concurrent Realtek spectrum scanning

• Discovers compatible procfs interfaces, resolves channel frequencies through iw, and runs one progressive passive-scan worker per adapter. A hotplug supervisor manages workers while mutex-protected state is copied through the public snapshot API.

src/spectrum_scanner.cpp

spectrum_scanner.hppDefine the scanner data model and C API +51/-0

Define the scanner data model and C API

• Defines bounded interface and channel snapshot structures plus idempotent scanner lifecycle and thread-safe snapshot functions for C callers.

src/spectrum_scanner.hpp

Other (2) +9 / -1
CMakeLists.txtBuild spectrum scanner for simulator and target +8/-0

Build spectrum scanner for simulator and target

• Adds the scanner backend and LVGL screen sources to both simulator and target source lists.

CMakeLists.txt

lvglAdvance LVGL to simulator keypad safety fix +1/-1

Advance LVGL to simulator keypad safety fix

• Updates the LVGL submodule revision to include a NULL driver-data guard in the SDL keyboard handler, preventing crashes with the application's custom keypad input device.

lvgl

@qodo-free-for-open-source-projects

qodo-free-for-open-source-projects Bot commented Sep 15, 2026

Copy link
Copy Markdown

Code Review by Qodo

🐞 Bugs (0) 📘 Rule violations (0) 📎 Requirement gaps (0) 🎨 UX issues (0) 🔗 Cross-repo conflicts (0) 📜 Skill insights (0)

Grey Divider


Action required

1. The first channel never appears ✓ Resolved 🐞 Bug ≡ Correctness
Description
parse_chan_info consumes the first numeric row with the break in its header-detection loop
before the row-parsing loop starts. Every valid report loses its first channel, so that channel is
absent from the graph and cannot be selected as the recommendation, while a single-row report
appears empty.
Code

src/spectrum_scanner.cpp[160]

+        break;  /* row parsing continues below, after the header line */
Evidence
The documented format places channel rows immediately after the Index header. Header detection
continues after finding that header, and the next iteration reads and discards the first row at
break; only subsequent lines reach the parser and then the UI's filter and recommendation logic.

src/spectrum_scanner.cpp[130-166]
src/gsmenu/spectrum_screen.c[168-193]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
`parse_chan_info` consumes and discards the first data row after the `Index` header before entering its row-parsing loop.
## Fix Focus Areas
- src/spectrum_scanner.cpp[145-166]
## Recommended Fix
Break out of the header-search loop immediately when the `Index` header is found, so the following `getline` in the row-parsing loop reads the first numeric channel row. Add parser coverage for both single-row and multi-row reports.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


2. Closing the scanner leaves scans running ✓ Resolved 🐞 Bug ☼ Reliability
Description
run_iface exits its polling loop when either running flag is cleared without terminating or
reaping the active scan_pid. Closing the screen or unplugging an adapter can therefore leave iw
scanning after shutdown and later leave an unreaped child, while reopening can launch an overlapping
scan for the same radio.
Code

src/spectrum_scanner.cpp[R284-286]

+            waited_pid = waitpid(scan_pid, &status, WNOHANG);
+            if (waited_pid == 0) std::this_thread::sleep_for(std::chrono::milliseconds(150));
+        } while (waited_pid == 0 && iface_running->load() && g_running.load());
Evidence
The scan PID exists only as a local variable, and the sole wait is nonblocking inside a loop
conditioned on the worker flags. Both screen unload and hotplug clear those flags and join only the
worker thread, with no child cleanup before a later start may create another worker.

src/spectrum_scanner.cpp[227-239]
src/spectrum_scanner.cpp[268-293]
src/spectrum_scanner.cpp[303-346]
src/gsmenu/spectrum_screen.c[214-225]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
Worker shutdown abandons an in-flight `iw scan passive` child because the polling loop exits without terminating or reaping its PID.
## Fix Focus Areas
- src/spectrum_scanner.cpp[268-293]
- src/spectrum_scanner.cpp[303-333]
## Recommended Fix
Ensure every scan PID is reaped on every exit path. When shutdown or interface removal interrupts an active scan, terminate the child, escalate after a bounded timeout if necessary, and call `waitpid` before allowing the worker to finish.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


3. Scan failures trigger a process storm ✓ Resolved 🐞 Bug ☼ Reliability
Description
run_iface immediately starts another iteration after a fork failure or a child that exits promptly
because the outer loop has no failure delay. A missing iw executable, rejected scan, unavailable
radio, or transient fork failure consequently makes every adapter repeatedly fork and parse as fast
as the system permits.
Code

src/spectrum_scanner.cpp[R283-286]

+            if (scan_pid < 0) break;  /* fork failed; still show whatever chan_info has */
+            waited_pid = waitpid(scan_pid, &status, WNOHANG);
+            if (waited_pid == 0) std::this_thread::sleep_for(std::chrono::milliseconds(150));
+        } while (waited_pid == 0 && iface_running->load() && g_running.load());
Evidence
A fork failure breaks only the inner polling loop, after which the still-true outer loop immediately
calls start_scan_async again. Prompt child failures are reaped by waitpid and follow the same
immediate restart path because no status check or delay exists between rounds.

src/spectrum_scanner.cpp[227-239]
src/spectrum_scanner.cpp[265-287]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
Failed or immediately rejected scan attempts return directly to the unthrottled outer loop, causing rapid process creation and file parsing.
## Fix Focus Areas
- src/spectrum_scanner.cpp[268-287]
## Recommended Fix
Inspect fork and child exit results and apply an interruptible bounded backoff after failures, including executable-not-found and nonzero scan exits. Keep shutdown responsive by checking both running flags during the delay, and reset the backoff after a normal scan round.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


Grey Divider

Tip of the day
💡 Did you know, you can group findings by type and pick your Finding display, from Minimal to Full

More tips ↗ | Customize Qodo ↗ | Qodo docs ↗

Grey Divider

Qodo Logo

Comment thread src/spectrum_scanner.cpp Outdated
Comment thread src/spectrum_scanner.cpp
Comment thread src/spectrum_scanner.cpp
1. parse_chan_info's header-detection loop broke on the iteration AFTER
   finding the "Index" line, so that next getline() (the first data
   row) was consumed and discarded before the row-parsing loop ever
   saw it. Every scan silently lost its first channel (and a
   single-channel report looked empty). Now breaks in the same
   iteration the header line is found.

2. run_iface exited its polling loop on shutdown/unplug without
   terminating or reaping the in-flight `iw scan` child, leaking a
   running scan process and risking an overlapping scan on the same
   radio if the screen reopened. Now SIGTERMs and reaps it before
   returning.

3. A fork failure or a scan that exited immediately (missing `iw`, a
   rejected scan, no radio) had no back-off, so run_iface would
   fork/exec as fast as the system allowed. Now backs off 1s
   (interruptible) before retrying after any non-clean exit; a
   successful scan still restarts immediately with no pause.
@Lupinixx

Copy link
Copy Markdown
Contributor Author
WIN_20260915_21_47_36_Pro.mp4

Reference video, while running the scan starting a speedtest on my phone.

josephnef added a commit to OpenIPC/devourer that referenced this pull request Sep 17, 2026
… as a library primitive (#432)

## Why

Analysing
[OpenIPC/PixelPilot_rk#148](OpenIPC/PixelPilot_rk#148)
— a GS spectrum screen built on the vendor driver's `chan_info` procfs —
sent us looking for the equivalent gaps here. Three turned out to be
structural and connected:

1. **Frame-free sensing is Realtek-locked.** `GetRxEnergy` is on
`IRtlRadio`, so `src/chanmig/` and `src/hopset/` — the whole
adaptive-link story — are vendor-neutral in their *logic* but can gather
no evidence at all on the MediaTek backend shipped in #422. Commit
`90e1cad` (#415) named this exact follow-up: *"hopset TX-side sensing
and the chanmig energy probe are Realtek-only until a neutral frame-free
energy type exists"*.
2. **`RxQuality` never got #431's new sensors.** `clm`/`nhm_env` went
onto `RxEnergy` (Realtek-only); the neutral struct that already carries
`fa_ofdm`/`cca_ofdm`/`igi` did not.
3. **No library-level survey API.** The dwell loop lived only in
`examples/chanscout/main.cpp` — and was untested: the scheduler has a
selftest, the consumer has one, the executor between them had none.

## The neutral concept is busy airtime, not phydm counters

`RxEnergy` cannot be the portable type — it is phydm-shaped, and
`IRadio.h` and `IRtlRadio.h` both assign those counters to the Realtek
level on purpose. But one field in it is already neutral and says so:
CLM is *"AIRTIME, directly comparable across channels and adapters, and
unlike NHM it needs no gain reference."*

Both silicon families count it in hardware:

| family | mechanism |
|---|---|
| Realtek J1/J2/J3 | CCX CLM, 4 µs busy ticks |
| MediaTek MT7612U | `MT_CH_BUSY` / `MT_CH_IDLE`, already implemented
and armed in this port |

## Commit 1 — `ChannelBusy` on `IRadio`

`ChannelBusy` + `BusySource` in `src/RxSense.h`, with two **pure**
conversions so every arithmetic decision is testable with no device.

`BusySource` is load-bearing, not metadata: the MediaTek timers count
TX+RX+NAV+EIFS, so a transmitting radio includes its own airtime, while
Realtek's CLM is receive-side deferral only. Ranking across a mixed
adapter pair compares two rulers.

Implemented **once** on `IRtlRadio` in terms of `GetRxEnergy(true)` —
all five Realtek backends inherit it with no per-backend edits, and the
two without CLM (RTL8733B, Kestrel) report *no reading* rather than a
fabricated zero, by construction.

`AdapterCaps::busy_airtime_ok` / `_measured` / `rx_energy_ok` retire a
discriminator that was never correct: the RTL8733B passes
`dynamic_cast<IRtlRadio*>` and implements no energy reader at all.

The MediaTek side goes through a **narrow** new `mt7612u_ch_time()`
touching only the two channel-timer registers. Not
`mt7612u_link_stats()`: that also reads `MT_RX_STAT_1`, whose false-CCA
field is read-and-clear and owned by `mt7612u_phy_tick()`'s AGC loop, so
polling it at caller cadence would both misreport the figure and starve
the gain tracking. For the same reason `energy_pct` is left invalid
there — the only candidate counter has an owner.

Also: the Realtek DIG rails hardcoded inside neutral `build_rx_quality`
move into `LinkHealthThresholds` with the same defaults.
Behaviour-identical; a neutral header stops asserting Realtek register
constants.

## Commit 2 — `src/sensing/`, the dwell as a library primitive

A new subtree, the one that calls device methods. It owns no thread,
performs no sleep and takes no clock of record — which is what lets
`chanmig/` and `hopset/` keep the purity they both assert. (`src/cell/`
was not a precedent: it takes raw scalars and includes no `IRadio.h`.)

Two layers, so a later `examples/tx` conversion is mechanical:
`SenseWindow.h` is the shared settle → barrier → observe → read
discipline with no retune and no frame term — `hopset_sense_window`
still carries its own copy and its comment already says *"the discipline
is chanscout's"* — and `DwellExecutor.h` is the survey-shaped layer.
`chanscout` converts onto it: **40 insertions, 186 deletions**, with all
the demo policy (health, thermal, p95, bin-age, advise) deliberately
left behind.

### A latent bug the new test caught

A `SetMonitorChannel` throw part-way through a full-width dwell left the
width-restoration latch clear, so the next bin dwell took the lean
same-width `FastRetune` at a possibly-80 MHz width — and every later bin
would have observed at the candidate's width. A wrong reading that still
looks entirely plausible. Now any full-gate tune that throws sets the
latch, because the chip may have been part-way reconfigured.

## Validation

**Headless:** 66/66 ctest including two new gates; ASan+UBSan clean;
TSan clean — newly meaningful, because the frame aggregator is exercised
by a test for the first time; jaguar1-only, jaguar3-only and
mt7612u-only subsets build and pass. The mt7612u subset is what caught a
caps block landing in `GetTxCaps` instead of `GetAdapterCaps`, since
MT7612U is off in a default build.

**On air (8812CU):** a baseline `chanscout` built from master, run back
to back against the refactored one on the same plan — 314 dwells each
side, seq gapless on both, identical flag histogram, no field lost,
`observe_ms` median 112 → 112 (0% drift), `retune_us` 1304 → 1314 (1%).
An earlier run showed 107 → 102, which is what exposed `finish()`
sampling its timestamp before the observation read and silently
shortening the window the plausibility ceiling derives from.
`tests/chanscout_stress.sh` completed its full run: **2728 consecutive
dwell retunes, seq gapless, zero flags of any kind set across the whole
stream** (no truncation, no retune failure, no read failure, no
counter-suspect, no missing NHM), scout health `ok` throughout, no
wedge.

**Not validated, and shipped saying so:** the MediaTek path — no MT7612U
on the bench. `busy_airtime_measured = false`, register basis documented
in `src/mt7612u/CLAUDE.md`. Two things to measure when an adapter is
available: that busy/idle track real occupancy beyond repetition noise,
and — the real risk — that polling at dwell cadence does not disturb
`phy_tick`'s gain tracking.

## Deliberately not here

- **Rewiring `ChannelScore`** off its `fa_rate/(fa_rate+200)` magic
constant onto real CLM airtime. That changes the migration law and needs
its own re-validation of the 14-row failure matrix and an on-air soak.
- **Converting `examples/tx`.** It is the TX hot path, called inside the
slot-timed hop loop, and its correctness criterion is on-air FHSS
lockstep that headless tests cannot see.
- Nothing calls `GetChannelBusy` yet, so commit 1 is provably
no-behaviour-change on every Realtek path.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

https://claude.ai/code/session_01Au9D2ntoFn4ABLJqp9vYDk

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant