Skip to content

APFPV link mode: join the OpenIPC apfpv camera's open AP with devourer - #129

Open
dawitt50 wants to merge 3 commits into
OpenIPC:masterfrom
dawitt50:ap-mode
Open

dawitt50 wants to merge 3 commits into
OpenIPC:masterfrom
dawitt50:ap-mode

Conversation

@dawitt50

Copy link
Copy Markdown

Adds a second link mode next to WFB-NG: the USB adapter (devourer userspace driver) joins the open Wi-Fi access point of an OpenIPC apfpv camera as a regular station, and the camera pushes its RTP stream to the phone's static IP.

  • StaLink (native): probe/auth/assoc state machine, ARP, ICMP echo, a minimal TCP client for the camera's air_man_ap control port, UDP forwarding to the local player (5600) and MAVLink (14550).
  • Connects without a scan (last known channel, then the apfpv boot channels; full sweep optional).
  • Follows channel switch announcements, including 40/80 MHz width changes.
  • Dropouts don't end the association; rejoin goes straight to the known BSSID after a deauth or a camera restart (beacon TSF jump). A key frame is requested whenever video can flow again.
  • App: Link mode setting, APFPV settings dialog, Channel menu sends set_ap_channel, and Drone → Settings opens native camera controls (air_man_ap + aalinkFPV web API).
  • README: new section "Link modes: WFB-NG and APFPV".

Camera side (apfpv firmware)

The stock firmware runs a WPA2 access point with a DHCP server. For this mode, /usr/bin/adapter (which writes hostapd.conf on every start) was patched:

  • WPA2 off: wpa_passphrase and rsn_pairwise removed, wpa=2 -> wpa=0 (open network, no 4-way handshake when joining).
  • No DHCP: udhcpd is no longer started, nor restarted when a station connects; the ground station uses the fixed IP 192.168.0.10. Instead of the DHCP reset, only the station's ARP entry is cleared (ip neigh del).
  • disassoc_low_ack=0: hostapd no longer drops the station when many frames go unacknowledged during a fade, so the association survives a dropout and video resumes without rejoining.
  • Optional MAC filter (macaddr_acl=1 with an accept_mac_file) so only the ground station's adapter MAC can join.
  • majestic sends the stream to the ground station (outgoing.server: udp://192.168.0.10:5600).

The camera changes are scripted in tools/apfpv-open-static-cam.sh (keeps a backup, --revert restores it).

Limitations

Open networks only (no WEP/WPA), static IP (no DHCP), no 802.11 fragment reassembly, UDP only plus one short TCP request at a time.

Tested on a RTL8812EU adapter with OpenIPC apfpv firmware.

🤖 Generated with Claude Code

Stefan Wittmann and others added 3 commits October 17, 2026 23:19
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
tools/apfpv-open-static-cam.sh patches the apfpv firmware's /usr/bin/adapter
for an open access point without DHCP and with disassoc_low_ack=0, optionally
with a MAC filter; --revert restores the backup. Referenced from the README.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@qodo-free-for-open-source-projects

Copy link
Copy Markdown

PR Summary by Qodo

Add APFPV station link mode for OpenIPC cameras

✨ Enhancement 📝 Documentation ⚙️ Configuration changes 🕐 40+ Minutes

Grey Divider

AI Description

• Add APFPV mode so a USB adapter can join an OpenIPC camera’s open access point.
• Forward camera video and MAVLink locally, with channel-switch and dropout recovery.
• Add camera controls, a reversible camera setup script, and configuration guidance.
Diagram

graph TD
  UI["Video Activity"] --> Bridge["Java link API"] --> Native["Native link"] --> Station["StaLink stack"] --> Adapter["USB adapter"] --> Camera["Camera AP"]
  UI --> Controls["Camera controls"] --> Bridge
  Camera --> Station --> Player["Local player"]
Loading
High-Level Assessment

The following are alternative approaches to this PR:

1. Use an established embedded TCP/IP stack
  • ➕ Reduces the amount of custom transport-protocol logic to maintain and validate.
  • ➖ Adds a dependency and requires integration with devourer’s raw-frame and station paths.
2. Use the phone’s standard Wi-Fi connection
  • ➕ Avoids implementing a station and IP stack in the app.
  • ➖ Does not meet the goal of joining through the USB adapter while preserving the phone’s own networking.

Recommendation: The devourer-based station path fits the USB-adapter requirement and reuses the existing local video and MAVLink consumers. Keep the protocol scope narrow, but prioritize hardware and packet-level testing of association, channel switches, fragmentation, and TCP recovery; an established TCP/IP stack is worth considering if control traffic expands.

Files changed (11) +3605 / -25

Enhancement (8) +3402 / -25
CameraControlDialog.javaAdd native APFPV camera controls +737/-0

Add native APFPV camera controls

• Adds a settings dialog for air_man_ap commands and authenticated aalinkFPV HTTP endpoints. Serializes requests on a background executor and supports transport over either the USB adapter or the phone’s Wi-Fi.

app/src/main/java/com/openipc/pixelpilot/CameraControlDialog.java

VideoActivity.javaExpose APFPV mode, settings, and recovery status +474/-7

Expose APFPV mode, settings, and recovery status

• Persists link and camera settings, adds mode-aware menus and camera controls, and switches adapter and VPN behavior with the selected mode. Displays AP connection statistics, remembers the camera channel, and requests a key frame after recovery; also makes the WFB-NG drone web address configurable.

app/src/main/java/com/openipc/pixelpilot/VideoActivity.java

WfbLinkManager.javaShow APFPV-specific adapter startup status +7/-1

Show APFPV-specific adapter startup status

• Tracks whether APFPV mode is selected for startup messaging and shows a camera-join message instead of the WFB-NG channel message.

app/src/main/java/com/openipc/pixelpilot/WfbLinkManager.java

StaLink.cppImplement the APFPV userspace station and packet path +1551/-0

Implement the APFPV userspace station and packet path

• Implements open-network discovery and association, channel and width tracking, dropout recovery, and adapter frame handling. Processes ARP, ICMP, IPv4 fragments, incoming UDP forwarding, and one short TCP request at a time for camera control.

app/wfbngrtl8812/src/main/cpp/StaLink.cpp

StaLink.hDefine station configuration, state, and transport interfaces +262/-0

Define station configuration, state, and transport interfaces

• Declares the station lifecycle, connection and recovery status, traffic counters, and blocking camera TCP request API. Defines the configuration and internal state used for scanning, retuning, packet handling, and transport sessions.

app/wfbngrtl8812/src/main/cpp/StaLink.h

WfbngLink.cppWire APFPV station mode into the native link +267/-17

Wire APFPV station mode into the native link

• Branches adapter startup into StaLink, configures station transmission and ACK behavior, and releases devices on shutdown. Adds JNI entry points for AP configuration, status, recovery counts, and camera commands while retaining the WFB-NG path.

app/wfbngrtl8812/src/main/cpp/WfbngLink.cpp

WfbngLink.hppAdd native link-mode and station ownership state +34/-0

Add native link-mode and station ownership state

• Defines WFB-NG and APFPV modes, stores station configuration and its shared lifetime, and declares station startup and device-release helpers.

app/wfbngrtl8812/src/main/cpp/WfbngLink.hpp

WfbNgLink.javaExpose APFPV operations to Android +70/-0

Expose APFPV operations to Android

• Adds Java mode constants, native declarations, and wrappers for station settings, connection status, recovery counts, and camera TCP commands.

app/wfbngrtl8812/src/main/java/com/openipc/wfbngrtl8812/WfbNgLink.java

Documentation (1) +77 / -0
README.mdDocument WFB-NG and APFPV link modes +77/-0

Document WFB-NG and APFPV link modes

• Explains APFPV setup, channel discovery and switching, camera controls, dropout behavior, and protocol limitations. Includes the camera-side setup command and majestic stream destination.

README.md

Other (2) +126 / -0
CMakeLists.txtBuild the new station implementation +2/-0

Build the new station implementation

• Adds StaLink source and header files to the native shared-library target.

app/wfbngrtl8812/src/main/cpp/CMakeLists.txt

apfpv-open-static-cam.shProvide reversible OpenIPC AP preparation +124/-0

Provide reversible OpenIPC AP preparation

• Backs up and patches the camera’s adapter script to run an open AP without DHCP and to retain associations through fades. Supports an optional MAC allowlist, restart control, and restoration from the backup.

tools/apfpv-open-static-cam.sh

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

Copy link
Copy Markdown

Code Review by Qodo

🐞 Bugs (11) 📘 Rule violations (0) 📜 Skill insights (0)

Grey Divider


Action required

1. A rejected adapter can crash later controls 🐞 Bug ☼ Reliability
Description
runStation() assigns current_fd before checking whether another adapter already owns the AP
association. If a second adapter is rejected, its device is erased but its descriptor remains
current after the first adapter exits, so a later adaptive-link or transmit-power operation reaches
rtl_devices.at() with a missing descriptor.
Code

app/wfbngrtl8812/src/main/cpp/WfbngLink.cpp[306]

+    current_fd = fd;
Evidence
The second run overwrites the descriptor before rejection and erases that device; cleanup of the
first device cannot clear the overwritten value, while AP-mode controls dereference it.

app/wfbngrtl8812/src/main/cpp/WfbngLink.cpp[304-331]
app/wfbngrtl8812/src/main/cpp/WfbngLink.hpp[146-149]
app/wfbngrtl8812/src/main/cpp/WfbngLink.cpp[675-680]
app/wfbngrtl8812/src/main/cpp/WfbngLink.cpp[806-826]

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

## Issue description
A rejected second AP-mode adapter overwrites the active descriptor, leaving later device controls able to access an erased device.
## Fix Focus Areas
- app/wfbngrtl8812/src/main/cpp/WfbngLink.cpp[304-331]
- app/wfbngrtl8812/src/main/cpp/WfbngLink.hpp[146-149]
## Recommended Fix
Set `current_fd` only after accepting the adapter as the station, and ensure rejection cannot change the active adapter's descriptor.

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


2. Video fragments can assemble with gaps 🐞 Bug ≡ Correctness
Description
reassemble() counts every fragment's length toward completion without checking which byte ranges
have arrived. An overlapping fragment or a delayed duplicate can make the count reach the expected
size while bytes are missing, after which the zero-filled datagram can be forwarded to the video
listener.
Code

app/wfbngrtl8812/src/main/cpp/StaLink.cpp[1036]

+    it->received += plen;
Evidence
The implementation copies at each offset but increments a plain byte sum; its completion check then
dispatches the reconstructed packet to the UDP forwarding path. Retry suppression compares only with
the immediately previous sequence number, so it does not rule out a delayed duplicate.

app/wfbngrtl8812/src/main/cpp/StaLink.cpp[1030-1047]
app/wfbngrtl8812/src/main/cpp/StaLink.cpp[964-968]
app/wfbngrtl8812/src/main/cpp/StaLink.cpp[1093-1101]
app/wfbngrtl8812/src/main/cpp/StaLink.cpp[1113-1152]

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

## Issue description
Summing fragment lengths treats overlapping or repeated bytes as newly received bytes and can complete an IP datagram with a gap.
## Fix Focus Areas
- app/wfbngrtl8812/src/main/cpp/StaLink.cpp[1009-1047]
- app/wfbngrtl8812/src/main/cpp/StaLink.h[224-236]
## Recommended Fix
Track received byte ranges or blocks per datagram, and return a reassembled packet only when every byte through the final fragment has been received.

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


3. A missing MAC argument drops the filter 🐞 Bug ⛨ Security
Description
The --allow-mac parser shifts to an argument without first checking that one exists. When the
option is last, MAC stays empty and the script continues past the failed shift, skips both
validation and ACL installation, and installs the open-network configuration.
Code

tools/apfpv-open-static-cam.sh[R45-48]

+        --allow-mac) shift; MAC=$(echo "$1" | tr 'A-F' 'a-f') ;;
+        *) die "unknown option: $1" ;;
+    esac
+    shift
Evidence
Argument handling has no missing-value check, and both validation and ACL insertion are conditional
on a nonempty MAC.

tools/apfpv-open-static-cam.sh[41-49]
tools/apfpv-open-static-cam.sh[68-70]
tools/apfpv-open-static-cam.sh[99-105]
tools/apfpv-open-static-cam.sh[118-120]

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

## Issue description
Invoking `--allow-mac` without its value silently installs without the requested MAC restriction.
## Fix Focus Areas
- tools/apfpv-open-static-cam.sh[41-49]
- tools/apfpv-open-static-cam.sh[68-70]
## Recommended Fix
Check that an argument remains immediately after `--allow-mac` and exit with an error before modifying the adapter if it does not.

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



Remediation recommended

4. An ACL write failure leaves a partial install 🐞 Bug ☼ Reliability
Description
The script replaces /usr/bin/adapter before writing the requested MAC accept file. If that write
fails, it exits without restoring the adapter, leaving the open-network configuration installed with
a reference to a missing or unwritten ACL file.
Code

tools/apfpv-open-static-cam.sh[R118-119]

+mv "$TMP" "$ADAPTER" && chmod 755 "$ADAPTER" || die "writing $ADAPTER failed"
+[ -n "$MAC" ] && { echo "$MAC" > "$ACCEPT" || die "writing $ACCEPT failed"; echo "Only $MAC may join ($ACCEPT)."; }
Evidence
ACL directives are inserted into the generated adapter, which is moved into place before the
accept-file write that can call die.

tools/apfpv-open-static-cam.sh[99-109]
tools/apfpv-open-static-cam.sh[114-120]

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

## Issue description
Failure to create the MAC accept file leaves the adapter replaced despite the installation command failing.
## Fix Focus Areas
- tools/apfpv-open-static-cam.sh[96-120]
## Recommended Fix
Create and verify the accept file before replacing the adapter, or restore the backed-up adapter and clean up on any later failure.

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


5. Brief fades miss the key-frame request 🐞 Bug ≡ Correctness
Description
tick() is the only place that sets in_gap_, while onRx() increments the recovery counter only
if that flag is already set. If reception resumes just after the 250 ms dropout threshold but before
the next worker tick, the app receives no recovery change and does not request a new key frame.
Code

app/wfbngrtl8812/src/main/cpp/StaLink.cpp[R688-692]

+        if (in_gap_ && state_.load() == State::Connected) {
+            // Dropout over: video flows again right now, nothing to rebuild.
+            in_gap_ = false;
+            recoveries_++;
+            __android_log_print(ANDROID_LOG_INFO,
Evidence
The worker checks for silence periodically, but reception checks only the flag; Java requests a key
frame solely on a changed recovery count.

app/wfbngrtl8812/src/main/cpp/StaLink.cpp[30-42]
app/wfbngrtl8812/src/main/cpp/StaLink.cpp[560-565]
app/wfbngrtl8812/src/main/cpp/StaLink.cpp[688-708]
app/src/main/java/com/openipc/pixelpilot/VideoActivity.java[2167-2175]

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

## Issue description
A dropout that ends between worker ticks never sets the recovery flag, so the video refresh request is skipped.
## Fix Focus Areas
- app/wfbngrtl8812/src/main/cpp/StaLink.cpp[688-708]
- app/wfbngrtl8812/src/main/cpp/StaLink.cpp[560-565]
## Recommended Fix
When an AP packet arrives, compare its arrival time with `last_heard_` and apply the dropout threshold there as well, before deciding whether to increment recoveries.

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


6. A fast restart can miss a key frame 🐞 Bug ≡ Correctness
Description
lastApRecoveries persists across adapter runs, but each new StaLink starts its recovery counter
at zero. If the new association reaches the previous count before the next periodic stats callback,
the equality check misses that association and skips its key-frame request.
Code

app/src/main/java/com/openipc/pixelpilot/VideoActivity.java[R2170-2174]

+        if (recoveries != lastApRecoveries) {
+            lastApRecoveries = recoveries;
+            if (recoveries > 0) {
+                requestKeyFrame();
+            }
Evidence
The native counter is initialized per station instance, whereas the activity compares only its
numeric value on a timer independent of station creation.

app/wfbngrtl8812/src/main/cpp/StaLink.h[199-203]
app/wfbngrtl8812/src/main/cpp/WfbngLink.cpp[318-323]
app/wfbngrtl8812/src/main/cpp/WfbngLink.cpp[629-635]
app/src/main/java/com/openipc/pixelpilot/VideoActivity.java[2124-2126]
app/src/main/java/com/openipc/pixelpilot/VideoActivity.java[2167-2175]

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

## Issue description
A new station can reuse the previous station's recovery count before stats observe an intermediate zero, hiding a new association.
## Fix Focus Areas
- app/src/main/java/com/openipc/pixelpilot/VideoActivity.java[2124-2126]
- app/src/main/java/com/openipc/pixelpilot/VideoActivity.java[2167-2175]
## Recommended Fix
Associate the observed count with an adapter generation, or reset the saved count when restarting adapters so the new association always triggers a key-frame request.

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


View review recommended (5)
7. Delayed replies can truncate camera settings 🐞 Bug ☼ Reliability
Description
tcpTick() finishes a request after 1.5 seconds without new bytes even when the peer has not closed
and an HTTP response is incomplete. If a later response segment is delayed beyond that interval,
cgi() receives a non-null partial reply and returns on JSON parse failure rather than trying the
request again.
Code

app/wfbngrtl8812/src/main/cpp/StaLink.cpp[R1275-1277]

+    if (tcp_.st == St::Established && !tcp_.rx.empty() && now - tcp_.last_rx >= kTcpReplyIdle) {
+        tcpFinish(true);
+        return;
Evidence
The native idle path marks any nonempty reply complete; the Java parser retries null transport
replies but immediately returns for a non-null reply containing incomplete JSON.

app/wfbngrtl8812/src/main/cpp/StaLink.cpp[1243-1252]
app/wfbngrtl8812/src/main/cpp/StaLink.cpp[1270-1278]
app/src/main/java/com/openipc/pixelpilot/CameraControlDialog.java[518-541]

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

## Issue description
An inter-segment delay makes native TCP return a partial but non-null HTTP reply, which the camera-settings parser does not retry.
## Fix Focus Areas
- app/wfbngrtl8812/src/main/cpp/StaLink.cpp[1270-1280]
- app/src/main/java/com/openipc/pixelpilot/CameraControlDialog.java[518-541]
## Recommended Fix
For HTTP requests, determine completion from the response framing or peer FIN rather than the short idle interval; also retry incomplete or unparseable replies within the existing request retry loop.

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


8. Camera commands fail when another is in flight 🐞 Bug ☼ Reliability
Description
StaLink::tcpRequest returns false straight away when tcp_.st != St::Idle. Callers read that as
"no answer": requestKeyFrame and the AP Channel menu (onApChannelSelected) don't retry, so they
give up, and only the camera dialog's airman/cgi retry. A key-frame request started by a
recovery (for example during a channel switch or right after a rejoin) can therefore make a user's
set_ap_channel report "Camera did not answer, channel unchanged". In the other direction, a
running dialog or channel command silently drops the IDR request that recovery needed.
Code

app/wfbngrtl8812/src/main/cpp/StaLink.cpp[1208]

+    if (!running_ || state_.load() != State::Connected || tcp_.st != St::Idle) return false;
Evidence
There is only one TCP session slot. A busy slot returns false right away without waiting.
nativeCameraCommand passes that on as null, and the Java callers treat null as the camera not
answering. Only CameraControlDialog retries.

app/wfbngrtl8812/src/main/cpp/StaLink.cpp[1200-1210]
app/src/main/java/com/openipc/pixelpilot/VideoActivity.java[2133-2154]
app/src/main/java/com/openipc/pixelpilot/VideoActivity.java[487-498]
app/src/main/java/com/openipc/pixelpilot/CameraControlDialog.java[505-514]

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

## Issue description
`StaLink::tcpRequest` returns false right away when another TCP request is in flight. Callers can't tell this apart from "camera did not answer", so key-frame requests and AP channel switches fail at random.

## Fix Focus Areas
- app/wfbngrtl8812/src/main/cpp/StaLink.cpp[1200-1210]
- app/src/main/java/com/openipc/pixelpilot/VideoActivity.java[2133-2154]
- app/src/main/java/com/openipc/pixelpilot/VideoActivity.java[487-498]

## Recommended Fix
In `tcpRequest`, if `tcp_.st != Idle`, wait on `tcp_cv_` (with the caller's timeout) until the slot goes back to Idle before starting, and return false only when not connected or the wait times out. Have the callers notify waiters when they reset `tcp_.st = Idle`. Another option is to serialize requests with a dedicated mutex held for the whole request.

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


9. Adapter object can be freed while stop runs 🐞 Bug ☼ Reliability
Description
The new releaseDevice(fd) call at the end of run()/runStation() erases the
shared_ptr<IRtlDevice> from rtl_devices on the run thread. Meanwhile WfbngLink::stop() on the
Java thread does find/at on the same unsynchronized map and calls StopRxLoop() and
stop_adaptive_link() through a raw pointer. When stop() ends the RX loop, the run thread wakes
up and can destroy the device (and mutate the map) while stop() is still inside StopRxLoop().
That is a use-after-free and a data race on std::map. Before this PR, entries were never erased on
the normal exit path.
Code

app/wfbngrtl8812/src/main/cpp/WfbngLink.cpp[R374-375]

+    dev->Stop();
+    releaseDevice(fd);
Evidence
stop() takes a raw IRtlDevice* from rtl_devices without any lock. releaseDevice erases the
owning shared_ptr from the RX thread as soon as StartRxLoop returns. nativeSetTxPower also
reads the map through current_fd without a lock.

app/wfbngrtl8812/src/main/cpp/WfbngLink.cpp[382-398]
app/wfbngrtl8812/src/main/cpp/WfbngLink.hpp[142-149]
app/wfbngrtl8812/src/main/cpp/WfbngLink.cpp[365-379]
app/wfbngrtl8812/src/main/cpp/WfbngLink.cpp[810-819]

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

## Issue description
`releaseDevice()` now erases devices from `rtl_devices` at the end of `run()`. `stop()` and `nativeSetTxPower` read the same map and call methods through raw pointers on other threads, with no synchronization.

## Fix Focus Areas
- app/wfbngrtl8812/src/main/cpp/WfbngLink.hpp[142-149]
- app/wfbngrtl8812/src/main/cpp/WfbngLink.cpp[382-398]
- app/wfbngrtl8812/src/main/cpp/WfbngLink.cpp[810-819]

## Recommended Fix
Add a mutex around every `rtl_devices` access (insert, erase, find/at, and `current_fd`). In `stop()` and the TX power setter, copy the `shared_ptr<IRtlDevice>` while holding the lock and call through that copy, so the object stays alive until the call returns.

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


10. Failed channel switch is taken as success 🐞 Bug ≡ Correctness
Description
StaLink::worker() catches exceptions from FastRetune() or SetMonitorChannel() but still sets
channel_, width_mhz_, chan_offset_, and dwell_start_ to the requested values and clears the
pending switch. If the driver rejects a CSA width change or scan hop, the radio remains on its old
settings while search and probe decisions, the status line, and connectedChannel() use the false
state, with no retune retry scheduled.
Code

app/wfbngrtl8812/src/main/cpp/StaLink.cpp[R297-304]

+            } catch (const std::exception &e) {
+                __android_log_print(ANDROID_LOG_ERROR, TAG, "retune to ch %d failed: %s", retune, e.what());
+            }
+            std::lock_guard<std::mutex> lock(mu_);
+            channel_ = static_cast<uint8_t>(retune);
+            width_mhz_ = retune_width;
+            chan_offset_ = retune_offset;
+            dwell_start_ = Clock::now();
Evidence
The retune calls can throw, but their catch block only logs the exception; the following
lock-guarded assignments update the tuned-channel and width state regardless of success. Subsequent
channel-search logic relies on those assignments, so a failed driver switch is treated as though it
completed.

app/wfbngrtl8812/src/main/cpp/StaLink.cpp[273-306]
app/wfbngrtl8812/src/main/cpp/StaLink.cpp[273-305]
app/wfbngrtl8812/src/main/cpp/StaLink.cpp[466-485]
app/wfbngrtl8812/src/main/cpp/StaLink.cpp[552-588]

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

## Issue description
In `StaLink::worker()`, a failed retune is recorded as successful, leaving station channel state, search behavior, and reported channel inconsistent with the radio.

## Fix Focus Areas
- app/wfbngrtl8812/src/main/cpp/StaLink.cpp[273-306]
- app/wfbngrtl8812/src/main/cpp/StaLink.cpp[466-485]

## Recommended Fix
Track a success flag set only when `FastRetune()` or `SetMonitorChannel()` returns normally, and update the tuned channel, width, offset, and dwell bookkeeping only on success. On failure, retain the actual settings and arrange a retry or recovery search—for example, restore `retune_to_` under `mu_` or fall back to a 20 MHz `SetMonitorChannel` on the requested primary channel.

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


11. Camera login is sent in clear over open Wi-Fi 🐞 Bug ⛨ Security
Description
requestKeyFrame and CameraControlDialog.cgi() send the drone username and password as HTTP Basic
auth over the camera's access point. With this PR (and tools/apfpv-open-static-cam.sh) that access
point has WPA2 removed. Anyone in radio range can capture those credentials, which are usually the
camera's root login, and replay them against the camera's web API. Key-frame requests go out
automatically on every association and every end of a dropout, so the credentials are broadcast
often, not only when the user opens the controls.
Code

app/src/main/java/com/openipc/pixelpilot/VideoActivity.java[R2139-2144]

+        String auth = Base64.encodeToString((getDroneUsername() + ":" + getDronePassword())
+                .getBytes(java.nio.charset.StandardCharsets.UTF_8), Base64.NO_WRAP);
+        String request = "GET /request/idr HTTP/1.0\r\n"
+                + "Host: " + cameraIp + "\r\n"
+                + "Authorization: Basic " + auth + "\r\n"
+                + "Connection: close\r\n\r\n";
Evidence
The script turns WPA off (wpa=0, passphrase removed), and the MAC filter is optional. The app builds
a Basic Authorization header from the stored drone credentials and sends it in plaintext HTTP over
that open link.

app/src/main/java/com/openipc/pixelpilot/VideoActivity.java[2133-2154]
app/src/main/java/com/openipc/pixelpilot/CameraControlDialog.java[523-530]
tools/apfpv-open-static-cam.sh[100-108]

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

## Issue description
APFPV mode runs over an unencrypted access point, yet the app sends the drone login as HTTP Basic auth, automatically for every key-frame request and for every camera-control request.

## Fix Focus Areas
- app/src/main/java/com/openipc/pixelpilot/VideoActivity.java[2133-2154]
- app/src/main/java/com/openipc/pixelpilot/CameraControlDialog.java[523-530]
- README.md

## Recommended Fix
Request key frames through an unauthenticated channel (for example an air_man_ap command, or majestic's IDR request on a port with no credentials), or make the automatic key-frame request opt-in. Warn the user in the APFPV settings dialog and in the README that the camera password is sent in clear over the open network, and recommend a dedicated password for this mode.

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


Grey Divider

Context sources
Review mode: Auto: 🧠 Deep: Large, cross-layer networking feature with many independent, subtle failure paths.

Grey Divider

Tip of the day
💡 Did you know, you can see how risky a PR is at a glance with blast radius labels

More tips ↗ | Customize Qodo ↗ | Qodo docs ↗

Grey Divider

Qodo Logo


int WfbngLink::runStation(libusb_device_handle *dev_handle, libusb_context *ctx, int fd, int wifiChannel) {
IRtlDevice *dev = rtl_devices.at(fd).get();
current_fd = fd;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Action required

1. A rejected adapter can crash later controls 🐞 Bug ☼ Reliability

runStation() assigns current_fd before checking whether another adapter already owns the AP
association. If a second adapter is rejected, its device is erased but its descriptor remains
current after the first adapter exits, so a later adaptive-link or transmit-power operation reaches
rtl_devices.at() with a missing descriptor.
Agent Prompt
## Issue description
A rejected second AP-mode adapter overwrites the active descriptor, leaving later device controls able to access an erased device.
## Fix Focus Areas
- app/wfbngrtl8812/src/main/cpp/WfbngLink.cpp[304-331]
- app/wfbngrtl8812/src/main/cpp/WfbngLink.hpp[146-149]
## Recommended Fix
Set `current_fd` only after accepting the adapter as the station, and ensure rejection cannot change the active adapter's descriptor.

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

}
if (it->payload.size() < off + plen) it->payload.resize(off + plen);
std::memcpy(it->payload.data() + off, ip + ihl, plen);
it->received += plen;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Action required

2. Video fragments can assemble with gaps 🐞 Bug ≡ Correctness

reassemble() counts every fragment's length toward completion without checking which byte ranges
have arrived. An overlapping fragment or a delayed duplicate can make the count reach the expected
size while bytes are missing, after which the zero-filled datagram can be forwarded to the video
listener.
Agent Prompt
## Issue description
Summing fragment lengths treats overlapping or repeated bytes as newly received bytes and can complete an IP datagram with a gap.
## Fix Focus Areas
- app/wfbngrtl8812/src/main/cpp/StaLink.cpp[1009-1047]
- app/wfbngrtl8812/src/main/cpp/StaLink.h[224-236]
## Recommended Fix
Track received byte ranges or blocks per datagram, and return a reassembled packet only when every byte through the final fragment has been received.

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

Comment on lines +45 to +48
--allow-mac) shift; MAC=$(echo "$1" | tr 'A-F' 'a-f') ;;
*) die "unknown option: $1" ;;
esac
shift

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Action required

3. A missing mac argument drops the filter 🐞 Bug ⛨ Security

The --allow-mac parser shifts to an argument without first checking that one exists. When the
option is last, MAC stays empty and the script continues past the failed shift, skips both
validation and ACL installation, and installs the open-network configuration.
Agent Prompt
## Issue description
Invoking `--allow-mac` without its value silently installs without the requested MAC restriction.
## Fix Focus Areas
- tools/apfpv-open-static-cam.sh[41-49]
- tools/apfpv-open-static-cam.sh[68-70]
## Recommended Fix
Check that an argument remains immediately after `--allow-mac` and exit with an error before modifying the adapter if it does not.

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

Comment on lines +118 to +119
mv "$TMP" "$ADAPTER" && chmod 755 "$ADAPTER" || die "writing $ADAPTER failed"
[ -n "$MAC" ] && { echo "$MAC" > "$ACCEPT" || die "writing $ACCEPT failed"; echo "Only $MAC may join ($ACCEPT)."; }

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Remediation recommended

4. An acl write failure leaves a partial install 🐞 Bug ☼ Reliability

The script replaces /usr/bin/adapter before writing the requested MAC accept file. If that write
fails, it exits without restoring the adapter, leaving the open-network configuration installed with
a reference to a missing or unwritten ACL file.
Agent Prompt
## Issue description
Failure to create the MAC accept file leaves the adapter replaced despite the installation command failing.
## Fix Focus Areas
- tools/apfpv-open-static-cam.sh[96-120]
## Recommended Fix
Create and verify the accept file before replacing the adapter, or restore the backed-up adapter and clean up on any later failure.

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

Comment on lines +688 to +692
if (in_gap_ && state_.load() == State::Connected) {
// Dropout over: video flows again right now, nothing to rebuild.
in_gap_ = false;
recoveries_++;
__android_log_print(ANDROID_LOG_INFO,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Remediation recommended

5. Brief fades miss the key-frame request 🐞 Bug ≡ Correctness

tick() is the only place that sets in_gap_, while onRx() increments the recovery counter only
if that flag is already set. If reception resumes just after the 250 ms dropout threshold but before
the next worker tick, the app receives no recovery change and does not request a new key frame.
Agent Prompt
## Issue description
A dropout that ends between worker ticks never sets the recovery flag, so the video refresh request is skipped.
## Fix Focus Areas
- app/wfbngrtl8812/src/main/cpp/StaLink.cpp[688-708]
- app/wfbngrtl8812/src/main/cpp/StaLink.cpp[560-565]
## Recommended Fix
When an AP packet arrives, compare its arrival time with `last_heard_` and apply the dropout threshold there as well, before deciding whether to increment recoveries.

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

Comment on lines +1275 to +1277
if (tcp_.st == St::Established && !tcp_.rx.empty() && now - tcp_.last_rx >= kTcpReplyIdle) {
tcpFinish(true);
return;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Remediation recommended

7. Delayed replies can truncate camera settings 🐞 Bug ☼ Reliability

tcpTick() finishes a request after 1.5 seconds without new bytes even when the peer has not closed
and an HTTP response is incomplete. If a later response segment is delayed beyond that interval,
cgi() receives a non-null partial reply and returns on JSON parse failure rather than trying the
request again.
Agent Prompt
## Issue description
An inter-segment delay makes native TCP return a partial but non-null HTTP reply, which the camera-settings parser does not retry.
## Fix Focus Areas
- app/wfbngrtl8812/src/main/cpp/StaLink.cpp[1270-1280]
- app/src/main/java/com/openipc/pixelpilot/CameraControlDialog.java[518-541]
## Recommended Fix
For HTTP requests, determine completion from the response framing or peer FIN rather than the short idle interval; also retry incomplete or unparseable replies within the existing request retry loop.

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

std::string &reply) {
using St = TcpSession::St;
std::unique_lock<std::mutex> lock(mu_);
if (!running_ || state_.load() != State::Connected || tcp_.st != St::Idle) return false;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Remediation recommended

8. Camera commands fail when another is in flight 🐞 Bug ☼ Reliability

StaLink::tcpRequest returns false straight away when tcp_.st != St::Idle. Callers read that as
"no answer": requestKeyFrame and the AP Channel menu (onApChannelSelected) don't retry, so they
give up, and only the camera dialog's airman/cgi retry. A key-frame request started by a
recovery (for example during a channel switch or right after a rejoin) can therefore make a user's
set_ap_channel report "Camera did not answer, channel unchanged". In the other direction, a
running dialog or channel command silently drops the IDR request that recovery needed.
Agent Prompt
## Issue description
`StaLink::tcpRequest` returns false right away when another TCP request is in flight. Callers can't tell this apart from "camera did not answer", so key-frame requests and AP channel switches fail at random.

## Fix Focus Areas
- app/wfbngrtl8812/src/main/cpp/StaLink.cpp[1200-1210]
- app/src/main/java/com/openipc/pixelpilot/VideoActivity.java[2133-2154]
- app/src/main/java/com/openipc/pixelpilot/VideoActivity.java[487-498]

## Recommended Fix
In `tcpRequest`, if `tcp_.st != Idle`, wait on `tcp_cv_` (with the caller's timeout) until the slot goes back to Idle before starting, and return false only when not connected or the wait times out. Have the callers notify waiters when they reset `tcp_.st = Idle`. Another option is to serialize requests with a dedicated mutex held for the whole request.

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

Comment on lines +374 to +375
dev->Stop();
releaseDevice(fd);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Remediation recommended

9. Adapter object can be freed while stop runs 🐞 Bug ☼ Reliability

The new releaseDevice(fd) call at the end of run()/runStation() erases the
shared_ptr<IRtlDevice> from rtl_devices on the run thread. Meanwhile WfbngLink::stop() on the
Java thread does find/at on the same unsynchronized map and calls StopRxLoop() and
stop_adaptive_link() through a raw pointer. When stop() ends the RX loop, the run thread wakes
up and can destroy the device (and mutate the map) while stop() is still inside StopRxLoop().
That is a use-after-free and a data race on std::map. Before this PR, entries were never erased on
the normal exit path.
Agent Prompt
## Issue description
`releaseDevice()` now erases devices from `rtl_devices` at the end of `run()`. `stop()` and `nativeSetTxPower` read the same map and call methods through raw pointers on other threads, with no synchronization.

## Fix Focus Areas
- app/wfbngrtl8812/src/main/cpp/WfbngLink.hpp[142-149]
- app/wfbngrtl8812/src/main/cpp/WfbngLink.cpp[382-398]
- app/wfbngrtl8812/src/main/cpp/WfbngLink.cpp[810-819]

## Recommended Fix
Add a mutex around every `rtl_devices` access (insert, erase, find/at, and `current_fd`). In `stop()` and the TX power setter, copy the `shared_ptr<IRtlDevice>` while holding the lock and call through that copy, so the object stays alive until the call returns.

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

Comment on lines +297 to +304
} catch (const std::exception &e) {
__android_log_print(ANDROID_LOG_ERROR, TAG, "retune to ch %d failed: %s", retune, e.what());
}
std::lock_guard<std::mutex> lock(mu_);
channel_ = static_cast<uint8_t>(retune);
width_mhz_ = retune_width;
chan_offset_ = retune_offset;
dwell_start_ = Clock::now();

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Remediation recommended

10. Failed channel switch is taken as success 🐞 Bug ≡ Correctness

StaLink::worker() catches exceptions from FastRetune() or SetMonitorChannel() but still sets
channel_, width_mhz_, chan_offset_, and dwell_start_ to the requested values and clears the
pending switch. If the driver rejects a CSA width change or scan hop, the radio remains on its old
settings while search and probe decisions, the status line, and connectedChannel() use the false
state, with no retune retry scheduled.
Agent Prompt
## Issue description
In `StaLink::worker()`, a failed retune is recorded as successful, leaving station channel state, search behavior, and reported channel inconsistent with the radio.

## Fix Focus Areas
- app/wfbngrtl8812/src/main/cpp/StaLink.cpp[273-306]
- app/wfbngrtl8812/src/main/cpp/StaLink.cpp[466-485]

## Recommended Fix
Track a success flag set only when `FastRetune()` or `SetMonitorChannel()` returns normally, and update the tuned channel, width, offset, and dwell bookkeeping only on success. On failure, retain the actual settings and arrange a retry or recovery search—for example, restore `retune_to_` under `mu_` or fall back to a 20 MHz `SetMonitorChannel` on the requested primary channel.

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

Comment on lines +2139 to +2144
String auth = Base64.encodeToString((getDroneUsername() + ":" + getDronePassword())
.getBytes(java.nio.charset.StandardCharsets.UTF_8), Base64.NO_WRAP);
String request = "GET /request/idr HTTP/1.0\r\n"
+ "Host: " + cameraIp + "\r\n"
+ "Authorization: Basic " + auth + "\r\n"
+ "Connection: close\r\n\r\n";

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Remediation recommended

11. Camera login is sent in clear over open wi-fi 🐞 Bug ⛨ Security

requestKeyFrame and CameraControlDialog.cgi() send the drone username and password as HTTP Basic
auth over the camera's access point. With this PR (and tools/apfpv-open-static-cam.sh) that access
point has WPA2 removed. Anyone in radio range can capture those credentials, which are usually the
camera's root login, and replay them against the camera's web API. Key-frame requests go out
automatically on every association and every end of a dropout, so the credentials are broadcast
often, not only when the user opens the controls.
Agent Prompt
## Issue description
APFPV mode runs over an unencrypted access point, yet the app sends the drone login as HTTP Basic auth, automatically for every key-frame request and for every camera-control request.

## Fix Focus Areas
- app/src/main/java/com/openipc/pixelpilot/VideoActivity.java[2133-2154]
- app/src/main/java/com/openipc/pixelpilot/CameraControlDialog.java[523-530]
- README.md

## Recommended Fix
Request key frames through an unauthenticated channel (for example an air_man_ap command, or majestic's IDR request on a port with no credentials), or make the automatic key-frame request opt-in. Warn the user in the APFPV settings dialog and in the README that the camera password is sent in clear over the open network, and recommend a dedicated password for this mode.

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

@josephnef josephnef left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for this, it is a substantial and well-structured piece of work. The station state machine, the ARP/ICMP/TCP mini-stack and the devourer integration are more careful than most contributed network code: every header parse I traced is length-checked, lock ordering is consistent (mu_ → q_mu_, sta_mutex → mu_ only from JNI), the six new JNI signatures match, and the existing R8 keep rules already cover the new natives. No merge conflicts with master.

I am requesting changes for two blocking items and a handful of robustness fixes. Qodo's review above is largely accurate; where I agree I reference its numbering rather than repeat it.

CI preview

CI (pr.yml, merged after this PR was opened) has not run here yet. It will on your next push. Expect:

  • clang-format job: fail. With the pinned clang-format 18.1.8 the diff is 195 lines in StaLink.cpp, 22 in StaLink.h, 57 in WfbngLink.cpp, 4 in WfbngLink.hpp (mostly bin-packed arguments and one-line loop bodies, plus one 124-column line at StaLink.cpp:695). Fix with pip install clang-format==18.1.8 && scripts/format_sources.sh.
  • Host GoogleTest and debug APK build: expected to pass. The emulator smoke job is non-blocking.

Blocking

  1. Device teardown race, and it now affects WFB-NG mode too (Qodo #9, #1). releaseDevice(fd) is called at the end of run() / runStation() (WfbngLink.cpp:278, 296, 375) and erases from rtl_devices (WfbngLink.hpp:146-149). Master only erased on the early-abort path. rtl_devices is an unsynchronised std::map that stop() (find then at(fd).get() then StopRxLoop()), nativeSetTxPower (:817-818) and start_link_quality_thread (:679, 786) read from other threads. When the RX loop exits on its own (USB detach, libusb error) and WfbLinkManager then calls stop, the run thread can destroy the device while stop() is inside StopRxLoop() through a raw pointer, and .at() on an erased fd throws through JNI. Keep the erase if devourer's per-adapter lock needs it, but guard rtl_devices / current_fd with a mutex and have stop() and the setters take a shared_ptr copy under it. Also set current_fd only after the adapter is accepted as the station (Qodo #1).
  2. Formatting, see above.

Should fix

  • ICMP echo of arbitrary size is injected as one MPDU (StaLink.cpp:1161-1175). The reply copies the whole datagram, and reassembly allows up to 64 KB, so anyone on the open AP can make the worker hand a ~60 KB frame to send_packet. Drop echo requests above ~1500 bytes.
  • Fragment reassembly completes with gaps (Qodo #2, StaLink.cpp:1036). received += plen counts overlapping or duplicate fragments, so a zero-filled datagram can be forwarded to :5600. Track coverage or drop overlaps.
  • Unbounded buffers: tcp_.rx grows until the deadline with no cap (StaLink.cpp:1348, cap at e.g. 64 KB); seen_ grows per distinct BSSID (:836, cap it).
  • SetTxPower from the JNI thread races the worker's SetMonitorChannel / FastRetune (StaLink.cpp:280-289). devourer's IRtlDevice.h:111-116 requires a single control thread. Queue it through the worker like requestTune.
  • Qodo #10 (failed channel switch recorded as success, StaLink.cpp:304), #8 (TCP single-flight: a recovery key-frame request makes a user's set_ap_channel report "camera did not answer", :1208), #7 (partial HTTP reply accepted after 1.5 s idle, :1277), #5 and #6 (gap-recovery edge cases). All hold on my read.
  • tools/apfpv-open-static-cam.sh: --allow-mac as the last argument silently installs without the ACL (Qodo #3, :48); the adapter is replaced before the ACL file is written, so a write failure leaves a half-installed config (Qodo #4, :119); and $master is assumed defined at the ip neigh del site (:107-108), add it to the pre-flight checks.

Document

  • Open network without PMF: a spoofed deauth or a spoofed beacon carrying the SSID can steer the station (StaLink.cpp:754, 836). Inherent to the design, but the README should say so and recommend --allow-mac.
  • Camera HTTP credentials go as Basic auth in cleartext over the open AP (VideoActivity.java:2140, CameraControlDialog.java:519). Worth one sentence.

What would make this mergeable

  1. Run scripts/format_sources.sh with clang-format 18.1.8.
  2. Mutex around rtl_devices / current_fd, shared_ptr copies in stop() and the setters.
  3. Bound network input (ICMP echo size, tcp_.rx, seen_) and fix fragment coverage.
  4. Serialise SetTxPower with the worker's retunes.
  5. README notes on the security model.

If you want something merged sooner, the camera-side script plus its README section would stand on their own as a separate PR.

@josephnef

Copy link
Copy Markdown
Collaborator

Heads-up: #126 (background VPN start fix) and #124/#122 are merged, so this branch now conflicts in VideoActivity.java around onPause. Please rebase on current master together with the changes requested above; the onPause stop now goes through WfbNgVpnService.stopRunning().

This branch has not been deployed

No deployments
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.

2 participants