From 8f880e9d1bcc59f40130e17c317adcb0d5719a67 Mon Sep 17 00:00:00 2001 From: Stefan Wittmann Date: Sat, 17 Oct 2026 23:19:29 +0200 Subject: [PATCH 01/10] APFPV mode: connect devourer station to open apfpv camera AP Co-Authored-By: Claude Opus 5.5 --- APFPV_WPA2_HANDOVER.md | 123 ++ README.md | 69 + .../pixelpilot/CameraControlDialog.java | 737 ++++++++ .../com/openipc/pixelpilot/VideoActivity.java | 476 ++++- .../openipc/pixelpilot/WfbLinkManager.java | 8 +- app/wfbngrtl8812/src/main/cpp/CMakeLists.txt | 2 + app/wfbngrtl8812/src/main/cpp/StaLink.cpp | 1551 +++++++++++++++++ app/wfbngrtl8812/src/main/cpp/StaLink.h | 262 +++ app/wfbngrtl8812/src/main/cpp/WfbngLink.cpp | 284 ++- app/wfbngrtl8812/src/main/cpp/WfbngLink.hpp | 34 + .../com/openipc/wfbngrtl8812/WfbNgLink.java | 70 + 11 files changed, 3591 insertions(+), 25 deletions(-) create mode 100644 APFPV_WPA2_HANDOVER.md create mode 100644 app/src/main/java/com/openipc/pixelpilot/CameraControlDialog.java create mode 100644 app/wfbngrtl8812/src/main/cpp/StaLink.cpp create mode 100644 app/wfbngrtl8812/src/main/cpp/StaLink.h diff --git a/APFPV_WPA2_HANDOVER.md b/APFPV_WPA2_HANDOVER.md new file mode 100644 index 00000000..53a779c8 --- /dev/null +++ b/APFPV_WPA2_HANDOVER.md @@ -0,0 +1,123 @@ +# Übergabe: WPA2-PSK für den APFPV-Modus + +Einstieg für eine neue Claude-Sitzung. Antworten auf **Deutsch**. Der Nutzer baut die App selbst +in Android Studio. **Keine eigenen Build- oder Testschleifen.** Er schickt Compiler-Fehler und Logs. +Ziel ist, sparsam mit Token umzugehen: nur die Stellen lesen, die man ändert, nicht ganze Dateien. + +## Stand + +- Repo `C:\Users\david\Documents\Claude\PixelPilot_apk`, Branch `ap-mode`, **nichts committet**. + Vor Etappe 1 den Nutzer fragen, ob er erst committen will. +- **APFPV-Modus:** Der USB-Adapter (devourer, Userspace-Treiber, RTL8812EU) ist als Station mit dem + **offenen** AP der OpenIPC-apfpv-Kamera verbunden. Die Kamera hat 192.168.0.1, das Handy die feste + IP 192.168.0.10. Video kommt per RTP/UDP auf Port 5600. +- Läuft auf dem Gerät: + - Verbinden ohne Scan + - CSA folgen (inkl. Kanalbreite 40/80) + - Rejoin nach Deauth oder TSF-Sprung direkt zur bekannten BSSID + - Dropouts beenden die Assoziation nicht +- Kernklasse ist `app/wfbngrtl8812/src/main/cpp/StaLink.{h,cpp}`. Sie enthält den + Zustandsautomaten Searching → Authenticating → Associating → Connected, ARP/ICMP/TCP-Client und den + Worker-Thread. `send_packet` muss außerhalb des RX-Threads laufen, deshalb gibt es eine TX-Queue. +- JNI liegt in `WfbngLink.cpp` (`nativeSetApConfig`, …), Java in `WfbNgLink.java` und + `VideoActivity.java` (`showApSettingsDialog`, Prefs `ap_*`). + +## Fakten aus der Prüfung + +- **devourer hat keine Verschlüsselung für eine Station.** Der Sicherheits-CAM des Chips wird beim + Init geleert, und für Jaguar3 fehlen die TX/RX-Deskriptorfelder für Verschlüsselung. Geschützte + Frames kommen roh an (RCR accept-all), selbst gebaute verschlüsselte Frames lassen sich per + `send_packet` senden, HW-ACK funktioniert. +- **Vorlage auf AP-Seite:** `devourer/tests/ap_wpa2.cpp` (4-Way-Authenticator, Software-CCMP, nutzt + OpenSSL) und `devourer/docs/ap-mode.md`. + - Der CCMP-AAD maskiert in FC die Subtype-, Retry-, PwrMgmt- und MoreData-Bits und setzt Protected. + - Er maskiert die Sequenznummer und lässt die Fragmentnummer stehen. + - QoS-AAD enthält nur die TID. + - Nonce = `prio(TID) | A2 | PN(6, big-endian)`. + - CCMP-Header = PN0 PN1 0 (0x20|keyid<<6) PN2..PN5. +- **OpenSSL gibt es im NDK nicht.** Das vorhandene libsodium hat weder AES-CCM noch SHA1. Den + BSD-lizenzierten hostap-Code übernehmen statt ihn neu zu schreiben: + `devourer/reference/8821cu/core/crypto/` (aes-internal*, aes-ccm, aes-unwrap, sha1*). Vorher die + Lizenzköpfe prüfen. +- **Kamera:** Die Stock-Firmware `/usr/bin/adapter` erzeugt `wpa=2`, `rsn_pairwise=CCMP`, + `wpa_passphrase=$pass`, `auth_algs=1` und **kein** `ieee80211w`. Das ist reines WPA2-PSK/CCMP ohne + PMF. hostapd kann zwar auch SAE. + - Das Skript des Nutzers `C:\Users\david\Documents\Claude\OpenIPC\apfpv-open-static-cam.sh` schaltet + WPA2 ab (wpa=0, MAC-ACL). + - Für den Test braucht es eine Variante, die `wpa=2` behält und nur `disassoc_low_ack=0` setzt. +- **hostapd-Zeitlimits für EAPOL:** erstes Timeout 100 ms, dann je 1 s, 4 Versuche. Danach kommt ein + Deauth mit Grund 15. Den GTK erneuert hostapd standardmäßig alle 86400 s. + +## Entscheidungen (mit dem Nutzer abgestimmt) + +- Nur **WPA2-PSK + CCMP**, `ieee80211w` 0 oder 1. Offene Netze bleiben unterstützt. +- Kein WPA3/SAE, keine PMF-Pflicht, kein TKIP. Erkennen und eine klare Statusmeldung anzeigen. +- Die PMK (PBKDF2-SHA1, 4096 Runden, SSID als Salt) **vorab in Java** mit + `SecretKeyFactory("PBKDF2WithHmacSHA1")` beim Speichern berechnen und als 32 Byte an native + übergeben. Neu berechnen, wenn sich SSID oder Passwort ändern, auch beim SSID-Ändern im + `CameraControlDialog`. + +## Etappe 1: Kern (nativ) + +1. Krypto-Dateien von hostap übernehmen (AES-128-Block, CCM bzw. CCMP, AES-Key-Unwrap, HMAC-SHA1 + und PRF-384/512) und in `app/wfbngrtl8812/src/main/cpp/CMakeLists.txt` eintragen. Eine dünne + Wrapper-Header `WpaCrypto.h` schreiben. +2. `StaConfig` um `std::array pmk` und `bool use_wpa` erweitern. +3. **Netzwahl und Assoziation:** + - Im Beacon oder der Probe-Response das RSN-IE (48) merken. + - Wenn `use_wpa` gilt, ein RSN-IE in den Assoc-Request schreiben: Version 1, Gruppenchiffre aus + dem AP-IE, paarweise CCMP, AKM PSK, Caps 0. + - Dasselbe IE byte-genau in Nachricht 2 verwenden. + - Ein Privacy-Bit, das nicht zur Konfiguration passt, als Hinweis in der Statuszeile melden. +4. **Neuer Zustand `Handshaking`** nach erfolgreicher Assoc-Response. EAPOL kommt mit LLC/SNAP und + Ethertype 0x888E (in `handleMsdu` abzweigen). + - **Nachricht 1:** ANonce lesen, Replay-Counter merken, SNonce zufällig erzeugen, + PTK = PRF-384(PMK, "Pairwise key expansion", min/max(AA,SPA) + min/max(ANonce,SNonce)), + KCK/KEK/TK ableiten, dann Nachricht 2 mit MIC (HMAC-SHA1 über KCK, 16 Byte) und RSN-IE senden. + - **Nachricht 3:** + - Prüfen: MIC, Replay-Counter größer als bisher, ANonce gleich. + - RSN-IE gegen das Beacon prüfen, bei Abweichung abbrechen. + - Key-Data mit KEK entpacken (unwrap), GTK-KDE (00-0F-AC:1) parsen: Key-ID plus GTK. + - RSC als Start-Replay-Zähler für die Gruppe übernehmen. + - **Nachricht 4** unverschlüsselt senden, **danach** PTK und GTK installieren und Connected setzen. + - EAPOL-Frames gehen nie über den CCMP-Pfad, solange kein PTK installiert ist. + - **Group-Key-Handshake** (Rekey): Group-Nachricht 1, verschlüsselt, Key-Data entpacken, GTK + ersetzen und Group-Nachricht 2 senden. +5. **RX** in `handleData`, wo heute steht „Protected: only open networks are supported“: + - Duplikate über Sequenznummer und Retry vor dem Entschlüsseln verwerfen. + - Key-ID aus dem CCMP-Header lesen: Unicast → PTK, Multicast → GTK. + - CCM entschlüsseln und den 8-Byte-MIC prüfen. Bei falschem MIC verwerfen und zählen. + - **Replay-Prüfung pro TID (16 Zähler) erst nach erfolgreichem MIC.** + - Dann den bisherigen Pfad (LLC/A-MSDU) auf dem Klartext fortsetzen. +6. **TX** in `sendData` (ARP, ICMP, TCP): mit installiertem PTK das Protected-Bit setzen, + CCMP-Header plus Chiffretext plus MIC anhängen. Der 48-Bit-PN wird unter dem Mutex streng + hochgezählt. Null-Keepalive-Frames bleiben unverschlüsselt. +7. **`reconnect()` und alle Wege zurück zu Searching:** PTK, GTK, PN und Replay-Zähler löschen, + **bevor** `beginAuth()` aufgerufen wird. +8. **Falsches Passwort erkennen:** Kommt Nachricht 1 mehrmals ohne Nachricht 3 oder folgt ein Deauth + mit Grund 15 oder 2, Status „APFPV: 4-way handshake failed – wrong password?“ anzeigen und den + Rejoin bremsen (Backoff 2 s). +9. JNI `nativeSetApConfig` um `byte[] pmk` erweitern (null bedeutet offen). + `statusText()`/`printableAscii` bleiben, wie sie sind. + +Danach testet der Nutzer: Kamera mit WPA2, App mit Passwort, dann Logcat-Tag `pixelpilot-sta`. + +## Etappe 2: App und Doku (erst nach erfolgreichem Test) + +- In `showApSettingsDialog` ein Passwortfeld ergänzen (leer bedeutet offen), Pref `ap_password`. +- PMK-Berechnung in einem Hintergrund-Thread laufen lassen, Ergebnis in `applyLinkConfig()` + übergeben. +- Hinweis anzeigen, wenn die Kamera WPA3 oder PMF-Pflicht meldet. +- README-Abschnitt „Link modes: WFB-NG and APFPV“ aktualisieren. „Limitations: open networks only“ + ändern zu „open or WPA2-PSK (CCMP), no WPA3/SAE, no required PMF“. +- Optional eine Kamera-Skriptvariante mit WPA2 an. + +## Was für das Recovering wichtig ist + +- Ein Dropout ohne Deauth bleibt unverändert: Die Schlüssel bleiben gültig, Video läuft sofort weiter. +- Ein Rejoin kostet zusätzlich den 4-Way-Handshake, etwa 10–50 ms. Deshalb muss die PMK vorab + berechnet sein, im Rejoin-Pfad darf kein PBKDF2 laufen. +- Deauth und Disassoc bleiben ungeschützt (kein PMF). Die bisherige Erkennung „Kamera kennt uns + nicht mehr“ funktioniert weiter. +- Mit einem Schlüssel darf nie ein PN doppelt vorkommen. Software-Wiederholungen bekommen einen + neuen PN. diff --git a/README.md b/README.md index b4aac9f2..b9e936ed 100644 --- a/README.md +++ b/README.md @@ -51,6 +51,75 @@ audio: outputVolume: 30 ``` +## Link modes: WFB-NG and APFPV + +Settings → **Link mode** switches how the wifi adapter is used: + +- **WFB-NG** (default): wfb-ng broadcast in monitor mode, as before. +- **APFPV**: the adapter joins the camera's own **open** (unencrypted) Wi-Fi access point as a + regular station, driven entirely by the devourer userspace driver. The phone uses a static IP + and the camera pushes its RTP stream there. Made for the OpenIPC apfpv firmware. + +**APFPV settings...** holds the SSID, the phone's static IP, the video UDP port and the adapter's +MAC address (generated once, locally administered), the camera's IP and the maximum channel width. +Defaults match the OpenIPC apfpv firmware (camera `192.168.0.1`, ground station `192.168.0.10`, +video to port 5600). + +Finding the camera without a scan: the app starts on the channel the camera was last seen on +(remembered automatically), then checks the channels the apfpv camera passes through while booting +(157, then 165, before `air_man_ap` moves to `wlanchan`). Channel switch announcements from the +camera's access point (hostapd `chan_switch`) are followed without reconnecting. A sweep of all +5 GHz and 2.4 GHz channels (DFS channels passively) is optional in **APFPV settings**. Received UDP +datagrams for the video port are handed to the player, port 14550 to the MAVLink OSD. + +In APFPV mode the **Channel** menu changes the camera's channel: while connected it sends +`set_ap_channel ` to the camera's `air_man_ap` (TCP 12355), the camera announces the switch +and the app follows, so `air_man_ap`'s ping check afterwards succeeds. The camera's reply is shown +as a toast. Without a connection the menu only changes where the next search starts. + +Channel width: the app follows the camera's channel width up to **Max. channel width** in APFPV +settings (20/40/80 MHz, default 80). It reads the width from the camera's beacons and from its +channel switch announcements, so a switch to a 40/80 MHz channel is followed at the new width +right away (`air_man_ap` pings the ground station right after a switch and reverts when that +fails). 80 MHz requires the primary to be the lowest 20 MHz of the block, which every 80 MHz +channel `air_man_ap` offers is. If a wide channel does not work with an adapter, set the maximum +to 20 MHz: the app then only advertises 20 MHz and the camera sends to it at 20 MHz. + +Camera control: in APFPV mode **Drone → Settings** opens the camera's controls instead of the web +page: channel and width, video mode, region (`air_man_ap`, TCP 12355, no login) and image options +(flip, overshoot fix, HQ i-frames, focus mode), aalink link parameters (max MCS per width, link +resilience, max throughput, LQ consideration, temperature threshold, VTX power, OSD size), +flight-controller port, CPU overclock, SSID, hostapd start channel and reboot (the camera's +aalinkFPV web API, with the Drone login credentials). They go over the USB adapter while it is +joined to the camera (a WebView could not reach it there, it uses the phone's network), otherwise +over the phone's own Wi-Fi, for when the phone itself is connected to the camera. In WFB-NG mode +Settings opens the web page at **Drone → Address** (default `10.5.0.10`). + +Dropouts: losing the signal does not end the association. The app stays on the channel and takes +video again the moment the camera is heard, with nothing to rebuild. It only rejoins when the camera +says it no longer knows the app (deauth, also sent in answer to the keepalive that goes out as soon +as the camera is heard again) or the beacon timestamp shows the camera restarted; the rejoin goes +straight to the known BSSID on the current channel, without searching. After 3 s of silence the +app also glances at the camera's boot channels (150 ms each, between 1 s on the home channel). +Whenever video can flow again, the app asks majestic for a key frame (`GET /request/idr`, with the +Drone login credentials), so the picture does not wait for the next scheduled one. + +Until video arrives, the connection state is shown in the middle of the screen, including the +networks the adapter hears on the current channel. Logcat tag `pixelpilot-sta` has the details. + +On the camera, send the stream to the phone's IP, e.g. in `/etc/majestic.yaml`: +``` +outgoing: + enabled: true + server: udp://192.168.0.10:5600 +``` + +Limitations: open networks only (no WEP/WPA), no DHCP (static IP), no 802.11 fragment reassembly, +UDP only, plus one short TCP request at a time for the camera's control port. The adapter answers +ARP and ping for its IP and sends a keepalive every 5 s. On RTL8812AU adapters devourer sends every +frame without hardware retries, so the few frames the phone sends (association, ARP) rely on +software retries; the video direction is acknowledged in hardware on all chips. + ## Quick Reference: Auto-FEC Levels 0 – 5 | Level | Denominator | Added Redundancy | diff --git a/app/src/main/java/com/openipc/pixelpilot/CameraControlDialog.java b/app/src/main/java/com/openipc/pixelpilot/CameraControlDialog.java new file mode 100644 index 00000000..82b41d84 --- /dev/null +++ b/app/src/main/java/com/openipc/pixelpilot/CameraControlDialog.java @@ -0,0 +1,737 @@ +package com.openipc.pixelpilot; + +import android.app.AlertDialog; +import android.content.Context; +import android.os.Handler; +import android.os.Looper; +import android.text.InputType; +import android.util.Base64; +import android.util.Log; +import android.view.Gravity; +import android.view.View; +import android.widget.ArrayAdapter; +import android.widget.Button; +import android.widget.EditText; +import android.widget.LinearLayout; +import android.widget.RadioButton; +import android.widget.RadioGroup; +import android.widget.ScrollView; +import android.widget.SeekBar; +import android.widget.Spinner; +import android.widget.Switch; +import android.widget.TextView; + +import com.openipc.wfbngrtl8812.WfbNgLink; + +import org.json.JSONObject; + +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.Iterator; +import java.util.List; +import java.util.Locale; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.function.Consumer; + +/** + * Drone -> Settings in APFPV mode: the camera's own controls. Reached either over the + * USB adapter's APFPV link (a WebView cannot be used there - it runs on the phone's network + * stack, which does not see the camera behind the adapter) or over the phone's own Wi-Fi + * when that is the one connected to the camera. Talks to the two control planes of the + * OpenIPC apfpv firmware directly: + *
    + *
  • air_man_ap, TCP 12355, one text command per connection, no login: channel + * (20/40/80 MHz), video mode, region.
  • + *
  • the aalinkFPV web API, HTTP GET with the drone login: image options, aalink link + * parameters, flight-controller port, CPU clock, SSID, hostapd start channel, + * reboot. The same endpoints the camera's own web page uses.
  • + *
+ * All requests run one after another on a single background thread; the link carries one + * TCP exchange at a time. + */ +final class CameraControlDialog { + interface Listener { + /** The camera's SSID was changed; it reboots and comes back with the new name. */ + void onSsidChanged(String ssid); + } + + /** One TCP request/response with the camera; null when nothing came back. */ + interface Transport { + /** @param halfClose end the sending side with the request, as {@code echo cmd | nc} does */ + String request(String ip, int port, String data, boolean halfClose, int timeoutMs); + } + + /** Through the USB adapter's APFPV link (devourer + the userspace stack). */ + static Transport overAdapter(WfbNgLink link) { + return (ip, port, data, halfClose, timeoutMs) -> link.cameraCommand(ip, port, data, timeoutMs, halfClose); + } + + /** + * Through the phone's own Wi-Fi. Sockets are bound to the Wi-Fi network: Android + * otherwise prefers mobile data when the camera's network has no internet. + */ + static Transport overWifi(Context context) { + return (ip, port, data, halfClose, timeoutMs) -> { + javax.net.SocketFactory factory = javax.net.SocketFactory.getDefault(); + android.net.ConnectivityManager cm = (android.net.ConnectivityManager) + context.getApplicationContext().getSystemService(Context.CONNECTIVITY_SERVICE); + if (cm != null) { + for (android.net.Network n : cm.getAllNetworks()) { + android.net.NetworkCapabilities caps = cm.getNetworkCapabilities(n); + if (caps != null && caps.hasTransport(android.net.NetworkCapabilities.TRANSPORT_WIFI)) { + factory = n.getSocketFactory(); + break; + } + } + } + java.io.ByteArrayOutputStream reply = new java.io.ByteArrayOutputStream(); + try (java.net.Socket s = factory.createSocket()) { + s.connect(new java.net.InetSocketAddress(ip, port), Math.min(timeoutMs, 3000)); + s.setSoTimeout(timeoutMs); + s.getOutputStream().write(data.getBytes(StandardCharsets.UTF_8)); + s.getOutputStream().flush(); + if (halfClose) s.shutdownOutput(); + java.io.InputStream in = s.getInputStream(); + byte[] buf = new byte[4096]; + int n; + while ((n = in.read(buf)) > 0) { + reply.write(buf, 0, n); + // Like the adapter path: a short silence after the answer ends it, + // for servers that keep the connection open. + s.setSoTimeout(1500); + } + } catch (java.net.SocketTimeoutException e) { + // Answer complete (or none at all, handled below). + } catch (java.io.IOException e) { + Log.w(TAG, "camera " + ip + ":" + port + " over Wi-Fi: " + e.getMessage()); + } + return reply.size() > 0 ? new String(reply.toByteArray(), StandardCharsets.UTF_8) : null; + }; + } + + private static final String TAG = "pixelpilot"; + private static final int AIR_MAN_PORT = 12355; + private static final int HTTP_PORT = 80; + private static final int TIMEOUT_MS = 15000; + + private static final String MAJ = "/cgi-bin/aalinkFPV-cli-maj.cgi"; + private static final String CONF = "/cgi-bin/aalinkFPV-conf.cgi"; + private static final String SERIAL = "/cgi-bin/aalinkFPV-serial.cgi"; + private static final String CPU = "/cgi-bin/aalinkFPV-cpu-oc.cgi"; + private static final String WLAN = "/cgi-bin/aalinkFPV-wlan.cgi"; + private static final String BOOT_CHANNEL = "/cgi-bin/aalinkFPV-channel-troubleshoot.cgi"; + private static final String MAIN_PAGE = "/cgi-bin/aalinkFPV.cgi"; + + private static final String[] REGIONS = {"00", "US", "CA", "AU", "EU", "JP", "CN"}; + + private final Context context; + private final Transport transport; + private final String via; + private final String cameraIp; + private final String auth; + private final int appMaxWidthMhz; + private final Listener listener; + private final ExecutorService io = Executors.newSingleThreadExecutor(); + private final Handler main = new Handler(Looper.getMainLooper()); + + private TextView status; + private final List channels = new ArrayList<>(); + private String currentChannel = ""; + private String wlanPass = ""; + + // Rows that get filled once the current values are loaded. + private RadioGroup widthGroup; + private ChoiceRow channelRow, videoModeRow, regionRow, hqRow, sourceRow, serialRow, cpuRow, bootChannelRow; + private ChoiceRow mcs20Row, mcs40Row, mcs80Row; + private ToggleRow flipRow, overshootRow, focusRow; + private SliderRow shiftRow, throughputRow, tempRow, txPowerRow, osdRow; + private EditText ssidEdit; + + /** @param via shown in the title, e.g. "USB adapter" or "phone Wi-Fi" */ + CameraControlDialog(Context context, Transport transport, String via, String cameraIp, String user, + String password, int appMaxWidthMhz, Listener listener) { + this.context = context; + this.transport = transport; + this.via = via; + this.cameraIp = cameraIp; + this.auth = Base64.encodeToString((user + ":" + password).getBytes(StandardCharsets.UTF_8), + Base64.NO_WRAP); + this.appMaxWidthMhz = appMaxWidthMhz; + this.listener = listener; + } + + void show() { + ScrollView scroll = new ScrollView(context); + LinearLayout root = new LinearLayout(context); + root.setOrientation(LinearLayout.VERTICAL); + root.setPadding(40, 20, 40, 20); + scroll.addView(root); + + status = new TextView(context); + status.setText("Loading camera settings..."); + root.addView(status); + + buildLinkSection(root); + buildVideoSection(root); + buildAalinkSection(root); + buildSystemSection(root); + + AlertDialog dialog = new AlertDialog.Builder(context) + .setTitle("APFPV camera " + cameraIp + " (via " + via + ")") + .setView(scroll) + .setNegativeButton("Close", null) + .create(); + dialog.setOnDismissListener(d -> io.shutdownNow()); + dialog.show(); + io.execute(this::loadAll); + } + + // ------------------------------------------------------------------------ + // Sections + // ------------------------------------------------------------------------ + + private void buildLinkSection(LinearLayout root) { + header(root, "Link"); + + root.addView(label("Channel width")); + widthGroup = new RadioGroup(context); + widthGroup.setOrientation(RadioGroup.HORIZONTAL); + for (int w : new int[]{20, 40, 80}) { + RadioButton rb = new RadioButton(context); + rb.setText(w + " MHz"); + rb.setId(View.generateViewId()); + rb.setTag(w); + widthGroup.addView(rb); + } + widthGroup.setOnCheckedChangeListener((g, id) -> fillChannels(selectedWidth())); + root.addView(widthGroup); + + channelRow = new ChoiceRow(root, "Channel", value -> { + int width = widthOf(value); + String note = width > appMaxWidthMhz + ? "\n\nThe app is set to at most " + appMaxWidthMhz + " MHz (APFPV settings), so it keeps " + + "receiving at that width." + : ""; + confirm("Switch the camera to channel " + value + "?", + "The camera announces the switch and the app follows it." + note, + () -> run("Channel", () -> { + String r = airman("set_ap_channel " + value); + currentChannel = airmanOr("get_current_ap_channel", currentChannel); + return r; + })); + }); + + regionRow = new ChoiceRow(root, "Region", value -> confirm( + "Set the radio region to " + value + "?", + "The region decides which channels the camera may use. Choosing one that does not " + + "match where you fly can break local radio regulations.", + () -> run("Region", () -> { + String r = airman("set_region " + value); + loadChannels(); + return r; + }))); + regionRow.setItems(REGIONS, REGIONS); + } + + private void buildVideoSection(LinearLayout root) { + header(root, "Video"); + + videoModeRow = new ChoiceRow(root, "Video mode", value -> confirm( + "Switch the video mode to \"" + value + "\"?", + "majestic restarts, the picture is gone for a few seconds. Modes that do not suit the " + + "sensor can leave the camera without video; modes that crop the sensor also " + + "add a crop to /etc/rc.local that applies on every boot.", + () -> run("Video mode", () -> airman("set_simple_video_mode '" + value + "'")))); + + flipRow = new ToggleRow(root, "Flip image", + on -> run("Flip image", () -> result(cgi(MAJ + "?action=set&value=" + on)))); + overshootRow = new ToggleRow(root, "Overshoot fix", + on -> run("Overshoot fix", () -> result(cgi(MAJ + "?action=set_overshoot_fix&value=" + on)))); + hqRow = new ChoiceRow(root, "HQ i-frames", + value -> run("HQ i-frames", () -> result(cgi(MAJ + "?action=set_hq_iframes&value=" + value)))); + hqRow.setItems(new String[]{"Off", "MQ", "MQ+", "HQ"}, new String[]{"off", "mq", "mqplus", "hq"}); + focusRow = new ToggleRow(root, "Focus mode (HQ centre)", + on -> run("Focus mode", () -> result(cgi(CONF + "?module=focusmode&action=set&value=" + on)))); + } + + private void buildAalinkSection(LinearLayout root) { + header(root, "aalink"); + + String[] mcs = {"0", "1", "2", "3", "4", "5", "6", "7"}; + mcs20Row = mcsRow(root, "Max MCS 20 MHz", "max_mcs_20", mcs); + mcs40Row = mcsRow(root, "Max MCS 40 MHz", "max_mcs_40", mcs); + mcs80Row = mcsRow(root, "Max MCS 80 MHz", "max_mcs_80", mcs); + + shiftRow = new SliderRow(root, "Link resilience", -50, 50, 1, "%.0f", + v -> run("Link resilience", () -> confSet("mcsshift", fmt("%.0f", v)))); + throughputRow = new SliderRow(root, "Maximum throughput (%)", 10, 100, 1, "%.0f", + v -> run("Throughput", () -> confSet("throughput", fmt("%.0f", v)))); + sourceRow = new ChoiceRow(root, "LQ consideration", + value -> run("LQ consideration", () -> confSet("mcssource", value))); + sourceRow.setItems(new String[]{"Lowest", "Downlink only"}, new String[]{"lowest", "downlink"}); + tempRow = new SliderRow(root, "Temp throttle threshold (°C)", 70, 100, 1, "%.0f", + v -> run("Temp threshold", () -> confSet("temp", fmt("%.0f", v)))); + txPowerRow = new SliderRow(root, "VTX power output", 0.2, 1.2, 0.05, "%.2f", + v -> run("VTX power", () -> confSet("txPower", fmt("%.2f", v)))); + osdRow = new SliderRow(root, "OSD size", 0.2, 2.0, 0.1, "%.1f", + v -> run("OSD size", () -> confSet("osdscale", fmt("%.1f", v)))); + } + + private void buildSystemSection(LinearLayout root) { + header(root, "System"); + + serialRow = new ChoiceRow(root, "Flight controller port", value -> run("Flight controller port", + () -> result(cgi(SERIAL + "?action=set&value=" + enc(value))))); + serialRow.setItems(new String[]{"None", "ttyS0", "ttyS1", "ttyS2", "ttyS3"}, + new String[]{"standalone", "/dev/ttyS0", "/dev/ttyS1", "/dev/ttyS2", "/dev/ttyS3"}); + + cpuRow = new ChoiceRow(root, "CPU overclock", value -> run("CPU overclock", + () -> result(cgi(CPU + "?action=set&value=" + value)))); + String[] cpu = {"none", "850", "900", "950", "1000", "1050", "1100", "1150", "1200"}; + String[] cpuLabels = new String[cpu.length]; + for (int i = 0; i < cpu.length; i++) cpuLabels[i] = i == 0 ? "None (800 MHz)" : cpu[i] + " MHz"; + cpuRow.setItems(cpuLabels, cpu); + + root.addView(label("SSID")); + LinearLayout ssidLine = line(); + ssidEdit = new EditText(context); + ssidEdit.setSingleLine(true); + ssidEdit.setLayoutParams(new LinearLayout.LayoutParams(0, LinearLayout.LayoutParams.WRAP_CONTENT, 1)); + ssidLine.addView(ssidEdit); + ssidLine.addView(button("Set", v -> { + String ssid = ssidEdit.getText().toString(); + if (ssid.isEmpty() || ssid.length() > 32) { + status("SSID: 1-32 characters"); + return; + } + confirm("Rename the camera's network to \"" + ssid + "\"?", + "The camera reboots and comes back under the new name; the app's APFPV settings are " + + "updated to match.", + () -> run("SSID", () -> { + // The CGI insists on a passphrase; the open-network adapter ignores it. + String pass = wlanPass.isEmpty() ? "12345678" : wlanPass; + String r = result(cgi(WLAN + "?action=set&ssid=" + enc(ssid) + "&pass=" + enc(pass))); + if (r != null && r.contains("ok")) main.post(() -> listener.onSsidChanged(ssid)); + return r; + })); + })); + root.addView(ssidLine); + + bootChannelRow = new ChoiceRow(root, "hostapd start channel", value -> confirm( + "Start the access point on channel " + value + "?", + "Changes the channel hostapd starts on (before the switch to the stored channel) and " + + "reboots the camera.", + () -> run("Start channel", () -> result(cgi(BOOT_CHANNEL + "?action=set&channel=" + value))))); + LinearLayout bootLine = line(); + bootLine.addView(button("Restore 157", v -> confirm("Restore the default start channel 157?", + "The camera reboots.", + () -> run("Start channel", () -> result(cgi(BOOT_CHANNEL + "?action=restore")))))); + root.addView(bootLine); + + LinearLayout rebootLine = line(); + rebootLine.addView(button("Reload values", v -> { + status("Loading camera settings..."); + io.execute(this::loadAll); + })); + rebootLine.addView(button("Reboot camera", v -> confirm("Reboot the camera?", + "The link drops and the app rejoins once the camera is back.", + () -> run("Reboot", () -> result(cgi(MAIN_PAGE + "?action=reboot")))))); + root.addView(rebootLine); + } + + private ChoiceRow mcsRow(LinearLayout root, String title, String module, String[] values) { + ChoiceRow row = new ChoiceRow(root, title, value -> run(title, () -> confSet(module, value))); + row.setItems(values, values); + return row; + } + + // ------------------------------------------------------------------------ + // Loading + // ------------------------------------------------------------------------ + + /** Runs on the io thread: reads every value, then fills the rows on the main thread. */ + private void loadAll() { + loadChannels(); + String modes = airman("get_all_video_modes"); + String mode = airman("get_current_video_mode"); + String region = airman("get_region"); + JSONObject flip = cgi(MAJ + "?action=get"); + JSONObject overshoot = cgi(MAJ + "?action=get_overshoot_fix"); + JSONObject hq = cgi(MAJ + "?action=get_hq_iframes"); + JSONObject focus = cgi(CONF + "?module=focusmode&action=get"); + JSONObject mcs20 = cgi(CONF + "?module=max_mcs_20&action=get"); + JSONObject mcs40 = cgi(CONF + "?module=max_mcs_40&action=get"); + JSONObject mcs80 = cgi(CONF + "?module=max_mcs_80&action=get"); + JSONObject shift = cgi(CONF + "?module=mcsshift&action=get"); + JSONObject throughput = cgi(CONF + "?module=throughput&action=get"); + JSONObject source = cgi(CONF + "?module=mcssource&action=get"); + JSONObject temp = cgi(CONF + "?module=temp&action=get"); + JSONObject txPower = cgi(CONF + "?module=txPower&action=get"); + JSONObject osd = cgi(CONF + "?module=osdscale&action=get"); + JSONObject serial = cgi(SERIAL + "?action=get"); + JSONObject cpu = cgi(CPU + "?action=get"); + JSONObject wlan = cgi(WLAN + "?action=get"); + + main.post(() -> { + fillChannels(widthOf(currentChannel)); + checkWidth(widthOf(currentChannel)); + if (modes != null) { + String[] list = lines(modes); + videoModeRow.setItems(list, list); + } + videoModeRow.select(mode); + regionRow.select(region); + if (flip != null) flipRow.set(flip.optBoolean("flipped")); + if (overshoot != null) overshootRow.set(overshoot.optBoolean("enabled")); + if (hq != null) hqRow.select(hq.optString("level")); + if (focus != null) focusRow.set(focus.optBoolean("enabled")); + mcs20Row.select(cardValue(mcs20)); + mcs40Row.select(cardValue(mcs40)); + mcs80Row.select(cardValue(mcs80)); + if (shift != null) shiftRow.set(shift.optDouble("mcsShift", 0)); + if (throughput != null) throughputRow.set(throughput.optDouble("throughput", 50)); + if (source != null) sourceRow.select(source.optString("mcsSource")); + if (temp != null) tempRow.set(temp.optDouble("highTemp", 95)); + if (txPower != null) txPowerRow.set(txPower.optDouble("txPower", 1.0)); + if (osd != null) osdRow.set(osd.optDouble("osdScale", 1.0)); + if (serial != null) serialRow.select(serial.optString("current")); + if (cpu != null) { + String cur = cpu.optString("current"); + cpuRow.select("800".equals(cur) ? "none" : cur); + } + if (wlan != null) { + ssidEdit.setText(wlan.optString("wlanssid")); + wlanPass = wlan.optString("wlanpass"); + } + boolean any = modes != null || flip != null; + status(any ? "Camera settings loaded. Each \"Set\" applies one value right away." + : "The camera did not answer. Is the phone connected to it (APFPV via the USB adapter or the phone Wi-Fi)?"); + }); + } + + /** io thread. */ + private void loadChannels() { + String list = airman("get_all_ap_channels"); + String cur = airman("get_current_ap_channel"); + synchronized (channels) { + channels.clear(); + if (list != null) { + for (String c : lines(list)) channels.add(c); + } + } + if (cur != null) currentChannel = cur; + main.post(() -> fillChannels(selectedWidth())); + } + + /** Main thread: the channel list for one width, like the camera's own page sorts it. */ + private void fillChannels(int width) { + List values = new ArrayList<>(); + List labels = new ArrayList<>(); + List twenty = new ArrayList<>(); + synchronized (channels) { + for (String c : channels) { + int w = widthOf(c); + if (w == 20) twenty.add(c); + if (w != width) continue; + values.add(c); + labels.add(w == 20 ? c : c.replace("_", "-") + " (" + w + " MHz)"); + } + } + channelRow.setItems(labels.toArray(new String[0]), values.toArray(new String[0])); + channelRow.select(currentChannel); + String[] t = twenty.toArray(new String[0]); + bootChannelRow.setItems(t, t); + bootChannelRow.select("157"); + } + + private void checkWidth(int width) { + for (int i = 0; i < widthGroup.getChildCount(); i++) { + View v = widthGroup.getChildAt(i); + if ((int) v.getTag() == width) { + widthGroup.check(v.getId()); + return; + } + } + } + + private int selectedWidth() { + View v = widthGroup.findViewById(widthGroup.getCheckedRadioButtonId()); + return v != null ? (int) v.getTag() : 20; + } + + /** "149" is 20 MHz, "149_153" 40 MHz, "149_161" 80 MHz (the camera page's rule). */ + private static int widthOf(String channel) { + if (channel == null || !channel.contains("_")) return 20; + String[] p = channel.split("_"); + try { + return Integer.parseInt(p[1].trim()) - Integer.parseInt(p[0].trim()) <= 8 ? 40 : 80; + } catch (RuntimeException e) { + return 20; + } + } + + /** The max-MCS endpoints answer per adapter family ({"EU":5,"AU":5,...}); they are set together. */ + private static String cardValue(JSONObject o) { + if (o == null) return null; + if (o.has("EU")) return String.valueOf(o.optInt("EU")); + Iterator keys = o.keys(); + return keys.hasNext() ? String.valueOf(o.optInt(keys.next())) : null; + } + + // ------------------------------------------------------------------------ + // Requests (io thread) + // ------------------------------------------------------------------------ + + private interface Request { + String call(); + } + + /** Runs a change on the io thread and reports the camera's answer. */ + private void run(String what, Request request) { + status(what + ": sending..."); + io.execute(() -> { + String reply = request.call(); + String text = reply == null ? "no answer from the camera" : reply.trim().replace('\n', ' '); + Log.i(TAG, "camera " + what + ": " + text); + main.post(() -> status(what + ": " + text)); + }); + } + + /** One air_man_ap command; null when nothing came back. Retries while the link is busy. */ + private String airman(String command) { + for (int attempt = 0; attempt < 3; attempt++) { + String reply = transport.request(cameraIp, AIR_MAN_PORT, command + "\n", true, TIMEOUT_MS); + if (reply != null) return reply.trim(); + if (!pause(400)) break; + } + return null; + } + + private String airmanOr(String command, String fallback) { + String r = airman(command); + return r != null ? r : fallback; + } + + /** GET on the camera's web API with the drone login; the JSON body, or null. */ + private JSONObject cgi(String pathAndQuery) { + String request = "GET " + pathAndQuery + " HTTP/1.0\r\n" + + "Host: " + cameraIp + "\r\n" + + "Authorization: Basic " + auth + "\r\n" + + "Connection: close\r\n\r\n"; + for (int attempt = 0; attempt < 3; attempt++) { + String reply = transport.request(cameraIp, HTTP_PORT, request, false, TIMEOUT_MS); + if (reply != null) { + int nl = reply.indexOf('\n'); + String statusLine = nl > 0 ? reply.substring(0, nl).trim() : reply.trim(); + if (!statusLine.contains(" 200")) { + Log.w(TAG, "camera " + pathAndQuery + ": " + statusLine); + return null; + } + int brace = reply.indexOf('{', reply.indexOf("\r\n\r\n") + 1); + if (brace < 0) return null; + try { + return new JSONObject(reply.substring(brace).trim()); + } catch (Exception e) { + Log.w(TAG, "camera " + pathAndQuery + ": unreadable answer", e); + return null; + } + } + if (!pause(400)) break; + } + return null; + } + + private String confSet(String module, String value) { + return result(cgi(CONF + "?module=" + module + "&action=set&value=" + enc(value))); + } + + /** "ok" / the error text of a CGI answer. */ + private static String result(JSONObject o) { + if (o == null) return null; + if (o.has("error")) return "error: " + o.optString("error"); + return o.toString(); + } + + private static boolean pause(long ms) { + try { + Thread.sleep(ms); + return true; + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + return false; + } + } + + private static String enc(String s) { + try { + return URLEncoder.encode(s, "UTF-8"); + } catch (java.io.UnsupportedEncodingException e) { + return s; + } + } + + private static String fmt(String format, double v) { + return String.format(Locale.US, format, v); + } + + private static String[] lines(String s) { + List out = new ArrayList<>(); + for (String l : s.split("\r?\n")) { + if (!l.trim().isEmpty()) out.add(l.trim()); + } + return out.toArray(new String[0]); + } + + // ------------------------------------------------------------------------ + // UI helpers (main thread) + // ------------------------------------------------------------------------ + + private void status(String text) { + if (status != null) status.setText(text); + } + + private void confirm(String title, String message, Runnable onYes) { + new AlertDialog.Builder(context) + .setTitle(title) + .setMessage(message) + .setPositiveButton("Apply", (d, w) -> onYes.run()) + .setNegativeButton("Cancel", null) + .show(); + } + + private void header(LinearLayout root, String text) { + TextView tv = new TextView(context); + tv.setText(text); + tv.setTextSize(18); + tv.setPadding(0, 30, 0, 6); + root.addView(tv); + } + + private TextView label(String text) { + TextView tv = new TextView(context); + tv.setText(text); + tv.setPadding(0, 12, 0, 0); + return tv; + } + + private LinearLayout line() { + LinearLayout l = new LinearLayout(context); + l.setOrientation(LinearLayout.HORIZONTAL); + l.setGravity(Gravity.CENTER_VERTICAL); + return l; + } + + private Button button(String text, View.OnClickListener onClick) { + Button b = new Button(context); + b.setText(text); + b.setOnClickListener(onClick); + return b; + } + + /** Spinner + Set button; nothing is sent until Set is pressed. */ + private final class ChoiceRow { + private final Spinner spinner; + private String[] values = new String[0]; + + ChoiceRow(LinearLayout root, String title, Consumer onSet) { + root.addView(label(title)); + LinearLayout l = line(); + spinner = new Spinner(context); + spinner.setLayoutParams(new LinearLayout.LayoutParams(0, LinearLayout.LayoutParams.WRAP_CONTENT, 1)); + l.addView(spinner); + l.addView(button("Set", v -> { + int i = spinner.getSelectedItemPosition(); + if (i >= 0 && i < values.length) onSet.accept(values[i]); + })); + root.addView(l); + } + + void setItems(String[] labels, String[] values) { + this.values = values; + ArrayAdapter adapter = new ArrayAdapter<>(context, + android.R.layout.simple_spinner_item, labels); + adapter.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item); + spinner.setAdapter(adapter); + } + + void select(String value) { + if (value == null) return; + for (int i = 0; i < values.length; i++) { + if (values[i].equalsIgnoreCase(value.trim())) { + spinner.setSelection(i); + return; + } + } + } + } + + /** Switch that applies on tap (not on programmatic set, so loading sends nothing). */ + private final class ToggleRow { + private final Switch toggle; + + ToggleRow(LinearLayout root, String title, Consumer onChange) { + toggle = new Switch(context); + toggle.setText(title); + toggle.setPadding(0, 12, 0, 0); + toggle.setOnClickListener(v -> onChange.accept(toggle.isChecked())); + root.addView(toggle); + } + + void set(boolean on) { + toggle.setChecked(on); + } + } + + /** SeekBar over [min, max] in steps, value shown beside it, sent with Set. */ + private final class SliderRow { + private final SeekBar seek; + private final TextView value; + private final double min, step; + private final String format; + + SliderRow(LinearLayout root, String title, double min, double max, double step, String format, + Consumer onSet) { + this.min = min; + this.step = step; + this.format = format; + root.addView(label(title)); + LinearLayout l = line(); + seek = new SeekBar(context); + seek.setMax((int) Math.round((max - min) / step)); + seek.setLayoutParams(new LinearLayout.LayoutParams(0, LinearLayout.LayoutParams.WRAP_CONTENT, 1)); + value = new TextView(context); + value.setMinWidth(120); + value.setGravity(Gravity.END); + seek.setOnSeekBarChangeListener(new SeekBar.OnSeekBarChangeListener() { + @Override + public void onProgressChanged(SeekBar s, int p, boolean fromUser) { + value.setText(fmt(SliderRow.this.format, current())); + } + + @Override + public void onStartTrackingTouch(SeekBar s) { + } + + @Override + public void onStopTrackingTouch(SeekBar s) { + } + }); + l.addView(seek); + l.addView(value); + l.addView(button("Set", v -> onSet.accept(current()))); + root.addView(l); + set(min); + } + + double current() { + return min + seek.getProgress() * step; + } + + void set(double v) { + seek.setProgress((int) Math.round((v - min) / step)); + value.setText(fmt(format, current())); + } + } +} diff --git a/app/src/main/java/com/openipc/pixelpilot/VideoActivity.java b/app/src/main/java/com/openipc/pixelpilot/VideoActivity.java index d23df482..55da5a06 100644 --- a/app/src/main/java/com/openipc/pixelpilot/VideoActivity.java +++ b/app/src/main/java/com/openipc/pixelpilot/VideoActivity.java @@ -150,6 +150,9 @@ public void run() { private static final String PREF_DRONE_USERNAME = "drone_username"; private static final String PREF_DRONE_PASSWORD = "drone_password"; + private static final String PREF_DRONE_ADDRESS = "drone_address"; + // The camera as seen through the wfb-ng tunnel (WfbNgVpnService). + private static final String DEFAULT_DRONE_ADDRESS = "10.5.0.10"; private static final String PREF_DVR_FILENAME = "dvr_filename"; public boolean getVRSetting() { @@ -370,6 +373,274 @@ private void initializeWfbNg() { wfbLink = new WfbNgLink(this); wfbLink.SetWfbNGStatsChanged(this); wfbLinkManager = new WfbLinkManager(this, binding, wfbLink); + applyLinkConfig(); + } + + // ---------------------------------------------------------------------------- + // LINK MODE (WFB-NG / AP) + // ---------------------------------------------------------------------------- + + private static final String PREF_LINK_MODE = "link_mode"; + private static final String PREF_AP_SSID = "ap_ssid"; + private static final String PREF_AP_OWN_IP = "ap_own_ip"; + private static final String PREF_AP_OWN_MAC = "ap_own_mac"; + private static final String PREF_AP_VIDEO_PORT = "ap_video_port"; + private static final String PREF_AP_CHANNEL = "ap_channel"; + private static final String PREF_AP_CAMERA_IP = "ap_camera_ip"; + private static final String PREF_AP_FULL_SCAN = "ap_full_scan"; + /** Widest channel AP mode follows the camera to (20/40/80 MHz); 20 is the safe fallback. */ + private static final String PREF_AP_MAX_WIDTH = "ap_max_width"; + private static final int DEFAULT_AP_MAX_WIDTH = 80; + // Defaults match the OpenIPC apfpv firmware: the camera is 192.168.0.1, its DHCP + // server only ever hands out 192.168.0.10 and majestic streams to udp://192.168.0.10:5600. + private static final String DEFAULT_AP_SSID = "OpenIPC"; + private static final String DEFAULT_AP_OWN_IP = "192.168.0.10"; + private static final String DEFAULT_AP_CAMERA_IP = "192.168.0.1"; + private static final int DEFAULT_AP_VIDEO_PORT = 5600; + /** + * Channels the apfpv camera passes through while it boots: hostapd starts on 157, + * "adapter start" switches to 165, then air_man_ap moves to the stored wlanchan (all via + * channel switch announcements, which the link follows). Checked after the remembered + * channel, so a camera that is still booting is found without a full scan. + */ + private static final int[] AP_BOOT_CHANNELS = {165, 157}; + private static final int DEFAULT_AP_CHANNEL = 165; + /** air_man_ap control port on the camera ("set_ap_channel " etc.). */ + private static final int AIR_MAN_PORT = 12355; + private static final int AIR_MAN_TIMEOUT_MS = 15000; + + public static int getLinkMode(Context context) { + return context.getSharedPreferences("general", Context.MODE_PRIVATE) + .getInt(PREF_LINK_MODE, WfbNgLink.LINK_MODE_WFB); + } + + /** AP mode's start channel: where the camera's AP was last seen. */ + public static int getApChannel(Context context) { + return context.getSharedPreferences("general", Context.MODE_PRIVATE) + .getInt(PREF_AP_CHANNEL, DEFAULT_AP_CHANNEL); + } + + /** The channel the adapters start on in the current mode. */ + private int getStartChannel() { + return isApMode() ? getApChannel(this) : getChannel(this); + } + + private boolean isApMode() { + return getLinkMode(this) == WfbNgLink.LINK_MODE_AP; + } + + /** + * MAC address the adapter uses as a station. Generated once, locally administered and + * unicast, then kept so the camera's ARP cache and DHCP leases stay valid across restarts. + */ + private String getApOwnMac() { + SharedPreferences prefs = getSharedPreferences("general", MODE_PRIVATE); + String mac = prefs.getString(PREF_AP_OWN_MAC, null); + if (mac == null) { + byte[] b = new byte[6]; + new java.security.SecureRandom().nextBytes(b); + b[0] = (byte) ((b[0] & 0xfc) | 0x02); + mac = String.format("%02x:%02x:%02x:%02x:%02x:%02x", b[0], b[1], b[2], b[3], b[4], b[5]); + prefs.edit().putString(PREF_AP_OWN_MAC, mac).apply(); + } + return mac; + } + + /** Hands the stored mode and AP settings to the native link; used on the next adapter start. */ + private void applyLinkConfig() { + SharedPreferences prefs = getSharedPreferences("general", MODE_PRIVATE); + int mode = getLinkMode(this); + wfbLink.setLinkMode(mode); + boolean valid = wfbLink.setApConfig( + prefs.getString(PREF_AP_SSID, DEFAULT_AP_SSID), + prefs.getString(PREF_AP_OWN_IP, DEFAULT_AP_OWN_IP), + getApOwnMac(), + prefs.getInt(PREF_AP_VIDEO_PORT, DEFAULT_AP_VIDEO_PORT), + AP_BOOT_CHANNELS, + prefs.getBoolean(PREF_AP_FULL_SCAN, false), + prefs.getInt(PREF_AP_MAX_WIDTH, DEFAULT_AP_MAX_WIDTH)); + if (!valid) { + Log.e(TAG, "stored AP settings are invalid, AP mode will not connect"); + } + wfbLinkManager.setApMode(mode == WfbNgLink.LINK_MODE_AP); + wfbLinkManager.setChannel(getStartChannel()); + } + + /** + * Channel menu in AP mode. The camera owns the channel, so while connected it is asked to + * switch (air_man_ap set_ap_channel); it announces the switch and the link follows it, + * and air_man_ap's ping check afterwards finds us on the new channel. Without a + * connection this only changes where the next search starts. + */ + private void onApChannelSelected(int channel) { + if (wfbLink.getApChannel() == 0) { + getSharedPreferences("general", MODE_PRIVATE).edit().putInt(PREF_AP_CHANNEL, channel).apply(); + wfbLinkManager.stopAdapters(); + wfbLinkManager.setChannel(channel); + wfbLinkManager.startAdapters(); + Toast.makeText(this, "Not connected: searching on channel " + channel, Toast.LENGTH_SHORT).show(); + return; + } + if (wfbLink.getApChannel() == channel) { + return; + } + String cameraIp = getSharedPreferences("general", MODE_PRIVATE) + .getString(PREF_AP_CAMERA_IP, DEFAULT_AP_CAMERA_IP); + Toast.makeText(this, "Asking the camera to switch to channel " + channel + "...", + Toast.LENGTH_SHORT).show(); + new Thread(() -> { + String reply = wfbLink.cameraCommand(cameraIp, AIR_MAN_PORT, + "set_ap_channel " + channel, AIR_MAN_TIMEOUT_MS, true); + runOnUiThread(() -> Toast.makeText(this, + reply == null ? "Camera did not answer, channel unchanged" : "Camera: " + reply.trim(), + Toast.LENGTH_LONG).show()); + }, "camera-command").start(); + } + + private void onLinkModeChanged(int mode) { + if (getLinkMode(this) == mode) { + return; + } + getSharedPreferences("general", MODE_PRIVATE).edit().putInt(PREF_LINK_MODE, mode).apply(); + wfbLinkManager.stopAdapters(); + applyLinkConfig(); + wfbLinkManager.startAdapters(); + // The tunnel only exists over wfb-ng. + if (mode == WfbNgLink.LINK_MODE_AP) { + // Directly, not with a STOP_SERVICE start; see WfbNgVpnService.stopRunning(). + WfbNgVpnService.stopRunning(); + } else { + startVpnService(); + } + } + + /** + * Submenu that switches between wfb-ng and joining the camera's Wi-Fi access point. + */ + private void setupLinkModeSubMenu(PopupMenu popup) { + SubMenu modeMenu = popup.getMenu().addSubMenu("Link mode"); + boolean ap = isApMode(); + + MenuItem wfbItem = modeMenu.add("WFB-NG"); + wfbItem.setCheckable(true); + wfbItem.setChecked(!ap); + wfbItem.setOnMenuItemClickListener(item -> { + onLinkModeChanged(WfbNgLink.LINK_MODE_WFB); + return true; + }); + + MenuItem apItem = modeMenu.add("APFPV"); + apItem.setCheckable(true); + apItem.setChecked(ap); + apItem.setOnMenuItemClickListener(item -> { + onLinkModeChanged(WfbNgLink.LINK_MODE_AP); + return true; + }); + + modeMenu.add("APFPV settings...").setOnMenuItemClickListener(item -> { + showApSettingsDialog(); + return true; + }); + } + + private static int selectedWidth(android.widget.RadioGroup group) { + android.view.View checked = group.findViewById(group.getCheckedRadioButtonId()); + return checked != null ? (int) checked.getTag() : DEFAULT_AP_MAX_WIDTH; + } + + private void showApSettingsDialog() { + SharedPreferences prefs = getSharedPreferences("general", MODE_PRIVATE); + + android.widget.LinearLayout layout = new android.widget.LinearLayout(this); + layout.setOrientation(android.widget.LinearLayout.VERTICAL); + layout.setPadding(50, 30, 50, 30); + + String[] labels = {"SSID (open network)", "Phone IP address", "Video UDP port", + "Adapter MAC address", "Camera IP address"}; + String[] values = { + prefs.getString(PREF_AP_SSID, DEFAULT_AP_SSID), + prefs.getString(PREF_AP_OWN_IP, DEFAULT_AP_OWN_IP), + String.valueOf(prefs.getInt(PREF_AP_VIDEO_PORT, DEFAULT_AP_VIDEO_PORT)), + getApOwnMac(), + prefs.getString(PREF_AP_CAMERA_IP, DEFAULT_AP_CAMERA_IP) + }; + final android.widget.EditText[] edits = new android.widget.EditText[labels.length]; + for (int i = 0; i < labels.length; i++) { + android.widget.TextView tv = new android.widget.TextView(this); + tv.setText(labels[i]); + layout.addView(tv); + edits[i] = new android.widget.EditText(this); + edits[i].setSingleLine(true); + edits[i].setText(values[i]); + layout.addView(edits[i]); + } + edits[2].setInputType(android.text.InputType.TYPE_CLASS_NUMBER); + + final android.widget.CheckBox fullScan = new android.widget.CheckBox(this); + fullScan.setText("Scan all channels if the camera is not found"); + fullScan.setChecked(prefs.getBoolean(PREF_AP_FULL_SCAN, false)); + layout.addView(fullScan); + + android.widget.TextView widthLabel = new android.widget.TextView(this); + widthLabel.setText("Max. channel width (follows the camera up to this; 20 MHz is the fallback " + + "if a wider channel does not work)"); + layout.addView(widthLabel); + final android.widget.RadioGroup widthGroup = new android.widget.RadioGroup(this); + widthGroup.setOrientation(android.widget.RadioGroup.HORIZONTAL); + final int[] widths = {20, 40, 80}; + int savedWidth = prefs.getInt(PREF_AP_MAX_WIDTH, DEFAULT_AP_MAX_WIDTH); + for (int w : widths) { + android.widget.RadioButton rb = new android.widget.RadioButton(this); + rb.setText(w + " MHz"); + rb.setId(android.view.View.generateViewId()); + rb.setTag(w); + widthGroup.addView(rb); + if (w == savedWidth) widthGroup.check(rb.getId()); + } + layout.addView(widthGroup); + + new android.app.AlertDialog.Builder(this) + .setTitle("APFPV settings") + .setMessage("The camera has to send its stream to this IP and port. " + + "The search starts on the last known channel (Channel menu), then " + + "checks the channels the camera uses while booting (" + + Arrays.toString(AP_BOOT_CHANNELS) + ").") + .setView(layout) + .setPositiveButton("Save", (dialog, which) -> { + String ssid = edits[0].getText().toString(); + String ip = edits[1].getText().toString().trim(); + String mac = edits[3].getText().toString().trim().toLowerCase(Locale.US); + String cameraIp = edits[4].getText().toString().trim(); + int port; + try { + port = Integer.parseInt(edits[2].getText().toString().trim()); + } catch (NumberFormatException e) { + port = -1; + } + boolean cameraIpValid = cameraIp.matches("\\d{1,3}(\\.\\d{1,3}){3}"); + if (ssid.isEmpty() || !cameraIpValid + || !wfbLink.setApConfig(ssid, ip, mac, port, AP_BOOT_CHANNELS, fullScan.isChecked(), + selectedWidth(widthGroup))) { + Toast.makeText(this, "Invalid APFPV settings, nothing saved", Toast.LENGTH_LONG).show(); + return; + } + prefs.edit() + .putString(PREF_AP_SSID, ssid) + .putString(PREF_AP_OWN_IP, ip) + .putString(PREF_AP_OWN_MAC, mac) + .putInt(PREF_AP_VIDEO_PORT, port) + .putString(PREF_AP_CAMERA_IP, cameraIp) + .putBoolean(PREF_AP_FULL_SCAN, fullScan.isChecked()) + .putInt(PREF_AP_MAX_WIDTH, selectedWidth(widthGroup)) + .apply(); + Toast.makeText(this, "APFPV settings saved", Toast.LENGTH_SHORT).show(); + if (isApMode()) { + wfbLinkManager.stopAdapters(); + wfbLinkManager.startAdapters(); + } + }) + .setNegativeButton("Cancel", null) + .show(); } // ---------------------------------------------------------------------------- @@ -632,6 +903,9 @@ private void setupButtonHandlers() { private void showSettingsMenu(View anchor) { PopupMenu popup = new PopupMenu(this, anchor); + // Link mode submenu (WFB-NG / AP) + setupLinkModeSubMenu(popup); + // VR submenu setupVRSubMenu(popup); @@ -639,7 +913,10 @@ private void showSettingsMenu(View anchor) { setupChannelSubMenu(popup); // Bandwidth submenu - setupBandwidthSubMenu(popup); + // Bandwidth submenu (wfb-ng only; in AP mode the camera's channel decides the width) + if (!isApMode()) { + setupBandwidthSubMenu(popup); + } // Video submenu setupVideoSubMenu(popup); @@ -647,8 +924,10 @@ private void showSettingsMenu(View anchor) { // OSD submenu setupOSDSubMenu(popup); - // WFB submenu - setupWFBSubMenu(popup); + // WFB submenu (the key only matters to wfb-ng) + if (!isApMode()) { + setupWFBSubMenu(popup); + } // Adaptive link submenu setupAdaptiveLinkSubMenu(popup); @@ -693,7 +972,8 @@ private void setupVRSubMenu(PopupMenu popup) { */ private void setupChannelSubMenu(PopupMenu popup) { SubMenu chnMenu = popup.getMenu().addSubMenu("Channel"); - int channelPref = getChannel(this); + // In AP mode: the camera's channel (asks the camera to switch while connected). + int channelPref = getStartChannel(); // Create a disabled item to act as the header MenuItem headerItem = chnMenu.add("Current: " + channelPref); @@ -1042,7 +1322,17 @@ private void setupDroneSubMenu(PopupMenu popup) { SubMenu drone = popup.getMenu().addSubMenu("Drone"); MenuItem settings = drone.add("Settings"); settings.setOnMenuItemClickListener(item -> { - startBrowser(); + if (isApMode()) { + showCameraControl(); + } else { + startBrowser(); + } + return true; + }); + + MenuItem address = drone.add("Address (" + getDroneAddress() + ")"); + address.setOnMenuItemClickListener(item -> { + showDroneAddressDialog(); return true; }); @@ -1054,6 +1344,82 @@ private void setupDroneSubMenu(PopupMenu popup) { }); } + /** + * APFPV mode: the camera's controls (air_man_ap + aalinkFPV web API) instead of its web + * page. Over the USB adapter while that is joined to the camera - a WebView could not + * reach it there, it uses the phone's network - otherwise over the phone's own Wi-Fi, + * for when the phone itself is connected to the camera. + */ + private void showCameraControl() { + boolean viaAdapter = wfbLink.getApChannel() != 0; + SharedPreferences prefs = getSharedPreferences("general", MODE_PRIVATE); + new CameraControlDialog(this, + viaAdapter ? CameraControlDialog.overAdapter(wfbLink) : CameraControlDialog.overWifi(this), + viaAdapter ? "USB adapter" : "phone Wi-Fi", + prefs.getString(PREF_AP_CAMERA_IP, DEFAULT_AP_CAMERA_IP), + getDroneUsername(), getDronePassword(), + prefs.getInt(PREF_AP_MAX_WIDTH, DEFAULT_AP_MAX_WIDTH), + ssid -> { + // The camera reboots under the new name; join that one from now on. + prefs.edit().putString(PREF_AP_SSID, ssid).apply(); + wfbLinkManager.stopAdapters(); + applyLinkConfig(); + wfbLinkManager.startAdapters(); + Toast.makeText(this, "APFPV SSID set to " + ssid, Toast.LENGTH_SHORT).show(); + }).show(); + } + + private String getDroneAddress() { + return getSharedPreferences("general", Context.MODE_PRIVATE) + .getString(PREF_DRONE_ADDRESS, DEFAULT_DRONE_ADDRESS); + } + + /** The drone's web UI URL; a bare host or host:port gets http:// in front. */ + private String getDroneUrl() { + String address = getDroneAddress(); + return address.matches("(?i)^https?://.*") ? address : "http://" + address; + } + + private void showDroneAddressDialog() { + android.widget.LinearLayout layout = new android.widget.LinearLayout(this); + layout.setOrientation(android.widget.LinearLayout.VERTICAL); + layout.setPadding(50, 30, 50, 30); + + final android.widget.EditText addressEditText = new android.widget.EditText(this); + addressEditText.setHint(DEFAULT_DRONE_ADDRESS); + addressEditText.setSingleLine(true); + addressEditText.setInputType(android.text.InputType.TYPE_CLASS_TEXT + | android.text.InputType.TYPE_TEXT_VARIATION_URI); + addressEditText.setText(getDroneAddress()); + layout.addView(addressEditText); + + new android.app.AlertDialog.Builder(this) + .setTitle("Drone Address") + .setMessage("IP address, host:port or URL of the drone's web interface, opened by " + + "Drone > Settings in WFB-NG mode (default " + DEFAULT_DRONE_ADDRESS + + ", through the tunnel). In APFPV mode Settings opens the camera controls " + + "instead; their camera IP is in APFPV settings.") + .setView(layout) + .setPositiveButton("Save", (dialog, which) -> { + String address = addressEditText.getText().toString().trim(); + if (address.isEmpty() || address.contains(" ")) { + Toast.makeText(this, "Invalid address, nothing saved", Toast.LENGTH_LONG).show(); + return; + } + getSharedPreferences("general", MODE_PRIVATE).edit() + .putString(PREF_DRONE_ADDRESS, address).apply(); + Toast.makeText(this, "Drone address saved: " + address, Toast.LENGTH_SHORT).show(); + }) + .setNeutralButton("Default", (dialog, which) -> { + getSharedPreferences("general", MODE_PRIVATE).edit() + .remove(PREF_DRONE_ADDRESS).apply(); + Toast.makeText(this, "Drone address reset to " + DEFAULT_DRONE_ADDRESS, + Toast.LENGTH_SHORT).show(); + }) + .setNegativeButton("Cancel", null) + .show(); + } + private void setupUdpForwardingSubMenu(PopupMenu popup) { SubMenu forwardMenu = popup.getMenu().addSubMenu("UDP Forwarding"); @@ -1288,6 +1654,10 @@ private void shareLogs() { // VPN SERVICE // ---------------------------------------------------------------------------- private void startVpnService() { + if (isApMode()) { + // The VPN carries IP over the wfb-ng tunnel, which AP mode does not have. + return; + } int VPN_REQUEST_CODE = 100; Intent intent = VpnService.prepare(this); @@ -1585,7 +1955,7 @@ protected void onStop() { protected void onResume() { registerReceivers(); - wfbLinkManager.setChannel(getChannel(this)); + wfbLinkManager.setChannel(getStartChannel()); wfbLinkManager.setBandwidth(getBandwidth(this)); // On resume is called when the app is reopened, a device might have been plugged since the last time it started. @@ -1609,6 +1979,10 @@ protected void onResume() { @Override public void onChannelSettingChanged(int channel) { + if (isApMode()) { + onApChannelSelected(channel); + return; + } int currentChannel = getChannel(this); if (currentChannel == channel) { return; @@ -1689,6 +2063,10 @@ public void onDecodingInfoChanged(final DecodingInfo decodingInfo) { @Override public void onWfbNgStatsChanged(WfbNGStats data) { + if (isApMode()) { + showApStats(data); + return; + } runOnUiThread(() -> { if (data.count_p_all > 0) { binding.tvMessage.setVisibility(View.INVISIBLE); @@ -1754,6 +2132,90 @@ public void onWfbNgStatsChanged(WfbNGStats data) { }); } + private int lastApRecoveries = 0; + private final java.util.concurrent.atomic.AtomicBoolean keyFrameRequestRunning = + new java.util.concurrent.atomic.AtomicBoolean(false); + + /** + * Asks majestic on the camera for an immediate IDR frame (GET /request/idr) over the AP + * link, with the drone login credentials. One request at a time; a dropout that ends + * while one is still running is covered by it. + */ + private void requestKeyFrame() { + if (!keyFrameRequestRunning.compareAndSet(false, true)) { + return; + } + SharedPreferences prefs = getSharedPreferences("general", MODE_PRIVATE); + String cameraIp = prefs.getString(PREF_AP_CAMERA_IP, DEFAULT_AP_CAMERA_IP); + 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"; + new Thread(() -> { + try { + String reply = wfbLink.cameraCommand(cameraIp, 80, request, 3000); + String status = reply == null ? "no answer" : reply.split("\r?\n", 2)[0]; + Log.i(TAG, "key frame request: " + status); + } finally { + keyFrameRequestRunning.set(false); + } + }, "keyframe-request").start(); + } + + /** + * AP mode has no FEC, so the pie chart is left alone. The native side reuses the stats + * fields: count_p_all = data frames from the AP, count_p_dec_ok = UDP datagrams handed + * to the player, count_p_lost = frames that had to be dropped, count_p_outgoing = sent. + */ + private void showApStats(WfbNGStats data) { + String apStatus = wfbLink.getApStatus(); + // Empty while no adapter runs; the adapter messages in tvMessage then stay as they are. + boolean adapterRunning = !apStatus.isEmpty(); + String status = adapterRunning ? apStatus : "APFPV: no adapter running"; + + // Video can flow again (new association or end of a dropout): the decoder would + // otherwise show nothing usable until the next scheduled key frame. + int recoveries = wfbLink.getApRecoveries(); + if (recoveries != lastApRecoveries) { + lastApRecoveries = recoveries; + if (recoveries > 0) { + requestKeyFrame(); + } + } + + // Remember where the camera is (found there, or followed it after a switch), so the + // next start looks there first instead of searching. + int channel = wfbLink.getApChannel(); + if (channel > 0 && channel != getApChannel(this)) { + getSharedPreferences("general", MODE_PRIVATE).edit().putInt(PREF_AP_CHANNEL, channel).apply(); + wfbLinkManager.setChannel(channel); + Log.i(TAG, "AP is on channel " + channel + ", starting there from now on"); + } + runOnUiThread(() -> { + int color = getColor(R.color.colorRedBg); + if (data.count_p_dec_ok > 0) { + color = data.count_p_lost > 0 ? getColor(R.color.colorYellowBg) : getColor(R.color.colorGreenBg); + } + binding.imgLinkStatus.setImageTintList(ColorStateList.valueOf(color)); + // Until video flows, the connection state is the one thing worth looking at, + // so it goes in the centre message instead of only the small status line. + if (data.count_p_dec_ok > 0) { + binding.tvMessage.setVisibility(View.GONE); + } else if (adapterRunning) { + binding.tvMessage.setText(status); + binding.tvMessage.setVisibility(View.VISIBLE); + } + binding.tvLinkStatus.setText(String.format(Locale.US, "%s | rx %d udp %d drop %d tx %d", + status, + data.count_p_all, + data.count_p_dec_ok, + data.count_p_lost, + data.count_p_outgoing)); + }); + } + @Override public void onNewMavlinkData(MavlinkData data) { runOnUiThread(() -> osdManager.render(data)); @@ -1945,7 +2407,7 @@ public void startBrowser() { WebView view = new WebView(this); view.setWebViewClient(new WebViewClient()); view.getSettings().setJavaScriptEnabled(true); - view.loadUrl("10.5.0.10"); + view.loadUrl(getDroneUrl()); Dialog dialog = new Dialog(this); dialog.setContentView(view); diff --git a/app/src/main/java/com/openipc/pixelpilot/WfbLinkManager.java b/app/src/main/java/com/openipc/pixelpilot/WfbLinkManager.java index 5f1a82cb..7865b688 100644 --- a/app/src/main/java/com/openipc/pixelpilot/WfbLinkManager.java +++ b/app/src/main/java/com/openipc/pixelpilot/WfbLinkManager.java @@ -26,6 +26,7 @@ public class WfbLinkManager extends BroadcastReceiver { private final Context context; private int wifiChannel; private Bandwidth bandWidth; + private boolean apMode; public enum Bandwidth { BANDWIDTH_20(20), @@ -52,6 +53,11 @@ public void refreshKey() { wfbLink.refreshKey(); } + /** Only changes the status messages; the mode itself lives in WfbNgLink. */ + public void setApMode(boolean apMode) { + this.apMode = apMode; + } + public void setChannel(int channel) { wifiChannel = channel; } @@ -234,7 +240,7 @@ public synchronized void startAdapters() { public synchronized boolean startAdapter(UsbDevice dev) { binding.tvMessage.setVisibility(View.VISIBLE); - String text = "Starting wfb-ng channel " + wifiChannel + " with " + String.format( + String text = (apMode ? "Joining APFPV camera on channel " : "Starting wfb-ng channel ") + wifiChannel + " with " + String.format( "[%04X", dev.getVendorId()) + ":" + String.format("%04X]", dev.getProductId()); binding.tvMessage.setText(text); if (!wfbLink.start(wifiChannel, bandWidth.getValue(), dev)) { diff --git a/app/wfbngrtl8812/src/main/cpp/CMakeLists.txt b/app/wfbngrtl8812/src/main/cpp/CMakeLists.txt index ff53d5ef..6a5eb9a5 100644 --- a/app/wfbngrtl8812/src/main/cpp/CMakeLists.txt +++ b/app/wfbngrtl8812/src/main/cpp/CMakeLists.txt @@ -109,6 +109,8 @@ add_library(${CMAKE_PROJECT_NAME} SHARED TxFrame.cpp SignalQualityCalculator.h SignalQualityCalculator.cpp + StaLink.h + StaLink.cpp ) target_link_libraries(${CMAKE_PROJECT_NAME} diff --git a/app/wfbngrtl8812/src/main/cpp/StaLink.cpp b/app/wfbngrtl8812/src/main/cpp/StaLink.cpp new file mode 100644 index 00000000..1fb8aad0 --- /dev/null +++ b/app/wfbngrtl8812/src/main/cpp/StaLink.cpp @@ -0,0 +1,1551 @@ +#include "StaLink.h" + +#include "SignalQualityCalculator.h" +#include "devourer/src/RadiotapBuilder.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#undef TAG +#define TAG "pixelpilot-sta" + +using namespace std::chrono_literals; + +namespace { + +constexpr uint8_t kBroadcast[6] = {0xff, 0xff, 0xff, 0xff, 0xff, 0xff}; +constexpr uint8_t kZeroMac[6] = {0, 0, 0, 0, 0, 0}; +constexpr uint8_t kLlcSnap[6] = {0xaa, 0xaa, 0x03, 0x00, 0x00, 0x00}; + +// Management frame retransmission. On Jaguar1 (RTL8812AU) devourer marks every +// injected frame as group-addressed, so the chip never retries in hardware and +// these software retries are all there is. +constexpr auto kMgmtRetryInterval = 300ms; +constexpr int kMgmtMaxAttempts = 5; +constexpr auto kProbeInterval = 500ms; +constexpr auto kTickInterval = 50ms; +// Silence does NOT end the association: the AP keeps us associated through a +// fade, and video must be taken the moment it is heard again. The association is +// only redone when the AP says so (deauth/disassoc, or a deauth in answer to our +// keepalive after it restarted) or its beacon timestamp shows a restart. +// +// A silence this long counts as a dropout (beacons come every ~102 ms). When the +// AP is heard again, a keepalive goes out at once to check the association, and +// the UI asks the camera for a key frame. +constexpr auto kDropoutGap = 250ms; +// After this long without the AP, also peek at the candidate channels in case the +// camera rebooted onto one of its boot channels. Most of the time stays on the +// home channel, where a camera that only faded comes back. +constexpr auto kPeekAfter = 3s; +constexpr auto kPeekHomeDwell = 1000ms; +constexpr auto kPeekDwell = 150ms; +// A beacon timestamp this far behind the previous one means the AP restarted. +constexpr uint64_t kTsfRestartUs = 1000000; +// Re-announce our IP so the camera's ARP cache never expires mid-flight. +constexpr auto kGarpInterval = 30s; +// Null-data keepalive. The camera's hostapd polls a station after 20 s of +// silence (ap_max_inactivity=20) and drops it on missing ACKs; regular frames +// from us keep it from ever getting there. +constexpr auto kKeepaliveInterval = 5s; +// Channel scan while searching: a long look at the home channel, then a short +// one per other channel. 200 ms catches at least one beacon (every ~102 ms) and +// the answer to the probe request sent on arrival. +constexpr auto kHomeDwell = 1500ms; +constexpr auto kScanDwell = 200ms; +// 802.11 time unit; beacon intervals and CSA counts are in TU. +constexpr auto kTu = std::chrono::microseconds(1024); + +// OpenIPC APs use UNII-3 or UNII-1, so those go first; DFS and 2.4 GHz last. +constexpr uint8_t kScanChannels[] = {149, 153, 157, 161, 165, 36, 40, 44, 48, 52, 56, 60, 64, + 100, 104, 108, 112, 116, 120, 124, 128, 132, 136, 140, 144, 1, + 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13}; + +constexpr uint16_t kEthIpv4 = 0x0800; +constexpr uint16_t kEthArp = 0x0806; + +bool macEq(const uint8_t *a, const uint8_t *b) { return std::memcmp(a, b, 6) == 0; } + +// One's-complement sum over big-endian 16-bit words, for IP/ICMP/TCP checksums. +uint32_t csumAdd(uint32_t s, const uint8_t *d, size_t len) { + for (size_t i = 0; i + 1 < len; i += 2) s += (uint32_t)(d[i] << 8) | d[i + 1]; + if (len & 1) s += (uint32_t)d[len - 1] << 8; + return s; +} + +uint16_t csumFold(uint32_t s) { + while (s >> 16) s = (s & 0xffff) + (s >> 16); + return (uint16_t)~s; +} + +uint16_t csum16(const uint8_t *d, size_t len) { return csumFold(csumAdd(0, d, len)); } + +uint32_t ipKey(const uint8_t *ip) { + uint32_t k; + std::memcpy(&k, ip, 4); + return k; +} + +uint32_t rd32(const uint8_t *p) { return (uint32_t)p[0] << 24 | (uint32_t)p[1] << 16 | (uint32_t)p[2] << 8 | p[3]; } + +void wr32(uint8_t *p, uint32_t v) { + p[0] = v >> 24; + p[1] = v >> 16; + p[2] = v >> 8; + p[3] = v; +} + +constexpr uint8_t kTcpFin = 0x01, kTcpSyn = 0x02, kTcpRst = 0x04, kTcpPsh = 0x08, kTcpAck = 0x10; +constexpr auto kTcpRetransmit = 400ms; +// air_man_ap answers once it is done; when it keeps the connection open after +// that, this much silence after the first reply bytes ends the request. +constexpr auto kTcpReplyIdle = 1500ms; + +const char *stateName(StaLink::State s) { + switch (s) { + case StaLink::State::Searching: + return "searching"; + case StaLink::State::Authenticating: + return "authenticating"; + case StaLink::State::Associating: + return "associating"; + case StaLink::State::Connected: + return "connected"; + } + return "?"; +} + +} // namespace + +StaLink::StaLink(IRtlDevice *dev, StaConfig cfg) + : dev_(dev), cfg_(std::move(cfg)), channel_(cfg_.channel), home_channel_(cfg_.channel) { + buildScanList(); + radiotap_ = devourer::build_stream_radiotap(devourer::parse_tx_mode_str("6M")); + udp_sock_ = socket(AF_INET, SOCK_DGRAM, 0); + if (udp_sock_ < 0) { + __android_log_print(ANDROID_LOG_ERROR, TAG, "could not create the forwarding socket"); + } +} + +StaLink::~StaLink() { + stop(); + if (udp_sock_ >= 0) close(udp_sock_); +} + +void StaLink::start() { + if (running_.exchange(true)) return; + __android_log_print(ANDROID_LOG_INFO, + TAG, + "joining open network \"%s\" on channel %d as %02x:%02x:%02x:%02x:%02x:%02x / %d.%d.%d.%d", + cfg_.ssid.c_str(), + cfg_.channel, + cfg_.own_mac[0], + cfg_.own_mac[1], + cfg_.own_mac[2], + cfg_.own_mac[3], + cfg_.own_mac[4], + cfg_.own_mac[5], + cfg_.own_ip[0], + cfg_.own_ip[1], + cfg_.own_ip[2], + cfg_.own_ip[3]); + { + // First "networks heard" report only after a full listening window. + std::lock_guard lock(mu_); + last_report_ = last_seen_log_ = dwell_start_ = Clock::now(); + } + worker_ = std::thread([this] { worker(); }); +} + +void StaLink::stop() { + if (!running_.exchange(false)) return; + q_cv_.notify_all(); + if (worker_.joinable()) worker_.join(); + // Release a caller blocked in tcpRequest(); nothing will answer it any more. + std::lock_guard lock(mu_); + tcpFinish(false); +} + +uint8_t StaLink::connectedChannel() const { + std::lock_guard lock(mu_); + return state_.load() == State::Connected ? channel_ : 0; +} + +StaLink::Stats StaLink::takeStats() { + return Stats{rx_data_.exchange(0), rx_udp_.exchange(0), rx_dropped_.exchange(0), tx_frames_.exchange(0)}; +} + +std::string StaLink::statusText() const { + std::lock_guard lock(mu_); + char buf[160]; + State s = state_.load(); + if (s == State::Searching) { + // What the adapter hears tells apart "wrong channel/SSID" from "adapter deaf". + std::string heard; + int n = 0; + for (const auto &[bssid, net] : seen_) { + if (n++ == 3) { + heard += ", ..."; + break; + } + if (!heard.empty()) heard += ", "; + heard += net.ssid.empty() ? "" : net.ssid; + if (net.encrypted) heard += " (encrypted)"; + } + snprintf(buf, + sizeof(buf), + "APFPV: searching \"%s\" ch %d | %u frames/s | heard: %s", + cfg_.ssid.c_str(), + channel_, + frames_per_sec_, + heard.empty() ? "no networks" : heard.c_str()); + } else { + snprintf(buf, + sizeof(buf), + "APFPV: %s \"%s\" ch %d/%dMHz %02x:%02x:%02x:%02x:%02x:%02x rssi %u", + stateName(s), + cfg_.ssid.c_str(), + channel_, + width_mhz_, + bssid_[0], + bssid_[1], + bssid_[2], + bssid_[3], + bssid_[4], + bssid_[5], + last_rssi_); + std::string text = buf; + const auto silence = Clock::now() - last_heard_; + if (s == State::Connected && silence >= kDropoutGap) { + snprintf(buf, sizeof(buf), " | no signal %.1f s", + std::chrono::duration(silence).count()); + text += buf; + } + if (have_camera_ip_) { + snprintf(buf, sizeof(buf), " | video from %d.%d.%d.%d", camera_ip_[0], camera_ip_[1], camera_ip_[2], + camera_ip_[3]); + text += buf; + } + return text; + } + return buf; +} + +// --------------------------------------------------------------------------- +// Worker: owns all transmissions. devourer's send_packet() must not be called +// from the RX callback (libusb answers BUSY there), so the RX side only queues. +// --------------------------------------------------------------------------- + +void StaLink::worker() { + auto last_tick = Clock::time_point{}; + while (running_) { + int retune = -1; + int retune_width = 20, cur_width = 20; + uint8_t retune_offset = 0, cur_offset = 0; + auto now = Clock::now(); + if (now - last_tick >= kTickInterval) { + last_tick = now; + std::lock_guard lock(mu_); + tick(); + retune = std::exchange(retune_to_, -1); + retune_width = retune_width_; + retune_offset = retune_offset_; + cur_width = width_mhz_; + cur_offset = chan_offset_; + } + + std::vector> batch; + { + std::unique_lock lk(q_mu_); + if (retune < 0) q_cv_.wait_for(lk, 20ms, [this] { return !tx_queue_.empty() || !running_; }); + batch.swap(tx_queue_); + } + for (auto &f : batch) { + if (dev_->send_packet(f.data(), f.size())) tx_frames_++; + } + + if (retune > 0) { + // Outside mu_: a retune takes milliseconds (more on a band change) and the + // RX thread must keep draining meanwhile. devourer's own hopping RX does the + // same from a second thread while StartRxLoop runs. + try { + if (retune_width == cur_width && retune_offset == cur_offset) { + // Same width: the lean channel-only switch (~1-2 ms). + dev_->FastRetune(static_cast(retune), true); + } else { + // Width change: the full channel set (~90 ms), which FastRetune can't do. + dev_->SetMonitorChannel(SelectedChannel{ + .Channel = static_cast(retune), + .ChannelOffset = retune_offset, + .ChannelWidth = retune_width == 80 ? CHANNEL_WIDTH_80 + : retune_width == 40 ? CHANNEL_WIDTH_40 + : CHANNEL_WIDTH_20, + }); + __android_log_print(ANDROID_LOG_INFO, + TAG, + "tuned ch %d at %d MHz%s", + retune, + retune_width, + retune_width == 40 ? (retune_offset == 2 ? " (HT40-)" : " (HT40+)") : ""); + } + } catch (const std::exception &e) { + __android_log_print(ANDROID_LOG_ERROR, TAG, "retune to ch %d failed: %s", retune, e.what()); + } + std::lock_guard lock(mu_); + channel_ = static_cast(retune); + width_mhz_ = retune_width; + chan_offset_ = retune_offset; + dwell_start_ = Clock::now(); + last_tx_attempt_ = Clock::time_point{}; // probe right away on the new channel + } + } +} + +void StaLink::setState(State s) { + State old = state_.exchange(s); + if (old == s) return; + __android_log_print(ANDROID_LOG_INFO, TAG, "state %s -> %s", stateName(old), stateName(s)); + attempts_ = 0; + last_tx_attempt_ = Clock::time_point{}; + if (old == State::Connected) { + tcpFinish(false); // a request in flight would only time out now + } + if (s == State::Connected) { + in_gap_ = false; + recoveries_++; + } + if (s == State::Searching) { + aid_ = 0; + last_rx_seq_ = -1; + csa_channel_ = -1; + last_tsf_ = 0; + // Look where the AP was first, for a full home dwell, before scanning. + dwell_start_ = Clock::now(); + // Searching runs at 20 MHz: beacons are on the primary 20, and a wide + // tuning on a scan channel would centre the radio off it. + if (channel_ != home_channel_ || width_mhz_ != 20) { + requestTune(home_channel_, 20, 0); + } + scan_index_ = 0; + } +} + +void StaLink::requestTune(int channel, int width_mhz, uint8_t offset) { + retune_to_ = channel; + retune_width_ = width_mhz; + retune_offset_ = offset; +} + +int StaLink::desiredWidth(const uint8_t *ies, size_t len, uint8_t &offset) const { + offset = 0; + if (cfg_.max_width_mhz < 40 || channel_ <= 14) return 20; + int primary = -1, sec = 0, vht_width = 0, vht_center = 0; + bool any_width = false; + for (size_t i = 0; i + 2 <= len;) { + const uint8_t id = ies[i]; + const uint8_t l = ies[i + 1]; + const uint8_t *v = ies + i + 2; + if (i + 2 + l > len) break; + if (id == 61 && l >= 2) { // HT Operation: primary, secondary offset + STA width bits + primary = v[0]; + sec = v[1] & 0x03; + any_width = (v[1] & 0x04) != 0; + } else if (id == 192 && l >= 3) { // VHT Operation: width, centre segment 0 + vht_width = v[0]; + vht_center = v[1]; + } + i += 2 + l; + } + if (primary != channel_ || !any_width || (sec != 1 && sec != 3)) return 20; + offset = sec == 1 ? 1 : 2; + // devourer tunes 80 MHz only with the primary as the lowest 20 of the block + // (centre = primary + 6), which every 80 MHz channel air_man_ap offers is. + if (vht_width == 1 && cfg_.max_width_mhz >= 80 && ap_vht_ && vht_center == channel_ + 6) { + offset = 0; + return 80; + } + return 40; +} + +void StaLink::beginAuth() { + setState(State::Authenticating); + attempts_ = 1; + last_tx_attempt_ = Clock::now(); + sendAuth(); +} + +void StaLink::reconnect(const char *why) { + __android_log_print(ANDROID_LOG_WARN, + TAG, + "%s: rejoining %02x:%02x:%02x:%02x:%02x:%02x on ch %d", + why, + bssid_[0], bssid_[1], bssid_[2], bssid_[3], bssid_[4], bssid_[5], + channel_); + aid_ = 0; + last_rx_seq_ = -1; + beginAuth(); +} + +void StaLink::buildScanList() { + scan_list_.clear(); + auto add = [this](uint8_t ch) { + if (ch != 0 && std::find(scan_list_.begin(), scan_list_.end(), ch) == scan_list_.end()) { + scan_list_.push_back(ch); + } + }; + add(home_channel_); + for (uint8_t ch : cfg_.candidate_channels) add(ch); + if (cfg_.full_scan) { + for (uint8_t ch : kScanChannels) add(ch); + } + scan_index_ = 0; +} + +// Called with mu_ held. `ies` points at the information elements of a beacon, +// probe response or CSA action frame from our AP. +void StaLink::handleCsa(const uint8_t *ies, size_t len) { + int new_channel = -1; + int count = 0; + int op_class = 0; + int sec = -1; // Secondary Channel Offset element: 0 none, 1 above, 3 below + int wb_width = -1; // Wide Bandwidth Channel Switch: 0 = 20/40, 1 = 80 + int wb_center = 0; + auto wide_bw = [&](const uint8_t *v, uint8_t l) { + if (l >= 2) { + wb_width = v[0]; + wb_center = v[1]; + } + }; + for (size_t i = 0; i + 2 <= len;) { + const uint8_t id = ies[i]; + const uint8_t l = ies[i + 1]; + const uint8_t *v = ies + i + 2; + if (i + 2 + l > len) break; + if (id == 37 && l >= 3) { // Channel Switch Announcement: mode, channel, count + new_channel = v[1]; + count = v[2]; + } else if (id == 60 && l >= 4) { // Extended CSA: mode, operating class, channel, count + op_class = v[1]; + new_channel = v[2]; + count = v[3]; + } else if (id == 62 && l >= 1) { + sec = v[0]; + } else if (id == 194) { + wide_bw(v, l); + } else if (id == 196) { // Channel Switch Wrapper: sub-elements, 194 among them + for (size_t j = 0; j + 2 <= l;) { + const uint8_t sl = v[j + 1]; + if (j + 2 + sl > l) break; + if (v[j] == 194) wide_bw(v + j + 2, sl); + j += 2 + sl; + } + } + i += 2 + l; + } + if (new_channel <= 0 || new_channel == csa_channel_) return; + + // The new width, so the radio lands on it right at the switch: air_man_ap pings us + // straight after a switch and reverts when that fails, and a station still at + // 20 MHz cannot decode a 40/80 MHz ping. + int width = 20; + uint8_t offset = 0; + if (sec == 1 || sec == 3) { + width = 40; + offset = sec == 1 ? 1 : 2; + } else if (sec < 0) { + // Global operating classes (802.11 Annex E): 40 MHz above / below, 80 MHz. + if (op_class == 116 || op_class == 119 || op_class == 122 || op_class == 126) { + width = 40; + offset = 1; + } else if (op_class == 117 || op_class == 120 || op_class == 123 || op_class == 127) { + width = 40; + offset = 2; + } else if (op_class == 128) { + width = 80; + wb_width = 1; + wb_center = new_channel + 6; // only usable for us with the primary lowest anyway + } + } + if (wb_width == 1 && (width == 40 || width == 80)) { + if (wb_center == new_channel + 6) { + width = 80; + offset = 0; + } else if (width == 80) { + width = 20; // primary not the lowest 20: devourer can't tune it + } + } + if (new_channel <= 14 || cfg_.max_width_mhz < 40) { + width = 20; + offset = 0; + } else if (width == 80 && (cfg_.max_width_mhz < 80 || !ap_vht_)) { + width = offset ? 40 : 20; + } + if (new_channel == channel_ && width == width_mhz_ && offset == chan_offset_) return; + + // count = beacons until the switch (0: any time now). Leave one beacon early + // rather than late: being away briefly costs less than missing the switch. + csa_channel_ = new_channel; + csa_width_ = width; + csa_offset_ = offset; + csa_at_ = Clock::now() + std::max(count - 1, 0) * 100 * kTu; + __android_log_print(ANDROID_LOG_INFO, + TAG, + "AP announces a switch to ch %d (%d MHz for us) in %d beacons", + new_channel, + width, + count); +} + +// Called with mu_ held. +void StaLink::tick() { + auto now = Clock::now(); + report(now); + + if (csa_channel_ > 0 && state_.load() != State::Searching && now >= csa_at_) { + // The AP announced a channel switch and is leaving now; go with it. The + // association survives a CSA, so the state stays as it is. + __android_log_print(ANDROID_LOG_INFO, + TAG, + "following the AP: ch %d -> ch %d at %d MHz", + channel_, + csa_channel_, + csa_width_); + requestTune(csa_channel_, csa_width_, csa_offset_); + last_width_change_ = now; // the beacon check would otherwise second-guess it mid-switch + home_channel_ = static_cast(csa_channel_); + buildScanList(); + csa_channel_ = -1; + last_heard_ = now; // no beacons while switching + return; + } + + switch (state_.load()) { + case State::Searching: { + // Not heard on this channel within the dwell: move on through the scan list. + const auto dwell = channel_ == home_channel_ ? kHomeDwell : kScanDwell; + if (scan_list_.size() > 1 && now - dwell_start_ >= dwell) { + scan_index_ = (scan_index_ + 1) % scan_list_.size(); + requestTune(scan_list_[scan_index_], 20, 0); + break; + } + // DFS channels are passive-scan only: no transmitting until an AP is heard there. + const bool dfs = channel_ >= 52 && channel_ <= 144; + if (!dfs && now - last_tx_attempt_ >= kProbeInterval) { + last_tx_attempt_ = now; + sendProbeRequest(); + } + break; + } + case State::Authenticating: + case State::Associating: + if (now - last_tx_attempt_ >= kMgmtRetryInterval) { + if (attempts_ >= kMgmtMaxAttempts) { + __android_log_print(ANDROID_LOG_WARN, TAG, "no answer from the AP, searching again"); + setState(State::Searching); + break; + } + attempts_++; + last_tx_attempt_ = now; + if (state_.load() == State::Authenticating) { + sendAuth(); + } else { + sendAssocRequest(); + } + } + break; + case State::Connected: { + const auto silence = now - last_heard_; + if (silence >= kDropoutGap) in_gap_ = true; // the end of it is handled in onRx() + if (silence >= kPeekAfter && scan_list_.size() > 1) { + // Long outage: listen at home, glance at one candidate channel in between. + if (channel_ == home_channel_) { + if (now - dwell_start_ >= kPeekHomeDwell) { + scan_index_ = scan_index_ % (scan_list_.size() - 1) + 1; + requestTune(scan_list_[scan_index_], 20, 0); + } + } else if (now - dwell_start_ >= kPeekDwell) { + requestTune(home_channel_, 20, 0); + } + break; + } + if (now - last_garp_ >= kGarpInterval) { + last_garp_ = now; + sendArp(1, kZeroMac, cfg_.own_ip.data(), kBroadcast); + } + if (now - last_tx_attempt_ >= kKeepaliveInterval) { + last_tx_attempt_ = now; + // Null function (data, no payload), ToDS, power management bit clear. + std::vector m = mgmtHeader(0x48, bssid_.data(), bssid_.data()); + m[1] = 0x01; + enqueue(std::move(m)); + } + tcpTick(now); + break; + } + } +} + +// Called with mu_ held. +void StaLink::report(Clock::time_point now) { + if (now - last_report_ < 1s) return; + const uint32_t total = rx_frames_total_.load(); + frames_per_sec_ = total - frames_at_last_report_; + frames_at_last_report_ = total; + last_report_ = now; + + for (auto it = seen_.begin(); it != seen_.end();) { + it = (now - it->second.last > 10s) ? seen_.erase(it) : std::next(it); + } + + if (state_.load() == State::Connected && now - last_traffic_log_ >= 5s) { + // What arrives from the AP and where it goes: tells "camera sends nothing" from + // "camera sends something we drop" without a capture. + last_traffic_log_ = now; + std::string ports; + for (const auto &[port, n] : udp_by_port_) { + bool fwd = false; + for (const auto &f : cfg_.forward_ports) fwd |= f.first == port; + char b[48]; + snprintf(b, sizeof(b), " %u:%u%s", port, n, fwd ? "" : "(not forwarded)"); + ports += b; + } + __android_log_print(ANDROID_LOG_INFO, + TAG, + "last 5 s on ch %d: %u data frames, UDP for us by port:%s | IP fragments %u " + "(reassembled %u), 802.11 fragments %u, protected %u, non-IPv4 %u", + channel_, + sum_data_, + ports.empty() ? " none" : ports.c_str(), + sum_fragments_, + sum_reassembled_, + sum_mac_fragments_, + sum_protected_, + sum_not_ipv4_); + udp_by_port_.clear(); + sum_data_ = sum_fragments_ = sum_reassembled_ = sum_protected_ = sum_not_ipv4_ = sum_mac_fragments_ = 0; + } + + if (state_.load() != State::Searching || now - last_seen_log_ < 5s) return; + last_seen_log_ = now; + if (seen_.empty()) { + __android_log_print(ANDROID_LOG_WARN, + TAG, + "searching \"%s\" on ch %d: %u frames/s received, no beacons heard. " + "Wrong channel, or the camera's AP is not up?", + cfg_.ssid.c_str(), + channel_, + frames_per_sec_); + return; + } + __android_log_print(ANDROID_LOG_WARN, + TAG, + "searching \"%s\" on ch %d: %u frames/s received, networks heard:", + cfg_.ssid.c_str(), + channel_, + frames_per_sec_); + for (const auto &[b, net] : seen_) { + __android_log_print(ANDROID_LOG_WARN, + TAG, + " \"%s\" %02x:%02x:%02x:%02x:%02x:%02x ch %d %s rssi %u", + net.ssid.c_str(), + b[0], + b[1], + b[2], + b[3], + b[4], + b[5], + net.channel, + net.encrypted ? "encrypted" : "open", + net.rssi); + } +} + +// --------------------------------------------------------------------------- +// RX +// --------------------------------------------------------------------------- + +void StaLink::onRx(const Packet &packet) { + if (packet.RxAtrib.crc_err || packet.Data.size() < 24 + 4) return; + rx_frames_total_++; + const uint8_t *f = packet.Data.data(); + const size_t len = packet.Data.size() - 4; // strip FCS + + const uint8_t fc0 = f[0]; + const uint8_t type = (fc0 >> 2) & 0x03; + const uint8_t *addr1 = f + 4; + const uint8_t *addr2 = f + 10; + + std::lock_guard lock(mu_); + const bool from_ap = state_.load() != State::Searching && macEq(addr2, bssid_.data()); + if (from_ap) { + const auto now = Clock::now(); + 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, + TAG, + "AP back after %lld ms on ch %d", + (long long)std::chrono::duration_cast(now - last_heard_).count(), + channel_); + if (channel_ != home_channel_) { + // Heard while peeking at a candidate: the camera lives here now. + home_channel_ = channel_; + buildScanList(); + } + // Keepalive right away: an AP that dropped us (or restarted) answers it with a + // deauth, which rejoins at once instead of after the next keepalive. + last_tx_attempt_ = Clock::time_point{}; + } + last_heard_ = now; + last_rssi_ = packet.RxAtrib.rssi[0]; + } + + if (type == 0) { // management + const bool to_us = macEq(addr1, cfg_.own_mac.data()); + switch (fc0) { + case 0x80: // beacon + case 0x50: // probe response + if (state_.load() == State::Searching) { + handleBeacon(f, len, packet.RxAtrib.rssi[0]); + } else if (from_ap && len > 36) { + uint64_t tsf = 0; + for (int i = 7; i >= 0; i--) tsf = (tsf << 8) | f[24 + i]; + if (state_.load() == State::Connected && last_tsf_ != 0 && tsf + kTsfRestartUs < last_tsf_) { + // A restarted hostapd no longer knows us; don't wait for its deauth. + last_tsf_ = tsf; + reconnect("AP restarted (beacon timestamp went back)"); + break; + } + last_tsf_ = tsf; + handleCsa(f + 36, len - 36); + // Match the AP's width (within our limits). Covers joining, a CSA to a + // different width (followed at 20 MHz first) and a return from a peek. + if (state_.load() == State::Connected && retune_to_ < 0 && csa_channel_ < 0) { + uint8_t off; + const int w = desiredWidth(f + 36, len - 36, off); + const auto now = Clock::now(); + if ((w != width_mhz_ || off != chan_offset_) && now - last_width_change_ > 1s) { + last_width_change_ = now; + __android_log_print(ANDROID_LOG_INFO, + TAG, + "AP runs ch %d at a different width: %d -> %d MHz", + channel_, + width_mhz_, + w); + requestTune(channel_, w, off); + } + } + } + break; + case 0xb0: // authentication + if (from_ap && to_us) handleAuth(f, len); + break; + case 0x10: // association response + case 0x30: // reassociation response + if (from_ap && to_us) handleAssocResp(f, len); + break; + case 0xa0: // disassociation + case 0xc0: // deauthentication + if (from_ap && (to_us || (addr1[0] & 0x01))) { + uint16_t reason = len >= 26 ? (f[24] | (f[25] << 8)) : 0; + char why[64]; + snprintf(why, + sizeof(why), + "%s by the AP, reason %u", + fc0 == 0xa0 ? "disassociated" : "deauthenticated", + reason); + if (state_.load() == State::Connected) { + // The AP is right here, so rejoin it directly instead of searching. + reconnect(why); + } else { + // Mid-join: the bounded auth/assoc retries in tick() handle it, so an + // AP that keeps refusing cannot drive a tight reconnect loop. + __android_log_print(ANDROID_LOG_WARN, TAG, "%s while joining", why); + } + } + break; + case 0xd0: // action + if (from_ap && len >= 24 + 6 && f[25] == 4) { + if (f[24] == 0) { // spectrum management: CSA element follows + handleCsa(f + 26, len - 26); + } else if (f[24] == 4) { // public: extended CSA, fields without element header + const uint8_t ecsa[] = {60, 4, f[26], f[27], f[28], f[29]}; + handleCsa(ecsa, sizeof(ecsa)); + } + } + // Decline block-ack sessions (ADDBA request -> response "request declined"), + // so the AP stops asking and sends plain MPDUs. + if (from_ap && to_us && len >= 24 + 9 && f[24] == 3 && f[25] == 0) { + std::vector m = mgmtHeader(0xd0, bssid_.data(), bssid_.data()); + m.insert(m.end(), {3, 1, f[26], 37, 0, f[27], f[28], f[29], f[30]}); + enqueue(std::move(m)); + } + break; + default: + break; + } + } else if (type == 2 && from_ap && state_.load() == State::Connected) { + handleData(f, len, packet.RxAtrib.rssi[0], packet.RxAtrib.rssi[1]); + SignalQualityCalculator::get_instance().add_snr(packet.RxAtrib.snr[0], packet.RxAtrib.snr[1]); + } +} + +void StaLink::handleBeacon(const uint8_t *f, size_t len, uint8_t rssi) { + constexpr size_t kIeStart = 24 + 12; // timestamp, interval, capability + if (len < kIeStart) return; + const uint16_t capability = f[34] | (f[35] << 8); + + std::string ssid; + bool ssid_match = false; + int ds_channel = -1; + bool ht = false; + bool vht = false; + bool wmm = false; + std::vector rates; + for (size_t i = kIeStart; i + 2 <= len;) { + const uint8_t id = f[i]; + const uint8_t l = f[i + 1]; + const uint8_t *v = f + i + 2; + if (i + 2 + l > len) break; + if (id == 0) { + ssid.assign(reinterpret_cast(v), l); + ssid_match = ssid == cfg_.ssid; + } else if (id == 1 || id == 50) { + rates.insert(rates.end(), v, v + l); + } else if (id == 3 && l >= 1) { + ds_channel = v[0]; + } else if (id == 45) { + ht = true; + } else if (id == 191) { + vht = true; + } else if (id == 221 && l >= 4 && v[0] == 0x00 && v[1] == 0x50 && v[2] == 0xf2 && v[3] == 0x02) { + wmm = true; + } + i += 2 + l; + } + + std::array bssid; + std::memcpy(bssid.data(), f + 16, 6); + seen_[bssid] = SeenNetwork{ssid, ds_channel != -1 ? ds_channel : channel_, (capability & 0x0010) != 0, + rssi, Clock::now()}; + + if (!ssid_match) return; + // On 2.4 GHz neighbouring channels leak in; only join on the channel we are tuned to. + if (ds_channel != -1 && ds_channel != channel_) return; + if (capability & 0x0010) { + if (!warned_encrypted_) { + warned_encrypted_ = true; + __android_log_print(ANDROID_LOG_ERROR, + TAG, + "\"%s\" is encrypted, only open networks are supported", + cfg_.ssid.c_str()); + } + return; + } + + std::memcpy(bssid_.data(), f + 16, 6); + if (home_channel_ != channel_) { + __android_log_print(ANDROID_LOG_INFO, + TAG, + "\"%s\" is on ch %d, not on ch %d; looking there first from now on", + cfg_.ssid.c_str(), + channel_, + home_channel_); + home_channel_ = channel_; + buildScanList(); + } + ap_rates_ = std::move(rates); + ap_ht_ = ht; + ap_vht_ = vht; + ap_wmm_ = wmm; + last_heard_ = Clock::now(); + __android_log_print(ANDROID_LOG_INFO, + TAG, + "found \"%s\" bssid %02x:%02x:%02x:%02x:%02x:%02x ht=%d vht=%d wmm=%d", + cfg_.ssid.c_str(), + bssid_[0], + bssid_[1], + bssid_[2], + bssid_[3], + bssid_[4], + bssid_[5], + ht, + vht, + wmm); + last_tsf_ = 0; + for (int i = 7; i >= 0; i--) last_tsf_ = (last_tsf_ << 8) | f[24 + i]; + // Straight from the RX callback (only queued here; the worker sends it), which + // saves up to one tick per handshake step. + beginAuth(); +} + +void StaLink::handleAuth(const uint8_t *f, size_t len) { + if (state_.load() != State::Authenticating || len < 24 + 6) return; + const uint16_t alg = f[24] | (f[25] << 8); + const uint16_t seq = f[26] | (f[27] << 8); + const uint16_t status = f[28] | (f[29] << 8); + if (alg != 0 || seq != 2) return; + if (status != 0) { + __android_log_print(ANDROID_LOG_WARN, TAG, "authentication refused, status %u", status); + setState(State::Searching); + return; + } + setState(State::Associating); + attempts_ = 1; + last_tx_attempt_ = Clock::now(); + sendAssocRequest(); +} + +void StaLink::handleAssocResp(const uint8_t *f, size_t len) { + if (state_.load() != State::Associating || len < 24 + 6) return; + const uint16_t status = f[26] | (f[27] << 8); + if (status != 0) { + __android_log_print(ANDROID_LOG_WARN, TAG, "association refused, status %u", status); + setState(State::Searching); + return; + } + aid_ = (f[28] | (f[29] << 8)) & 0x3fff; + setState(State::Connected); + __android_log_print(ANDROID_LOG_INFO, TAG, "associated, AID %u", aid_); + // Announce our IP right away (gratuitous ARP), then periodically from tick(). + last_garp_ = Clock::now(); + sendArp(1, kZeroMac, cfg_.own_ip.data(), kBroadcast); +} + +void StaLink::handleData(const uint8_t *f, size_t len, uint8_t rssi_a, uint8_t rssi_b) { + const uint8_t fc0 = f[0]; + const uint8_t fc1 = f[1]; + // Only AP -> station frames: FromDS set, ToDS clear. + if ((fc1 & 0x03) != 0x02) return; + const uint8_t *addr1 = f + 4; + const uint8_t *addr3 = f + 16; + const bool unicast = macEq(addr1, cfg_.own_mac.data()); + if (!unicast && !(addr1[0] & 0x01)) return; + + const uint8_t subtype = fc0 >> 4; + if (subtype & 0x04) return; // Null / QoS Null: no payload + const bool qos = subtype & 0x08; + size_t hlen = 24; + if (qos) hlen += 2; + if (qos && (fc1 & 0x80)) hlen += 4; // +HTC + if (len < hlen + 8) return; + + SignalQualityCalculator::get_instance().add_rssi(rssi_a, rssi_b); + + const uint8_t frag = f[22] & 0x0f; + if (fc1 & 0x40) { + // Protected: only open networks are supported. + sum_protected_++; + rx_dropped_++; + return; + } + if ((fc1 & 0x04) || frag != 0) { + // 802.11 fragmentation (the AP's fragmentation threshold): not reassembled. + sum_mac_fragments_++; + if (!warned_mac_fragments_) { + warned_mac_fragments_ = true; + __android_log_print(ANDROID_LOG_WARN, + TAG, + "AP sends 802.11 fragments (frag %u, len %zu); these are dropped. " + "Check the camera's fragmentation threshold (iw phy ... set frag off).", + frag, + len); + } + rx_dropped_++; + return; + } + if (unicast) { + const int seq = (f[22] | (f[23] << 8)) >> 4; + if ((fc1 & 0x08) && seq == last_rx_seq_) return; // retransmission of what we already have + last_rx_seq_ = seq; + } + rx_data_++; + sum_data_++; + + const uint8_t *p = f + hlen; + size_t plen = len - hlen; + const bool amsdu = qos && (f[24] & 0x80); + if (!amsdu) { + if (std::memcmp(p, kLlcSnap, 6) != 0) { + sum_not_ipv4_++; + rx_dropped_++; + return; + } + handleMsdu((p[6] << 8) | p[7], p + 8, plen - 8, addr3); + return; + } + // A-MSDU: subframes of DA(6) SA(6) length(2) + LLC payload, each padded to 4 bytes. + while (plen >= 14) { + const uint8_t *sa = p + 6; + const size_t sublen = (p[12] << 8) | p[13]; + if (sublen + 14 > plen || sublen < 8) break; + const uint8_t *sub = p + 14; + if (std::memcmp(sub, kLlcSnap, 6) == 0) handleMsdu((sub[6] << 8) | sub[7], sub + 8, sublen - 8, sa); + size_t step = 14 + sublen; + step = (step + 3) & ~size_t(3); + if (step >= plen) break; + p += step; + plen -= step; + } +} + +void StaLink::handleMsdu(uint16_t ethertype, const uint8_t *payload, size_t len, const uint8_t *src_mac) { + if (ethertype == kEthArp) { + handleArp(payload, len); + } else if (ethertype == kEthIpv4) { + handleIpv4(payload, len, src_mac); + } else { + sum_not_ipv4_++; // IPv6 and the like: nothing here uses them + } +} + +// Called with mu_ held. RFC 791 reassembly, kept small: at most 8 datagrams in +// flight, each dropped 1 s after its first fragment. Overlapping fragments are +// not expected from a single sender and are not handled specially. +std::vector StaLink::reassemble(const uint8_t *ip, size_t ihl, size_t len) { + const auto now = Clock::now(); + frags_.erase(std::remove_if(frags_.begin(), frags_.end(), + [now](const IpFragments &f) { return now - f.start > 1s; }), + frags_.end()); + + uint32_t src; + std::memcpy(&src, ip + 12, 4); + const uint16_t id = (ip[4] << 8) | ip[5]; + const uint16_t fo = (ip[6] << 8) | ip[7]; + const bool more = fo & 0x2000; + const size_t off = static_cast(fo & 0x1fff) * 8; + const size_t plen = len - ihl; + if (off + plen + ihl > 65535) return {}; + + auto it = std::find_if(frags_.begin(), frags_.end(), + [src, id](const IpFragments &f) { return f.src == src && f.id == id; }); + if (it == frags_.end()) { + if (frags_.size() >= 8) frags_.erase(frags_.begin()); + frags_.push_back(IpFragments{src, id, {}, {}, 0, 0, now}); + it = frags_.end() - 1; + } + if (it->payload.size() < off + plen) it->payload.resize(off + plen); + std::memcpy(it->payload.data() + off, ip + ihl, plen); + it->received += plen; + if (off == 0) it->header.assign(ip, ip + ihl); + if (!more) it->total = off + plen; + if (it->total == 0 || it->header.empty() || it->received < it->total) return {}; + + std::vector whole(it->header); + whole.insert(whole.end(), it->payload.begin(), it->payload.begin() + it->total); + const size_t total_len = whole.size(); + whole[2] = static_cast(total_len >> 8); + whole[3] = static_cast(total_len & 0xff); + whole[6] = whole[7] = 0; // no longer a fragment + frags_.erase(it); + return whole; +} + +void StaLink::handleArp(const uint8_t *a, size_t len) { + if (len < 28) return; + // Ethernet / IPv4 only. + if (a[0] != 0 || a[1] != 1 || a[2] != 0x08 || a[3] != 0x00 || a[4] != 6 || a[5] != 4) return; + const uint16_t oper = (a[6] << 8) | a[7]; + const uint8_t *sha = a + 8; + const uint8_t *spa = a + 14; + const uint8_t *tpa = a + 24; + learnMac(spa, sha); + if (oper != 1) return; + if (std::memcmp(tpa, cfg_.own_ip.data(), 4) == 0) { + sendArp(2, sha, spa, sha); + return; + } + // Someone looks for an address that is not ours. If that is the camera trying to + // reach its stream target, the phone IP in the AP settings is wrong. + uint32_t target; + std::memcpy(&target, tpa, 4); + if (warned_arp_targets_.size() < 16 && warned_arp_targets_.insert(target).second) { + __android_log_print(ANDROID_LOG_WARN, + TAG, + "%d.%d.%d.%d asks for %d.%d.%d.%d (our IP is %d.%d.%d.%d). " + "If that is the camera's stream target, set it as the phone IP.", + spa[0], spa[1], spa[2], spa[3], + tpa[0], tpa[1], tpa[2], tpa[3], + cfg_.own_ip[0], cfg_.own_ip[1], cfg_.own_ip[2], cfg_.own_ip[3]); + } +} + +void StaLink::handleIpv4(const uint8_t *ip, size_t len, const uint8_t *src_mac) { + if (len < 20 || (ip[0] >> 4) != 4) return; + const size_t ihl = (ip[0] & 0x0f) * 4; + const size_t total = (ip[2] << 8) | ip[3]; + if (ihl < 20 || total < ihl || total > len) return; + len = total; // drop link-layer padding + + const uint8_t *dst = ip + 16; + const bool for_us = std::memcmp(dst, cfg_.own_ip.data(), 4) == 0; + const bool bcast = std::memcmp(dst, kBroadcast, 4) == 0; + if (!for_us && !bcast) return; + + const uint16_t frag = (ip[6] << 8) | ip[7]; + if (frag & 0x3fff) { + // More-fragments flag or a non-zero offset: datagrams larger than the MTU, + // e.g. when the sender's payload size exceeds ~1470 bytes. + sum_fragments_++; + std::vector whole = reassemble(ip, ihl, len); + if (whole.empty()) return; + sum_reassembled_++; + handleIpv4(whole.data(), whole.size(), src_mac); + return; + } + + const uint8_t proto = ip[9]; + const uint8_t *l4 = ip + ihl; + const size_t l4len = len - ihl; + if (for_us) learnMac(ip + 12, src_mac); + + if (proto == 6 && for_us) { + handleTcp(ip, ihl, len); + return; + } + + if (proto == 17 && l4len >= 8) { // UDP + const uint16_t dport = (l4[2] << 8) | l4[3]; + const size_t ulen = (l4[4] << 8) | l4[5]; + if (ulen < 8 || ulen > l4len) return; + if (for_us) { + udp_by_port_[dport]++; + if (seen_udp_ports_.size() < 32 && seen_udp_ports_.insert(dport).second) { + __android_log_print(ANDROID_LOG_INFO, + TAG, + "first UDP datagram for port %d: from %d.%d.%d.%d:%d, %zu bytes", + dport, + ip[12], ip[13], ip[14], ip[15], + (l4[0] << 8) | l4[1], + ulen - 8); + } + } + bool forwarded = false; + for (const auto &fwd : cfg_.forward_ports) { + if (fwd.first != dport) continue; + forwarded = true; + if (!have_camera_ip_ && for_us) { + have_camera_ip_ = true; + std::memcpy(camera_ip_.data(), ip + 12, 4); + __android_log_print(ANDROID_LOG_INFO, + TAG, + "receiving UDP from %d.%d.%d.%d:%d on port %d", + ip[12], ip[13], ip[14], ip[15], + (l4[0] << 8) | l4[1], + dport); + } + if (udp_sock_ >= 0) { + sockaddr_in to{}; + to.sin_family = AF_INET; + to.sin_port = htons(fwd.second); + to.sin_addr.s_addr = htonl(INADDR_LOOPBACK); + sendto(udp_sock_, l4 + 8, ulen - 8, MSG_DONTWAIT, (sockaddr *)&to, sizeof(to)); + } + rx_udp_++; + break; + } + if (!forwarded && for_us && warned_ports_.size() < 16 && warned_ports_.insert(dport).second) { + __android_log_print(ANDROID_LOG_WARN, + TAG, + "UDP from %d.%d.%d.%d to port %d is not forwarded (video port setting?)", + ip[12], ip[13], ip[14], ip[15], + dport); + } + } else if (proto == 1 && for_us && l4len >= 8 && l4[0] == 8) { // ICMP echo request + std::vector r(ip, ip + len); + std::memcpy(r.data() + 16, ip + 12, 4); // dst = original src + std::memcpy(r.data() + 12, cfg_.own_ip.data(), 4); + r[8] = 64; // TTL + r[10] = r[11] = 0; + uint16_t ic = csum16(r.data(), ihl); + r[10] = ic >> 8; + r[11] = ic & 0xff; + r[ihl] = 0; // echo reply + r[ihl + 2] = r[ihl + 3] = 0; + uint16_t cc = csum16(r.data() + ihl, l4len); + r[ihl + 2] = cc >> 8; + r[ihl + 3] = cc & 0xff; + sendData(src_mac, kEthIpv4, r.data(), r.size()); + } +} + +// --------------------------------------------------------------------------- +// Minimal TCP client, for the camera's control port. One connection at a time, +// in-order receive only, fixed retransmission timer. Called with mu_ held unless +// noted. +// --------------------------------------------------------------------------- + +void StaLink::learnMac(const uint8_t *ip, const uint8_t *mac) { + if (ipKey(ip) == 0 || (mac[0] & 0x01)) return; + if (arp_cache_.size() > 64) arp_cache_.clear(); + auto &slot = arp_cache_[ipKey(ip)]; + std::memcpy(slot.data(), mac, 6); + if (tcp_.st == TcpSession::St::Resolving && std::memcmp(tcp_.ip.data(), ip, 4) == 0) { + tcp_.mac = slot; + tcp_.st = TcpSession::St::SynSent; + tcp_.retries = 0; + tcp_.last_tx = Clock::now(); + tcpSend(kTcpSyn, tcp_.iss, nullptr, 0); + } +} + +// Not called with mu_ held: blocks until the exchange is over. +bool StaLink::tcpRequest(const std::array &ip, + uint16_t port, + const std::string &request, + std::chrono::milliseconds timeout, + bool half_close, + std::string &reply) { + using St = TcpSession::St; + std::unique_lock lock(mu_); + if (!running_ || state_.load() != State::Connected || tcp_.st != St::Idle) return false; + + std::random_device rd; + TcpSession s; + s.ip = ip; + s.rport = port; + s.lport = static_cast(49152 + rd() % 16000); + s.iss = rd(); + s.snd_nxt = s.iss + 1; + s.half_close = half_close; + s.data.assign(request.begin(), request.end()); + s.deadline = Clock::now() + timeout; + s.last_tx = Clock::now(); + tcp_ = std::move(s); + + __android_log_print(ANDROID_LOG_INFO, + TAG, + "TCP %d.%d.%d.%d:%d <- \"%s\"", + ip[0], ip[1], ip[2], ip[3], + port, + request.c_str()); + auto known = arp_cache_.find(ipKey(ip.data())); + if (known != arp_cache_.end()) { + tcp_.mac = known->second; + tcp_.st = St::SynSent; + tcpSend(kTcpSyn, tcp_.iss, nullptr, 0); + } else { + tcp_.st = St::Resolving; + sendArp(1, kZeroMac, ip.data(), kBroadcast); + } + + // tcpTick() enforces the deadline; the extra margin only guards against a worker that died. + const auto done = [this] { return tcp_.st == St::Done || tcp_.st == St::Failed; }; + if (!tcp_cv_.wait_until(lock, tcp_.deadline + 2s, done)) tcpFinish(false); + + reply = tcp_.rx; + __android_log_print(ANDROID_LOG_INFO, + TAG, + "TCP %d.%d.%d.%d:%d -> %s \"%s\"", + ip[0], ip[1], ip[2], ip[3], + port, + tcp_.st == St::Done ? "done" : "failed", + reply.c_str()); + tcp_.st = St::Idle; + return !reply.empty(); +} + +void StaLink::tcpFinish(bool ok) { + using St = TcpSession::St; + if (tcp_.st == St::Idle || tcp_.st == St::Done || tcp_.st == St::Failed) return; + if (tcp_.st == St::Established && !tcp_.half_close) { + // Close our side (a half-closing request already sent its FIN with the data); + // whatever the peer still sends is ignored from here on. + tcpSend(kTcpFin | kTcpAck, tcp_.snd_nxt + static_cast(tcp_.data.size()), nullptr, 0); + } + tcp_.st = ok ? St::Done : St::Failed; + tcp_cv_.notify_all(); +} + +void StaLink::tcpTick(Clock::time_point now) { + using St = TcpSession::St; + if (tcp_.st != St::Resolving && tcp_.st != St::SynSent && tcp_.st != St::Established) return; + if (now >= tcp_.deadline) { + __android_log_print(ANDROID_LOG_WARN, TAG, "TCP request timed out"); + tcpFinish(!tcp_.rx.empty()); + return; + } + if (tcp_.st == St::Established && !tcp_.rx.empty() && now - tcp_.last_rx >= kTcpReplyIdle) { + tcpFinish(true); + return; + } + if (now - tcp_.last_tx < kTcpRetransmit) return; + tcp_.last_tx = now; + tcp_.retries++; + switch (tcp_.st) { + case St::Resolving: + sendArp(1, kZeroMac, tcp_.ip.data(), kBroadcast); + break; + case St::SynSent: + tcpSend(kTcpSyn, tcp_.iss, nullptr, 0); + break; + case St::Established: + if (!tcp_.data_acked) { + tcpSend(kTcpAck | kTcpPsh | (tcp_.half_close ? kTcpFin : 0), + tcp_.snd_nxt, + tcp_.data.data(), + tcp_.data.size()); + } + break; + default: + break; + } +} + +void StaLink::handleTcp(const uint8_t *ip, size_t ihl, size_t len) { + using St = TcpSession::St; + if (tcp_.st != St::SynSent && tcp_.st != St::Established) return; + if (len < ihl + 20 || std::memcmp(ip + 12, tcp_.ip.data(), 4) != 0) return; + const uint8_t *t = ip + ihl; + const uint16_t sport = (t[0] << 8) | t[1]; + const uint16_t dport = (t[2] << 8) | t[3]; + if (sport != tcp_.rport || dport != tcp_.lport) return; + const uint32_t seq = rd32(t + 4); + const uint32_t ack = rd32(t + 8); + const size_t doff = (t[12] >> 4) * 4; + const uint8_t flags = t[13]; + if (doff < 20 || ihl + doff > len) return; + const uint8_t *payload = t + doff; + const size_t plen = len - ihl - doff; + const auto now = Clock::now(); + const uint32_t data_end = tcp_.snd_nxt + static_cast(tcp_.data.size()); + // Our next sequence number once the request is out: a half-closing request's FIN + // takes one more. + const uint32_t our_end = data_end + (tcp_.half_close ? 1 : 0); + + if (flags & kTcpRst) { + __android_log_print(ANDROID_LOG_WARN, TAG, "TCP connection reset by the peer"); + tcp_.st = tcp_.rx.empty() ? St::Failed : St::Done; + tcp_cv_.notify_all(); + return; + } + + if (tcp_.st == St::SynSent) { + if ((flags & (kTcpSyn | kTcpAck)) != (kTcpSyn | kTcpAck) || ack != tcp_.iss + 1) return; + tcp_.rcv_nxt = seq + 1; + tcp_.st = St::Established; + tcp_.retries = 0; + tcp_.last_tx = now; + // The ACK of the handshake rides on the request. A half-closing request ends + // our side right away, like `echo cmd | nc` does: some line servers only act + // on end of input. + tcpSend(kTcpAck | kTcpPsh | (tcp_.half_close ? kTcpFin : 0), + tcp_.snd_nxt, + tcp_.data.data(), + tcp_.data.size()); + return; + } + + if ((flags & kTcpAck) && static_cast(ack - our_end) >= 0) tcp_.data_acked = true; + if (plen > 0 && seq == tcp_.rcv_nxt) { + tcp_.rx.append(reinterpret_cast(payload), plen); + tcp_.rcv_nxt += static_cast(plen); + tcp_.last_rx = now; + } + if ((flags & kTcpFin) && seq + plen == tcp_.rcv_nxt) { + // Peer is done: acknowledge its FIN, together with ours unless that went + // out with the request. + tcp_.rcv_nxt += 1; + if (tcp_.half_close) { + tcpSend(kTcpAck, our_end, nullptr, 0); + } else { + tcpSend(kTcpFin | kTcpAck, data_end, nullptr, 0); + } + tcp_.st = St::Done; + tcp_cv_.notify_all(); + return; + } + // Acknowledge new data, and re-acknowledge duplicates or out-of-order segments + // so the peer retransmits from rcv_nxt. + if (plen > 0) tcpSend(kTcpAck, our_end, nullptr, 0); +} + +void StaLink::tcpSend(uint8_t flags, uint32_t seq, const uint8_t *data, size_t len) { + const bool syn = flags & kTcpSyn; + const size_t hlen = syn ? 24 : 20; + std::vector t(hlen + len, 0); + t[0] = tcp_.lport >> 8; + t[1] = tcp_.lport & 0xff; + t[2] = tcp_.rport >> 8; + t[3] = tcp_.rport & 0xff; + wr32(&t[4], seq); + wr32(&t[8], (flags & kTcpAck) ? tcp_.rcv_nxt : 0); + t[12] = static_cast((hlen / 4) << 4); + t[13] = flags; + t[14] = 0x20; // window 8192 + t[15] = 0x00; + if (syn) { + // MSS 1460 + t[20] = 2; + t[21] = 4; + t[22] = 0x05; + t[23] = 0xb4; + } + if (len) std::memcpy(&t[hlen], data, len); + + uint8_t pseudo[12]; + std::memcpy(pseudo, cfg_.own_ip.data(), 4); + std::memcpy(pseudo + 4, tcp_.ip.data(), 4); + pseudo[8] = 0; + pseudo[9] = 6; + pseudo[10] = static_cast(t.size() >> 8); + pseudo[11] = static_cast(t.size() & 0xff); + const uint16_t c = csumFold(csumAdd(csumAdd(0, pseudo, sizeof(pseudo)), t.data(), t.size())); + t[16] = c >> 8; + t[17] = c & 0xff; + sendIpv4(tcp_.mac.data(), tcp_.ip.data(), 6, t); +} + +void StaLink::sendIpv4(const uint8_t *dst_mac, const uint8_t *dst_ip, uint8_t proto, const std::vector &l4) { + std::vector p(20 + l4.size(), 0); + const size_t total = p.size(); + ip_id_++; + p[0] = 0x45; + p[2] = static_cast(total >> 8); + p[3] = static_cast(total & 0xff); + p[4] = ip_id_ >> 8; + p[5] = ip_id_ & 0xff; + p[6] = 0x40; // don't fragment + p[8] = 64; // TTL + p[9] = proto; + std::memcpy(&p[12], cfg_.own_ip.data(), 4); + std::memcpy(&p[16], dst_ip, 4); + const uint16_t c = csum16(p.data(), 20); + p[10] = c >> 8; + p[11] = c & 0xff; + std::memcpy(&p[20], l4.data(), l4.size()); + sendData(dst_mac, kEthIpv4, p.data(), p.size()); +} + +// --------------------------------------------------------------------------- +// TX frame builders. All are called with mu_ held. +// --------------------------------------------------------------------------- + +uint16_t StaLink::nextSeq() { + seq_ = (seq_ + 1) & 0x0fff; + return seq_ << 4; +} + +std::vector StaLink::mgmtHeader(uint8_t fc0, const uint8_t *addr1, const uint8_t *addr3) { + const uint8_t *own = cfg_.own_mac.data(); + const uint16_t seq = nextSeq(); + return {fc0, 0x00, 0x00, 0x00, addr1[0], addr1[1], addr1[2], + addr1[3], addr1[4], addr1[5], own[0], own[1], own[2], own[3], + own[4], own[5], addr3[0], addr3[1], addr3[2], addr3[3], addr3[4], + addr3[5], (uint8_t)(seq & 0xff), (uint8_t)(seq >> 8)}; +} + +// Supported Rates (max 8) + Extended Supported Rates. Mirrors the AP's own set +// once we know it, otherwise a band-correct default (no CCK on 5 GHz). +void StaLink::appendRates(std::vector &m) const { + std::vector rates = ap_rates_; + if (rates.empty()) { + if (channel_ <= 14) { + rates = {0x82, 0x84, 0x8b, 0x96, 0x0c, 0x12, 0x18, 0x24, 0x30, 0x48, 0x60, 0x6c}; + } else { + rates = {0x8c, 0x12, 0x98, 0x24, 0xb0, 0x48, 0x60, 0x6c}; + } + } + const size_t first = std::min(rates.size(), 8); + m.push_back(1); + m.push_back((uint8_t)first); + m.insert(m.end(), rates.begin(), rates.begin() + first); + if (rates.size() > first) { + m.push_back(50); + m.push_back((uint8_t)(rates.size() - first)); + m.insert(m.end(), rates.begin() + first, rates.end()); + } +} + +void StaLink::sendProbeRequest() { + std::vector m = mgmtHeader(0x40, kBroadcast, kBroadcast); + m.push_back(0); + m.push_back((uint8_t)cfg_.ssid.size()); + m.insert(m.end(), cfg_.ssid.begin(), cfg_.ssid.end()); + appendRates(m); + enqueue(std::move(m)); +} + +void StaLink::sendAuth() { + std::vector m = mgmtHeader(0xb0, bssid_.data(), bssid_.data()); + m.insert(m.end(), {0x00, 0x00, 0x01, 0x00, 0x00, 0x00}); // open system, seq 1, status 0 + enqueue(std::move(m)); +} + +void StaLink::sendAssocRequest() { + std::vector m = mgmtHeader(0x00, bssid_.data(), bssid_.data()); + // Capability: ESS, plus short preamble / short slot on 2.4 GHz. + const uint16_t cap = 0x0001 | (channel_ <= 14 ? 0x0420 : 0x0000); + m.insert(m.end(), {(uint8_t)(cap & 0xff), (uint8_t)(cap >> 8), 0x0a, 0x00}); // listen interval 10 + m.push_back(0); + m.push_back((uint8_t)cfg_.ssid.size()); + m.insert(m.end(), cfg_.ssid.begin(), cfg_.ssid.end()); + appendRates(m); + // Capabilities are advertised up to the configured width, not the AP's current + // one, so a later switch to a wider channel is used without re-associating. + const bool wide5g = channel_ > 14; + const bool can40 = wide5g && cfg_.max_width_mhz >= 40; + const bool can80 = wide5g && cfg_.max_width_mhz >= 80 && ap_vht_; + if (ap_ht_) { + // HT Capabilities: SM power save disabled, short GI 20, RX MCS 0-7, plus + // 20/40 MHz and short GI 40 when allowed. Lets the AP use HT rates towards us. + uint16_t ht_cap = 0x002c; + if (can40) ht_cap |= 0x0002 | 0x0040; + m.insert(m.end(), {45, 26, static_cast(ht_cap & 0xff), static_cast(ht_cap >> 8), 0x03}); + uint8_t mcs[16] = {0xff}; + m.insert(m.end(), mcs, mcs + 16); + m.insert(m.end(), {0, 0, 0, 0, 0, 0, 0}); + } + if (can80) { + // VHT Capabilities: max MPDU 3895, 80 MHz, short GI 80; RX/TX 1SS MCS 0-7 + // (MCS map 0xfffc: 1SS = 0-7, other streams not supported). + m.insert(m.end(), {191, 12, 0x20, 0x00, 0x00, 0x00, 0xfc, 0xff, 0x00, 0x00, 0xfc, 0xff, 0x00, 0x00}); + } + if (ap_wmm_) { + // WMM Information Element. hostapd only enables HT for WMM stations. + m.insert(m.end(), {221, 7, 0x00, 0x50, 0xf2, 0x02, 0x00, 0x01, 0x00}); + } + enqueue(std::move(m)); +} + +void StaLink::sendArp(uint16_t oper, const uint8_t *target_mac, const uint8_t *target_ip, const uint8_t *dst_mac) { + const uint8_t *own = cfg_.own_mac.data(); + const uint8_t *ip = cfg_.own_ip.data(); + const uint8_t a[28] = {0x00, 0x01, 0x08, 0x00, 6, + 4, (uint8_t)(oper >> 8), (uint8_t)(oper & 0xff), own[0], + own[1], own[2], own[3], own[4], own[5], + ip[0], ip[1], ip[2], ip[3], target_mac[0], + target_mac[1], target_mac[2], target_mac[3], target_mac[4], target_mac[5], + target_ip[0], target_ip[1], target_ip[2], target_ip[3]}; + sendData(dst_mac, kEthArp, a, sizeof(a)); +} + +void StaLink::sendData(const uint8_t *dst_mac, uint16_t ethertype, const uint8_t *payload, size_t len) { + // ToDS data frame: addr1 = BSSID, addr2 = us, addr3 = final destination. + std::vector m = mgmtHeader(0x08, bssid_.data(), dst_mac); + m[1] = 0x01; + m.insert(m.end(), kLlcSnap, kLlcSnap + 6); + m.push_back((uint8_t)(ethertype >> 8)); + m.push_back((uint8_t)(ethertype & 0xff)); + m.insert(m.end(), payload, payload + len); + enqueue(std::move(m)); +} + +void StaLink::enqueue(std::vector mpdu) { + std::vector frame; + frame.reserve(radiotap_.size() + mpdu.size()); + frame.insert(frame.end(), radiotap_.begin(), radiotap_.end()); + frame.insert(frame.end(), mpdu.begin(), mpdu.end()); + { + std::lock_guard lk(q_mu_); + if (tx_queue_.size() < 128) tx_queue_.push_back(std::move(frame)); + } + q_cv_.notify_all(); +} diff --git a/app/wfbngrtl8812/src/main/cpp/StaLink.h b/app/wfbngrtl8812/src/main/cpp/StaLink.h new file mode 100644 index 00000000..8e6a9417 --- /dev/null +++ b/app/wfbngrtl8812/src/main/cpp/StaLink.h @@ -0,0 +1,262 @@ +#ifndef PIXELPILOT_STA_LINK_H +#define PIXELPILOT_STA_LINK_H + +// AP mode: devourer acts as a regular Wi-Fi station (client) and joins an OPEN +// access point, typically the camera itself. The station logic lives here in +// userspace - probe/auth/assoc, ARP, ICMP echo - on top of devourer's raw +// injection and monitor RX. The chip's hardware ACK responder is armed for our +// own MAC, so the AP sees every unicast frame to us acknowledged within SIFS. +// +// Data path: the camera pushes RTP/UDP to our static IP. UDP datagrams for the +// configured ports are handed to 127.0.0.1, where the video player (5600) and +// the MAVLink reader (14550) already listen in WFB-NG mode, so nothing above +// this layer needs to know which mode is active. +// +// Control path: one short TCP request/response at a time (tcpRequest), enough to +// talk to the camera's air_man_ap control port (e.g. "set_ap_channel 161"). +// +// Out of scope: encryption (WEP/WPA), DHCP, IP fragments, general TCP. + +#include "devourer/src/IRtlDevice.h" +#include "devourer/src/RxPacket.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +struct StaConfig { + std::string ssid; + std::array own_mac{}; + // IPv4 address in network byte order. + std::array own_ip{}; + // Where to look first; channel switch announcements from the AP are followed. + uint8_t channel{0}; + // Checked in turn when the SSID is not heard on `channel`, e.g. the channels + // the camera passes through while booting. + std::vector candidate_channels; + // After the candidates, also sweep every other channel. + bool full_scan{false}; + // Widest channel the station may use (20, 40 or 80 MHz, 5 GHz only). It follows + // the AP's width up to this; 20 is the safe fallback. + int max_width_mhz{20}; + // UDP destination port on the air -> port on 127.0.0.1. + std::vector> forward_ports; +}; + +class StaLink { + public: + enum class State { Searching, Authenticating, Associating, Connected }; + + struct Stats { + uint32_t rx_data; // data frames from the AP addressed to us + uint32_t rx_udp; // UDP datagrams forwarded to localhost + uint32_t rx_dropped; // data frames we could not use (fragments, encrypted, ...) + uint32_t tx_frames; // frames handed to the driver + }; + + StaLink(IRtlDevice *dev, StaConfig cfg); + ~StaLink(); + + void start(); + void stop(); + + // Called from devourer's RX thread for every received frame. + void onRx(const Packet &packet); + + State state() const { return state_.load(); } + // Counters since the previous call. + Stats takeStats(); + // Human readable one-liner for the UI. + std::string statusText() const; + // The channel the AP is on while connected, 0 otherwise. + uint8_t connectedChannel() const; + // Counts every time video can flow again: each (re)association and each end of a + // dropout. The UI requests a key frame when it changes, so the decoder does not + // wait for the next scheduled one. + uint32_t recoveries() const { return recoveries_.load(); } + + // Opens a TCP connection to ip:port, sends `request` and collects the reply + // until the peer closes, goes quiet after answering, or `timeout` passes. + // half_close ends our sending side with the request (as `echo cmd | nc` does). + // Blocks the caller (not an RX or worker thread). Returns false when not + // connected, another request is running, or nothing came back. + bool tcpRequest(const std::array &ip, + uint16_t port, + const std::string &request, + std::chrono::milliseconds timeout, + bool half_close, + std::string &reply); + + private: + using Clock = std::chrono::steady_clock; + + // Minimal TCP client: one connection, in-order receive only, retransmits on a timer. + struct TcpSession { + enum class St { Idle, Resolving, SynSent, Established, Done, Failed } st{St::Idle}; + std::array ip{}; + std::array mac{}; + uint16_t lport{0}, rport{0}; + uint32_t iss{0}, snd_nxt{0}, rcv_nxt{0}; + std::vector data; // the request + bool data_acked{false}; + bool half_close{false}; // FIN goes out with the request + std::string rx; + int retries{0}; + Clock::time_point last_tx{}, last_rx{}, deadline{}; + }; + void tcpTick(Clock::time_point now); + void handleTcp(const uint8_t *ip, size_t ihl, size_t len); + void tcpSend(uint8_t flags, uint32_t seq, const uint8_t *data, size_t len); + void tcpFinish(bool ok); + // Wraps an L4 payload in IPv4 and sends it to dst_mac. + void sendIpv4(const uint8_t *dst_mac, const uint8_t *dst_ip, uint8_t proto, const std::vector &l4); + void learnMac(const uint8_t *ip, const uint8_t *mac); + + void worker(); + void tick(); + void setState(State s); + // Authenticate to the known BSSID on the current channel right away - no search. + void beginAuth(); + // The AP no longer has us associated (deauth, restart): rejoin it where it is. + void reconnect(const char *why); + // Once a second: frame rate, and while searching a log line listing what is on the air. + void report(Clock::time_point now); + + void handleBeacon(const uint8_t *frame, size_t len, uint8_t rssi); + // Channel Switch Announcement / Extended CSA elements from our AP. + void handleCsa(const uint8_t *ies, size_t len); + // The width to run at for the AP's HT/VHT operation elements (capped by the + // config and devourer's limits); offset is devourer's 1 = HT40+, 2 = HT40-. + int desiredWidth(const uint8_t *ies, size_t len, uint8_t &offset) const; + // Queue a retune for the worker (which runs it outside mu_). + void requestTune(int channel, int width_mhz, uint8_t offset); + // Channels to scan, starting at home_channel_. + void buildScanList(); + void handleAuth(const uint8_t *frame, size_t len); + void handleAssocResp(const uint8_t *frame, size_t len); + void handleData(const uint8_t *frame, size_t len, uint8_t rssi_a, uint8_t rssi_b); + void handleMsdu(uint16_t ethertype, const uint8_t *payload, size_t len, const uint8_t *src_mac); + void handleArp(const uint8_t *arp, size_t len); + void handleIpv4(const uint8_t *ip, size_t len, const uint8_t *src_mac); + + void sendProbeRequest(); + void sendAuth(); + void sendAssocRequest(); + void sendArp(uint16_t oper, const uint8_t *target_mac, const uint8_t *target_ip, const uint8_t *dst_mac); + void sendData(const uint8_t *dst_mac, uint16_t ethertype, const uint8_t *payload, size_t len); + + std::vector mgmtHeader(uint8_t fc0, const uint8_t *addr1, const uint8_t *addr3); + void appendRates(std::vector &m) const; + uint16_t nextSeq(); + void enqueue(std::vector mpdu); + + IRtlDevice *dev_; + const StaConfig cfg_; + std::vector radiotap_; + int udp_sock_{-1}; + + std::atomic state_{State::Searching}; + std::atomic running_{false}; + std::thread worker_; + + // Protects everything below that the RX thread and the worker share. + mutable std::mutex mu_; + uint8_t channel_; // what the radio is tuned to (the primary 20 MHz) + int width_mhz_{20}; // tuned width + uint8_t chan_offset_{0}; // tuned 40 MHz secondary: 0 none, 1 above, 2 below + uint8_t home_channel_; // where the AP was last found; scans start here + std::vector scan_list_; // scan_list_[0] == home_channel_ + size_t scan_index_{0}; + Clock::time_point dwell_start_{}; + int retune_to_{-1}; // set via requestTune(), carried out by the worker outside mu_ + int retune_width_{20}; + uint8_t retune_offset_{0}; + Clock::time_point last_width_change_{}; + bool ap_vht_{false}; // AP advertises VHT capabilities (needed for 80 MHz) + int csa_channel_{-1}; // announced by the AP, switched to at csa_at_ + int csa_width_{20}; // ... at this width (from the CSA's width elements) + uint8_t csa_offset_{0}; + Clock::time_point csa_at_{}; + std::array bssid_{}; + std::vector ap_rates_; // AP's supported + extended rates (raw rate bytes) + bool ap_ht_{false}; + bool ap_wmm_{false}; + bool warned_encrypted_{false}; + int attempts_{0}; + Clock::time_point last_tx_attempt_{}; + Clock::time_point last_heard_{}; + Clock::time_point last_garp_{}; + uint16_t aid_{0}; + int last_rx_seq_{-1}; + uint8_t last_rssi_{0}; // raw chip value, same scale wfb-ng mode feeds the signal-quality calculator + bool in_gap_{false}; // connected, but nothing heard from the AP for kDropoutGap + uint64_t last_tsf_{0}; // AP's beacon timestamp; jumping back means the AP restarted + std::atomic recoveries_{0}; + + // Diagnostics, so a user can tell why nothing connects. + struct SeenNetwork { + std::string ssid; + int channel; + bool encrypted; + uint8_t rssi; + Clock::time_point last; + }; + std::map, SeenNetwork> seen_; // by BSSID, while searching + Clock::time_point last_report_{}; + Clock::time_point last_seen_log_{}; + uint32_t frames_at_last_report_{0}; + uint32_t frames_per_sec_{0}; + std::set warned_ports_; // UDP ports for us that nothing listens on + std::set warned_arp_targets_; // IPs the camera looks for that are not ours + bool have_camera_ip_{false}; + std::array camera_ip_{}; + std::atomic rx_frames_total_{0}; + + // IPv4 reassembly: a few datagrams in flight, keyed by source + IP id. + struct IpFragments { + uint32_t src; + uint16_t id; + std::vector header; // from the offset-0 fragment + std::vector payload; + size_t received{0}; + size_t total{0}; // known once the last fragment arrived + Clock::time_point start; + }; + std::vector frags_; + // Returns the complete datagram once all fragments are in, otherwise empty. + std::vector reassemble(const uint8_t *ip, size_t ihl, size_t len); + + // Per-5s traffic summary while connected (see report()). + std::map udp_by_port_; // UDP datagrams for us, by destination port + uint32_t sum_data_{0}, sum_fragments_{0}, sum_reassembled_{0}, sum_protected_{0}, sum_not_ipv4_{0}, + sum_mac_fragments_{0}; + bool warned_mac_fragments_{false}; + Clock::time_point last_traffic_log_{}; + std::set seen_udp_ports_; // first datagram per port is logged with its source + + std::map> arp_cache_; // IPv4 (as read from the wire) -> MAC + TcpSession tcp_; + std::condition_variable tcp_cv_; // waits on mu_ + uint16_t ip_id_{0}; + + std::mutex q_mu_; + std::condition_variable q_cv_; + std::vector> tx_queue_; + uint16_t seq_{0}; + + std::atomic rx_data_{0}; + std::atomic rx_udp_{0}; + std::atomic rx_dropped_{0}; + std::atomic tx_frames_{0}; +}; + +#endif // PIXELPILOT_STA_LINK_H diff --git a/app/wfbngrtl8812/src/main/cpp/WfbngLink.cpp b/app/wfbngrtl8812/src/main/cpp/WfbngLink.cpp index 2e374a7b..6645f351 100644 --- a/app/wfbngrtl8812/src/main/cpp/WfbngLink.cpp +++ b/app/wfbngrtl8812/src/main/cpp/WfbngLink.cpp @@ -125,6 +125,12 @@ int WfbngLink::run(JNIEnv *env, jobject context, jint wifiChannel, jint bw, jint // the heap fallback never triggers. Heap is the behavior every working // Android build has shipped. cfg.usb.rx_zerocopy = false; + const bool ap_mode = link_mode.load() == LinkMode::AP; + if (ap_mode) { + // A station's unicast frames to the AP are acknowledged, so let the chip + // retransmit until they are (wfb-ng mode relies on FEC and sends once). + cfg.tx.retry_limit = 7; + } rtl_devices[fd] = wifi_driver->CreateRtlDevice(dev_handle, ctx, nullptr, cfg); if (!rtl_devices.at(fd)) { @@ -141,6 +147,10 @@ int WfbngLink::run(JNIEnv *env, jobject context, jint wifiChannel, jint bw, jint return -1; } + if (ap_mode) { + return runStation(dev_handle, ctx, fd, wifiChannel); + } + uint8_t *video_channel_id_be8 = reinterpret_cast(&video_channel_id_be); uint8_t *udp_channel_id_be8 = reinterpret_cast(&udp_channel_id_be); uint8_t *mavlink_channel_id_be8 = reinterpret_cast(&mavlink_channel_id_be); @@ -265,6 +275,7 @@ int WfbngLink::run(JNIEnv *env, jobject context, jint wifiChannel, jint bw, jint if (dev) { dev->Stop(); } + releaseDevice(fd); libusb_release_interface(dev_handle, 0); libusb_exit(ctx); return -1; @@ -282,6 +293,7 @@ int WfbngLink::run(JNIEnv *env, jobject context, jint wifiChannel, jint bw, jint if (dev) { dev->Stop(); } + releaseDevice(fd); r = libusb_release_interface(dev_handle, 0); __android_log_print(ANDROID_LOG_DEBUG, TAG, "libusb_release_interface: %d", r); @@ -289,6 +301,84 @@ int WfbngLink::run(JNIEnv *env, jobject context, jint wifiChannel, jint bw, jint return 0; } +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; + + StaLink *sta = nullptr; + devourer::MacAddr own_mac; + { + std::lock_guard lock(sta_mutex); + if (sta_link) { + // One association per AP; a second adapter would only fight the first over the same MAC. + __android_log_print(ANDROID_LOG_WARN, TAG, "AP mode already runs on another adapter, ignoring fd=%d", fd); + } else if (sta_config.ssid.empty()) { + __android_log_print(ANDROID_LOG_ERROR, TAG, "AP mode without an SSID configured, not starting"); + } else { + StaConfig cfg = sta_config; + cfg.channel = static_cast(wifiChannel); + own_mac.bytes = cfg.own_mac; + sta_link = std::make_shared(dev, cfg); + sta = sta_link.get(); + } + } + + if (!sta) { + // Never brought up, so nothing to power down; same exit as an early stop in run(). + rtl_devices.erase(fd); + libusb_release_interface(dev_handle, 0); + libusb_exit(ctx); + return -1; + } + + int ret = 0; + try { + // The camera's AP is joined on its primary channel at 20 MHz; HT40 APs + // still talk to a 20 MHz-only station on the primary. + dev->InitWrite(SelectedChannel{ + .Channel = static_cast(wifiChannel), + .ChannelOffset = 0, + .ChannelWidth = CHANNEL_WIDTH_20, + }); + dev->SetTxPower(adaptive_tx_power); + + // The chip ACKs every unicast frame addressed to own_mac within SIFS, which a + // userspace stack could never do. Without it the AP retries each frame to us. + if (!dev->SetAckResponder(own_mac)) { + __android_log_print( + ANDROID_LOG_WARN, TAG, "adapter has no hardware ACK responder, the AP will retry every frame"); + } + + if (stop_requested(fd)) { + __android_log_print( + ANDROID_LOG_WARN, TAG, "stop requested for fd=%d during bring-up, not entering the rx loop", fd); + } else { + sta->start(); + // Blocking until stop() calls StopRxLoop(). + dev->StartRxLoop([sta](const Packet &packet) { sta->onRx(packet); }); + } + } catch (const std::runtime_error &error) { + __android_log_print(ANDROID_LOG_ERROR, TAG, "runtime_error: %s", error.what()); + ret = -1; + } + + // Stop before the device goes: a camera command may still hold a reference, + // and a stopped StaLink no longer touches the device. + sta->stop(); + { + std::lock_guard lock(sta_mutex); + sta_link.reset(); + } + + __android_log_print(ANDROID_LOG_DEBUG, TAG, "AP mode RX loop exited, releasing..."); + dev->Stop(); + releaseDevice(fd); + int r = libusb_release_interface(dev_handle, 0); + __android_log_print(ANDROID_LOG_DEBUG, TAG, "libusb_release_interface: %d", r); + libusb_exit(ctx); + return ret; +} + void WfbngLink::stop(JNIEnv *env, jobject context, jint fd) { // Recorded first, and whether or not the device exists yet: run() may not have got as far // as creating it, and StartRxLoop() would clear the flag set below anyway. @@ -392,23 +482,48 @@ extern "C" JNIEXPORT void JNICALL Java_com_openipc_wfbngrtl8812_WfbNgLink_native if (jcStatsConstructor == nullptr) { return; } - SignalQualityCalculator::get_instance().add_fec_data( - aggregator->count_p_all, aggregator->count_p_fec_recovered, aggregator->count_p_lost); - - auto quality = SignalQualityCalculator::get_instance().calculate_signal_quality(); - uint32_t avg_rssi_int = round(map_range(quality.quality, -1024.f, 1024.f, 0.f, 100.f)); - - auto stats = env->NewObject(jcStats, - jcStatsConstructor, - (jint)aggregator->count_p_all, - (jint)aggregator->count_p_dec_err, - (jint)(aggregator->count_p_all - aggregator->count_p_dec_err), - (jint)aggregator->count_p_fec_recovered, - (jint)aggregator->count_p_lost, - (jint)aggregator->count_p_bad, - (jint)aggregator->count_p_override, - (jint)aggregator->count_p_outgoing, - (jint)avg_rssi_int); + + jobject stats; + if (native(wfbngLinkN)->link_mode.load() == LinkMode::AP) { + // No FEC in AP mode: report data frames received, UDP datagrams handed to + // the player, frames we had to drop and frames sent, in the same slots. + StaLink::Stats st{}; + { + std::lock_guard lock(native(wfbngLinkN)->sta_mutex); + if (native(wfbngLinkN)->sta_link) st = native(wfbngLinkN)->sta_link->takeStats(); + } + auto quality = SignalQualityCalculator::get_instance().calculate_signal_quality(); + uint32_t avg_rssi_int = round(map_range(quality.quality, -1024.f, 1024.f, 0.f, 100.f)); + stats = env->NewObject(jcStats, + jcStatsConstructor, + (jint)st.rx_data, + (jint)0, + (jint)st.rx_udp, + (jint)0, + (jint)st.rx_dropped, + (jint)0, + (jint)0, + (jint)st.tx_frames, + (jint)avg_rssi_int); + } else { + SignalQualityCalculator::get_instance().add_fec_data( + aggregator->count_p_all, aggregator->count_p_fec_recovered, aggregator->count_p_lost); + + auto quality = SignalQualityCalculator::get_instance().calculate_signal_quality(); + uint32_t avg_rssi_int = round(map_range(quality.quality, -1024.f, 1024.f, 0.f, 100.f)); + + stats = env->NewObject(jcStats, + jcStatsConstructor, + (jint)aggregator->count_p_all, + (jint)aggregator->count_p_dec_err, + (jint)(aggregator->count_p_all - aggregator->count_p_dec_err), + (jint)aggregator->count_p_fec_recovered, + (jint)aggregator->count_p_lost, + (jint)aggregator->count_p_bad, + (jint)aggregator->count_p_override, + (jint)aggregator->count_p_outgoing, + (jint)avg_rssi_int); + } if (stats == nullptr) { return; } @@ -427,8 +542,143 @@ extern "C" JNIEXPORT void JNICALL Java_com_openipc_wfbngrtl8812_WfbNgLink_native native(wfbngLinkN)->initAgg(); } +extern "C" JNIEXPORT void JNICALL Java_com_openipc_wfbngrtl8812_WfbNgLink_nativeSetLinkMode(JNIEnv *env, + jclass clazz, + jlong wfbngLinkN, + jint mode) { + native(wfbngLinkN)->link_mode = (mode == static_cast(LinkMode::AP)) ? LinkMode::AP : LinkMode::WFB; +} + +// Returns false (and keeps the previous config) when the IP or MAC does not parse. +extern "C" JNIEXPORT jboolean JNICALL Java_com_openipc_wfbngrtl8812_WfbNgLink_nativeSetApConfig( + JNIEnv *env, + jclass clazz, + jlong wfbngLinkN, + jstring ssid, + jstring ownIp, + jstring ownMac, + jint videoPort, + jintArray candidateChannels, + jboolean fullScan, + jint maxWidthMhz) { + auto str = [env](jstring s) { + const char *c = env->GetStringUTFChars(s, nullptr); + std::string out(c ? c : ""); + if (c) env->ReleaseStringUTFChars(s, c); + return out; + }; + StaConfig cfg; + if (candidateChannels != nullptr) { + const jsize n = env->GetArrayLength(candidateChannels); + std::vector chans(n); + env->GetIntArrayRegion(candidateChannels, 0, n, chans.data()); + for (jint ch : chans) { + if (ch > 0 && ch < 200) cfg.candidate_channels.push_back(static_cast(ch)); + } + } + cfg.full_scan = fullScan; + cfg.max_width_mhz = (maxWidthMhz >= 80) ? 80 : (maxWidthMhz >= 40) ? 40 : 20; + cfg.ssid = str(ssid); + if (cfg.ssid.size() > 32) return JNI_FALSE; + if (inet_pton(AF_INET, str(ownIp).c_str(), cfg.own_ip.data()) != 1) return JNI_FALSE; + auto mac = devourer::parse_mac(str(ownMac)); + // Must be unicast: the ACK engine cannot match a group address. + if (!mac || (mac->bytes[0] & 0x01)) return JNI_FALSE; + cfg.own_mac = mac->bytes; + if (videoPort <= 0 || videoPort > 65535) return JNI_FALSE; + // The player and the MAVLink reader listen on fixed local ports. + cfg.forward_ports = {{static_cast(videoPort), 5600}, {14550, 14550}}; + + WfbngLink *link = native(wfbngLinkN); + std::lock_guard lock(link->sta_mutex); + link->sta_config = std::move(cfg); + return JNI_TRUE; +} + +// Bytes from the air (SSIDs of other networks, camera replies) are not guaranteed +// to be valid modified UTF-8, which NewStringUTF() requires (CheckJNI aborts on it). +static std::string printableAscii(std::string s) { + for (char &c : s) { + const auto u = static_cast(c); + if (u >= 0x80 || (u < 0x20 && c != '\n' && c != '\r' && c != '\t')) c = '?'; + } + return s; +} + +extern "C" JNIEXPORT jstring JNICALL Java_com_openipc_wfbngrtl8812_WfbNgLink_nativeGetApStatus(JNIEnv *env, + jclass clazz, + jlong wfbngLinkN) { + WfbngLink *link = native(wfbngLinkN); + std::string text; // empty: no adapter running in AP mode + { + std::lock_guard lock(link->sta_mutex); + if (link->sta_link) text = link->sta_link->statusText(); + } + return env->NewStringUTF(printableAscii(text).c_str()); +} + +extern "C" JNIEXPORT jint JNICALL Java_com_openipc_wfbngrtl8812_WfbNgLink_nativeGetApChannel(JNIEnv *env, + jclass clazz, + jlong wfbngLinkN) { + WfbngLink *link = native(wfbngLinkN); + std::lock_guard lock(link->sta_mutex); + return link->sta_link ? link->sta_link->connectedChannel() : 0; +} + +// Grows each time video can flow again in AP mode (association, end of a dropout). +extern "C" JNIEXPORT jint JNICALL Java_com_openipc_wfbngrtl8812_WfbNgLink_nativeGetApRecoveries(JNIEnv *env, + jclass clazz, + jlong wfbngLinkN) { + WfbngLink *link = native(wfbngLinkN); + std::lock_guard lock(link->sta_mutex); + return link->sta_link ? static_cast(link->sta_link->recoveries()) : 0; +} + +// Sends one line to a TCP service on the camera (air_man_ap on 12355) over the AP +// link and returns its reply, or null. Blocks: call from a background thread. +extern "C" JNIEXPORT jstring JNICALL Java_com_openipc_wfbngrtl8812_WfbNgLink_nativeCameraCommand( + JNIEnv *env, + jclass clazz, + jlong wfbngLinkN, + jstring cameraIp, + jint port, + jstring command, + jint timeoutMs, + jboolean halfClose) { + WfbngLink *link = native(wfbngLinkN); + std::shared_ptr sta; + { + std::lock_guard lock(link->sta_mutex); + sta = link->sta_link; + } + if (!sta) return nullptr; + + const char *ip_c = env->GetStringUTFChars(cameraIp, nullptr); + std::array ip{}; + const bool ip_ok = ip_c && inet_pton(AF_INET, ip_c, ip.data()) == 1; + if (ip_c) env->ReleaseStringUTFChars(cameraIp, ip_c); + const char *cmd_c = env->GetStringUTFChars(command, nullptr); + std::string request = cmd_c ? cmd_c : ""; + if (cmd_c) env->ReleaseStringUTFChars(command, cmd_c); + if (!ip_ok || port <= 0 || port > 65535 || request.empty()) return nullptr; + if (request.back() != '\n') request += '\n'; + + std::string reply; + if (!sta->tcpRequest( + ip, static_cast(port), request, std::chrono::milliseconds(timeoutMs), halfClose, reply)) { + return nullptr; + } + return env->NewStringUTF(printableAscii(reply).c_str()); +} + // Modified start_link_quality_thread: use adaptive_link_enabled and adaptive_tx_power void WfbngLink::start_link_quality_thread(int fd) { + if (link_mode.load() == LinkMode::AP) { + // The adaptive link talks to the camera through the wfb-ng tunnel, which + // does not exist in AP mode. Only the TX power applies. + rtl_devices.at(fd)->SetTxPower(adaptive_tx_power); + return; + } auto thread_func = [this, fd]() { std::this_thread::sleep_for(std::chrono::seconds(1)); const char *ip = "10.5.0.10"; diff --git a/app/wfbngrtl8812/src/main/cpp/WfbngLink.hpp b/app/wfbngrtl8812/src/main/cpp/WfbngLink.hpp index 287027ef..d405653f 100644 --- a/app/wfbngrtl8812/src/main/cpp/WfbngLink.hpp +++ b/app/wfbngrtl8812/src/main/cpp/WfbngLink.hpp @@ -3,6 +3,7 @@ #include "FecChangeController.h" #include "SignalQualityCalculator.h" +#include "StaLink.h" #include "TxFrame.h" extern "C" { @@ -12,6 +13,7 @@ extern "C" { #include "devourer/src/IRtlDevice.h" #include "devourer/src/WiFiDriver.h" #include "wfb-ng/src/rx.hpp" +#include #include #include #include @@ -22,11 +24,30 @@ extern "C" { #include #include // Added for std::vector +struct libusb_context; + const uint8_t wfb_tx_port = 160; const uint8_t wfb_rx_port = 32; +// How the adapter is used. Must match the constants in WfbNgLink.java. +enum class LinkMode : int { + // wfb-ng broadcast in monitor mode (the original behaviour). + WFB = 0, + // Regular Wi-Fi station joining the camera's open access point (StaLink). + AP = 1, +}; + class WfbngLink { public: + // Takes effect on the next run(); the Java side restarts the adapters after changing it. + std::atomic link_mode{LinkMode::WFB}; + std::mutex sta_mutex; + StaConfig sta_config; // guarded by sta_mutex + // Guarded by sta_mutex; set while an AP-mode run() is active. Shared so a camera + // command can keep using it without holding sta_mutex for seconds; run() stops it + // before the device goes away, after which every call on it is a no-op. + std::shared_ptr sta_link; + // FEC switching thresholds (for menu) int fec_lost_to_5 = 2; int fec_recovered_to_4 = 30; @@ -114,6 +135,19 @@ class WfbngLink { } private: + // AP-mode half of run(), entered once the device exists. Owns the device + // shutdown and libusb teardown, like the rest of run() does for wfb-ng mode. + int runStation(libusb_device_handle *dev_handle, libusb_context *ctx, int fd, int wifiChannel); + + // Destroys the device object once run() is done with it. devourer's per-adapter + // lock lives inside that object, so as long as it stays in rtl_devices the next + // run() - after a mode, channel or settings change - is refused with "USB adapter + // in use ... by another devourer process". Must come before libusb_exit(). + void releaseDevice(int fd) { + rtl_devices.erase(fd); + if (current_fd == fd) current_fd = -1; + } + void stopDevice() { if (rtl_devices.find(current_fd) == rtl_devices.end()) return; auto dev = rtl_devices.at(current_fd).get(); diff --git a/app/wfbngrtl8812/src/main/java/com/openipc/wfbngrtl8812/WfbNgLink.java b/app/wfbngrtl8812/src/main/java/com/openipc/wfbngrtl8812/WfbNgLink.java index cf0fd101..42e927c4 100644 --- a/app/wfbngrtl8812/src/main/java/com/openipc/wfbngrtl8812/WfbNgLink.java +++ b/app/wfbngrtl8812/src/main/java/com/openipc/wfbngrtl8812/WfbNgLink.java @@ -49,6 +49,21 @@ public void setFecThresholds(int lostTo5, int recTo4, int recTo3, int recTo2, in public static native void nativeSetUseFec(long nativeInstance, int use); public static native void nativeSetUseLdpc(long nativeInstance, int use); public static native void nativeSetUseStbc(long nativeInstance, int use); + public static native void nativeSetLinkMode(long nativeInstance, int mode); + public static native boolean nativeSetApConfig(long nativeInstance, String ssid, String ownIp, + String ownMac, int videoPort, + int[] candidateChannels, boolean fullScan, + int maxWidthMhz); + public static native String nativeGetApStatus(long nativeInstance); + public static native int nativeGetApChannel(long nativeInstance); + public static native int nativeGetApRecoveries(long nativeInstance); + public static native String nativeCameraCommand(long nativeInstance, String cameraIp, int port, + String command, int timeoutMs, boolean halfClose); + + /** wfb-ng broadcast in monitor mode. Must match LinkMode in WfbngLink.hpp. */ + public static final int LINK_MODE_WFB = 0; + /** Join the camera's open Wi-Fi access point as a regular station. */ + public static final int LINK_MODE_AP = 1; public WfbNgLink(final Context parent) { this.context = parent; @@ -92,6 +107,61 @@ public void nativeSetUseStbc(int use) { nativeSetUseStbc(nativeWfbngLink, use); } + /** Takes effect the next time the adapters are started. */ + public void setLinkMode(int mode) { + nativeSetLinkMode(nativeWfbngLink, mode); + } + + /** + * Network the AP mode joins. Takes effect the next time the adapters are started. + * + * @param candidateChannels checked in turn when the AP is not on the start channel + * @param fullScan after the candidates, also sweep every other channel + * @param maxWidthMhz widest channel to follow the AP to: 20, 40 or 80 + * @return false if the IP or MAC address is malformed, or the MAC is not unicast. + */ + public boolean setApConfig(String ssid, String ownIp, String ownMac, int videoPort, + int[] candidateChannels, boolean fullScan, int maxWidthMhz) { + return nativeSetApConfig(nativeWfbngLink, ssid, ownIp, ownMac, videoPort, + candidateChannels, fullScan, maxWidthMhz); + } + + /** Channel of the AP while connected in AP mode, 0 otherwise. */ + public int getApChannel() { + return nativeGetApChannel(nativeWfbngLink); + } + + /** + * Grows each time video can flow again in AP mode: every (re)association and every end + * of a dropout. Starts again from 0 when the adapter restarts. + */ + public int getApRecoveries() { + return nativeGetApRecoveries(nativeWfbngLink); + } + + /** + * Sends one command line to a TCP service on the camera over the AP link and returns + * its reply, or null when not connected or nothing came back. Blocks for up to + * timeoutMs, so never call it on the main thread. + */ + public String cameraCommand(String cameraIp, int port, String command, int timeoutMs) { + return cameraCommand(cameraIp, port, command, timeoutMs, false); + } + + /** + * @param halfClose end the sending side with the command, as {@code echo cmd | nc} + * does; for line servers such as air_man_ap that act on end of input. + */ + public String cameraCommand(String cameraIp, int port, String command, int timeoutMs, + boolean halfClose) { + return nativeCameraCommand(nativeWfbngLink, cameraIp, port, command, timeoutMs, halfClose); + } + + /** One-line AP connection state, or an empty string while no adapter runs in AP mode. */ + public String getApStatus() { + return nativeGetApStatus(nativeWfbngLink); + } + public synchronized boolean start(int wifiChannel, int bandWidth, UsbDevice usbDevice) { // linkThreads.put() below overwrites the entry for a device, which would orphan an // older thread so that stopAll() never joins it and the interface is never released. From 741e9808a6b913225833e153dabe523a7c9275d4 Mon Sep 17 00:00:00 2001 From: Stefan Wittmann Date: Sat, 17 Oct 2026 23:51:38 +0200 Subject: [PATCH 02/10] Remove the internal WPA2 handover notes from the branch Co-Authored-By: Claude Opus 5.5 --- APFPV_WPA2_HANDOVER.md | 123 ----------------------------------------- 1 file changed, 123 deletions(-) delete mode 100644 APFPV_WPA2_HANDOVER.md diff --git a/APFPV_WPA2_HANDOVER.md b/APFPV_WPA2_HANDOVER.md deleted file mode 100644 index 53a779c8..00000000 --- a/APFPV_WPA2_HANDOVER.md +++ /dev/null @@ -1,123 +0,0 @@ -# Übergabe: WPA2-PSK für den APFPV-Modus - -Einstieg für eine neue Claude-Sitzung. Antworten auf **Deutsch**. Der Nutzer baut die App selbst -in Android Studio. **Keine eigenen Build- oder Testschleifen.** Er schickt Compiler-Fehler und Logs. -Ziel ist, sparsam mit Token umzugehen: nur die Stellen lesen, die man ändert, nicht ganze Dateien. - -## Stand - -- Repo `C:\Users\david\Documents\Claude\PixelPilot_apk`, Branch `ap-mode`, **nichts committet**. - Vor Etappe 1 den Nutzer fragen, ob er erst committen will. -- **APFPV-Modus:** Der USB-Adapter (devourer, Userspace-Treiber, RTL8812EU) ist als Station mit dem - **offenen** AP der OpenIPC-apfpv-Kamera verbunden. Die Kamera hat 192.168.0.1, das Handy die feste - IP 192.168.0.10. Video kommt per RTP/UDP auf Port 5600. -- Läuft auf dem Gerät: - - Verbinden ohne Scan - - CSA folgen (inkl. Kanalbreite 40/80) - - Rejoin nach Deauth oder TSF-Sprung direkt zur bekannten BSSID - - Dropouts beenden die Assoziation nicht -- Kernklasse ist `app/wfbngrtl8812/src/main/cpp/StaLink.{h,cpp}`. Sie enthält den - Zustandsautomaten Searching → Authenticating → Associating → Connected, ARP/ICMP/TCP-Client und den - Worker-Thread. `send_packet` muss außerhalb des RX-Threads laufen, deshalb gibt es eine TX-Queue. -- JNI liegt in `WfbngLink.cpp` (`nativeSetApConfig`, …), Java in `WfbNgLink.java` und - `VideoActivity.java` (`showApSettingsDialog`, Prefs `ap_*`). - -## Fakten aus der Prüfung - -- **devourer hat keine Verschlüsselung für eine Station.** Der Sicherheits-CAM des Chips wird beim - Init geleert, und für Jaguar3 fehlen die TX/RX-Deskriptorfelder für Verschlüsselung. Geschützte - Frames kommen roh an (RCR accept-all), selbst gebaute verschlüsselte Frames lassen sich per - `send_packet` senden, HW-ACK funktioniert. -- **Vorlage auf AP-Seite:** `devourer/tests/ap_wpa2.cpp` (4-Way-Authenticator, Software-CCMP, nutzt - OpenSSL) und `devourer/docs/ap-mode.md`. - - Der CCMP-AAD maskiert in FC die Subtype-, Retry-, PwrMgmt- und MoreData-Bits und setzt Protected. - - Er maskiert die Sequenznummer und lässt die Fragmentnummer stehen. - - QoS-AAD enthält nur die TID. - - Nonce = `prio(TID) | A2 | PN(6, big-endian)`. - - CCMP-Header = PN0 PN1 0 (0x20|keyid<<6) PN2..PN5. -- **OpenSSL gibt es im NDK nicht.** Das vorhandene libsodium hat weder AES-CCM noch SHA1. Den - BSD-lizenzierten hostap-Code übernehmen statt ihn neu zu schreiben: - `devourer/reference/8821cu/core/crypto/` (aes-internal*, aes-ccm, aes-unwrap, sha1*). Vorher die - Lizenzköpfe prüfen. -- **Kamera:** Die Stock-Firmware `/usr/bin/adapter` erzeugt `wpa=2`, `rsn_pairwise=CCMP`, - `wpa_passphrase=$pass`, `auth_algs=1` und **kein** `ieee80211w`. Das ist reines WPA2-PSK/CCMP ohne - PMF. hostapd kann zwar auch SAE. - - Das Skript des Nutzers `C:\Users\david\Documents\Claude\OpenIPC\apfpv-open-static-cam.sh` schaltet - WPA2 ab (wpa=0, MAC-ACL). - - Für den Test braucht es eine Variante, die `wpa=2` behält und nur `disassoc_low_ack=0` setzt. -- **hostapd-Zeitlimits für EAPOL:** erstes Timeout 100 ms, dann je 1 s, 4 Versuche. Danach kommt ein - Deauth mit Grund 15. Den GTK erneuert hostapd standardmäßig alle 86400 s. - -## Entscheidungen (mit dem Nutzer abgestimmt) - -- Nur **WPA2-PSK + CCMP**, `ieee80211w` 0 oder 1. Offene Netze bleiben unterstützt. -- Kein WPA3/SAE, keine PMF-Pflicht, kein TKIP. Erkennen und eine klare Statusmeldung anzeigen. -- Die PMK (PBKDF2-SHA1, 4096 Runden, SSID als Salt) **vorab in Java** mit - `SecretKeyFactory("PBKDF2WithHmacSHA1")` beim Speichern berechnen und als 32 Byte an native - übergeben. Neu berechnen, wenn sich SSID oder Passwort ändern, auch beim SSID-Ändern im - `CameraControlDialog`. - -## Etappe 1: Kern (nativ) - -1. Krypto-Dateien von hostap übernehmen (AES-128-Block, CCM bzw. CCMP, AES-Key-Unwrap, HMAC-SHA1 - und PRF-384/512) und in `app/wfbngrtl8812/src/main/cpp/CMakeLists.txt` eintragen. Eine dünne - Wrapper-Header `WpaCrypto.h` schreiben. -2. `StaConfig` um `std::array pmk` und `bool use_wpa` erweitern. -3. **Netzwahl und Assoziation:** - - Im Beacon oder der Probe-Response das RSN-IE (48) merken. - - Wenn `use_wpa` gilt, ein RSN-IE in den Assoc-Request schreiben: Version 1, Gruppenchiffre aus - dem AP-IE, paarweise CCMP, AKM PSK, Caps 0. - - Dasselbe IE byte-genau in Nachricht 2 verwenden. - - Ein Privacy-Bit, das nicht zur Konfiguration passt, als Hinweis in der Statuszeile melden. -4. **Neuer Zustand `Handshaking`** nach erfolgreicher Assoc-Response. EAPOL kommt mit LLC/SNAP und - Ethertype 0x888E (in `handleMsdu` abzweigen). - - **Nachricht 1:** ANonce lesen, Replay-Counter merken, SNonce zufällig erzeugen, - PTK = PRF-384(PMK, "Pairwise key expansion", min/max(AA,SPA) + min/max(ANonce,SNonce)), - KCK/KEK/TK ableiten, dann Nachricht 2 mit MIC (HMAC-SHA1 über KCK, 16 Byte) und RSN-IE senden. - - **Nachricht 3:** - - Prüfen: MIC, Replay-Counter größer als bisher, ANonce gleich. - - RSN-IE gegen das Beacon prüfen, bei Abweichung abbrechen. - - Key-Data mit KEK entpacken (unwrap), GTK-KDE (00-0F-AC:1) parsen: Key-ID plus GTK. - - RSC als Start-Replay-Zähler für die Gruppe übernehmen. - - **Nachricht 4** unverschlüsselt senden, **danach** PTK und GTK installieren und Connected setzen. - - EAPOL-Frames gehen nie über den CCMP-Pfad, solange kein PTK installiert ist. - - **Group-Key-Handshake** (Rekey): Group-Nachricht 1, verschlüsselt, Key-Data entpacken, GTK - ersetzen und Group-Nachricht 2 senden. -5. **RX** in `handleData`, wo heute steht „Protected: only open networks are supported“: - - Duplikate über Sequenznummer und Retry vor dem Entschlüsseln verwerfen. - - Key-ID aus dem CCMP-Header lesen: Unicast → PTK, Multicast → GTK. - - CCM entschlüsseln und den 8-Byte-MIC prüfen. Bei falschem MIC verwerfen und zählen. - - **Replay-Prüfung pro TID (16 Zähler) erst nach erfolgreichem MIC.** - - Dann den bisherigen Pfad (LLC/A-MSDU) auf dem Klartext fortsetzen. -6. **TX** in `sendData` (ARP, ICMP, TCP): mit installiertem PTK das Protected-Bit setzen, - CCMP-Header plus Chiffretext plus MIC anhängen. Der 48-Bit-PN wird unter dem Mutex streng - hochgezählt. Null-Keepalive-Frames bleiben unverschlüsselt. -7. **`reconnect()` und alle Wege zurück zu Searching:** PTK, GTK, PN und Replay-Zähler löschen, - **bevor** `beginAuth()` aufgerufen wird. -8. **Falsches Passwort erkennen:** Kommt Nachricht 1 mehrmals ohne Nachricht 3 oder folgt ein Deauth - mit Grund 15 oder 2, Status „APFPV: 4-way handshake failed – wrong password?“ anzeigen und den - Rejoin bremsen (Backoff 2 s). -9. JNI `nativeSetApConfig` um `byte[] pmk` erweitern (null bedeutet offen). - `statusText()`/`printableAscii` bleiben, wie sie sind. - -Danach testet der Nutzer: Kamera mit WPA2, App mit Passwort, dann Logcat-Tag `pixelpilot-sta`. - -## Etappe 2: App und Doku (erst nach erfolgreichem Test) - -- In `showApSettingsDialog` ein Passwortfeld ergänzen (leer bedeutet offen), Pref `ap_password`. -- PMK-Berechnung in einem Hintergrund-Thread laufen lassen, Ergebnis in `applyLinkConfig()` - übergeben. -- Hinweis anzeigen, wenn die Kamera WPA3 oder PMF-Pflicht meldet. -- README-Abschnitt „Link modes: WFB-NG and APFPV“ aktualisieren. „Limitations: open networks only“ - ändern zu „open or WPA2-PSK (CCMP), no WPA3/SAE, no required PMF“. -- Optional eine Kamera-Skriptvariante mit WPA2 an. - -## Was für das Recovering wichtig ist - -- Ein Dropout ohne Deauth bleibt unverändert: Die Schlüssel bleiben gültig, Video läuft sofort weiter. -- Ein Rejoin kostet zusätzlich den 4-Way-Handshake, etwa 10–50 ms. Deshalb muss die PMK vorab - berechnet sein, im Rejoin-Pfad darf kein PBKDF2 laufen. -- Deauth und Disassoc bleiben ungeschützt (kein PMF). Die bisherige Erkennung „Kamera kennt uns - nicht mehr“ funktioniert weiter. -- Mit einem Schlüssel darf nie ein PN doppelt vorkommen. Software-Wiederholungen bekommen einen - neuen PN. From a76a0e1d17a0aa3310b1a0f92e14088c127c791c Mon Sep 17 00:00:00 2001 From: Stefan Wittmann Date: Sat, 17 Oct 2026 23:56:53 +0200 Subject: [PATCH 03/10] Add the camera-side setup script for the APFPV link mode 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 --- README.md | 8 +++ tools/apfpv-open-static-cam.sh | 124 +++++++++++++++++++++++++++++++++ 2 files changed, 132 insertions(+) create mode 100755 tools/apfpv-open-static-cam.sh diff --git a/README.md b/README.md index b9e936ed..4f0a83e1 100644 --- a/README.md +++ b/README.md @@ -107,6 +107,14 @@ Drone login credentials), so the picture does not wait for the next scheduled on Until video arrives, the connection state is shown in the middle of the screen, including the networks the adapter hears on the current channel. Logcat tag `pixelpilot-sta` has the details. +The stock apfpv firmware runs a WPA2 access point with DHCP. `tools/apfpv-open-static-cam.sh` +prepares the camera for this mode: it patches `/usr/bin/adapter` (which writes `hostapd.conf`) +for an open network without DHCP and with `disassoc_low_ack=0`, so a fade does not end the +association, optionally with a MAC filter for the adapter. It keeps a backup (`--revert`): +``` +ssh root@192.168.0.1 sh -s < tools/apfpv-open-static-cam.sh +``` + On the camera, send the stream to the phone's IP, e.g. in `/etc/majestic.yaml`: ``` outgoing: diff --git a/tools/apfpv-open-static-cam.sh b/tools/apfpv-open-static-cam.sh new file mode 100755 index 00000000..d3438924 --- /dev/null +++ b/tools/apfpv-open-static-cam.sh @@ -0,0 +1,124 @@ +#!/bin/sh +# +# apfpv-open-static-cam.sh - CAMERA: open Wi-Fi, no DHCP +# +# Prepares an OpenIPC apfpv camera for PixelPilot's APFPV link mode. Patches +# /usr/bin/adapter (which writes hostapd.conf on every start): +# - WPA2 off (wpa_passphrase/rsn_pairwise removed, wpa=2 -> wpa=0) +# -> no 4-way handshake when joining +# - the DHCP server is no longer started, nor restarted when a station +# connects; the ground station has the fixed IP 192.168.0.10 (PixelPilot's +# default in APFPV settings). Clearing the station's ARP entry stays. +# - disassoc_low_ack=0: hostapd no longer drops the ground station when many +# frames go unacknowledged during a fade. The association survives the +# dropout and video resumes without rejoining. +# - optional: only one given MAC address may join +# +# The access point is restarted afterwards (Wi-Fi drops briefly). +# Also point majestic at the ground station, e.g. in /etc/majestic.yaml: +# outgoing: { enabled: true, server: udp://192.168.0.10:5600 } +# +# On the camera: +# sh apfpv-open-static-cam.sh install +# sh apfpv-open-static-cam.sh --allow-mac aa:bb:cc:dd:ee:ff +# install + allow only this MAC +# sh apfpv-open-static-cam.sh --no-restart install, active from the next start +# sh apfpv-open-static-cam.sh --revert restore the backup +# From a PC: +# ssh root@192.168.0.1 sh -s < apfpv-open-static-cam.sh +# ssh root@192.168.0.1 sh -s -- --allow-mac aa:bb:cc:dd:ee:ff < apfpv-open-static-cam.sh + +ADAPTER=${ADAPTER:-/usr/bin/adapter} +BACKUP=${BACKUP:-/root/adapter.before-open-static} +ACCEPT=${ACCEPT:-/etc/hostapd.accept} +MARK="apfpv-open-static" +RESTART=1 +REVERT=0 +MAC="" + +die() { echo "ERROR: $*" >&2; exit 1; } + +while [ $# -gt 0 ]; do + case "$1" in + --revert) REVERT=1 ;; + --no-restart) RESTART=0 ;; + --allow-mac) shift; MAC=$(echo "$1" | tr 'A-F' 'a-f') ;; + *) die "unknown option: $1" ;; + esac + shift +done + +[ -f "$ADAPTER" ] || die "$ADAPTER not found" + +restart_ap() { + [ "$RESTART" = 1 ] || { echo "Active from the next start (or: setsid $ADAPTER start)."; return; } + echo "Access point restarts in 2 s - Wi-Fi drops briefly." + setsid sh -c "sleep 2; $ADAPTER start" >/tmp/adapter-restart.log 2>&1 "$TMP" || { rm -f "$TMP"; die "editing failed, nothing changed."; } + sh -n "$TMP" || { rm -f "$TMP"; die "syntax error in the result, nothing changed."; } + grep -q '^disassoc_low_ack=0$' "$TMP" || { rm -f "$TMP"; die "replacing failed, nothing changed."; } + mv "$TMP" "$ADAPTER" && chmod 755 "$ADAPTER" || die "writing $ADAPTER failed" + echo "Updated: disassoc_low_ack=0." + restart_ap + exit 0 + fi + echo "Already installed, nothing to do. (To change it, --revert first.)" + exit 0 +fi + +# Each spot must exist exactly like this, otherwise it is a different apfpv version. +for pat in '^wpa_passphrase=\$pass$' '^wpa=2$' '^rsn_pairwise=CCMP$' '^disassoc_low_ack=1$' \ + '^ udhcpd -S /etc/udhcpd.conf$' '^ reset_single_ip_dhcp "\$1"$'; do + [ "$(grep -c "$pat" "$ADAPTER")" = 1 ] || die "spot '$pat' not found exactly once - nothing changed." +done + +cp "$ADAPTER" "$BACKUP" || die "backup to $BACKUP failed" +TMP="$ADAPTER.tmp.$$" + +ACL="" +[ -n "$MAC" ] && ACL="macaddr_acl=1\\naccept_mac_file=$ACCEPT\\n" + +sed \ + -e '/^wpa_passphrase=\$pass$/d' \ + -e '/^rsn_pairwise=CCMP$/d' \ + -e "s|^wpa=2\$|# $MARK: open network, no WPA2 handshake\\nwpa=0\\n$ACL|" \ + -e "s|^disassoc_low_ack=1\$|# $MARK: keep the ground station through a fade\\ndisassoc_low_ack=0|" \ + -e "s|^ udhcpd -S /etc/udhcpd.conf\$| # $MARK: no DHCP, the ground station has the fixed IP \$master|" \ + -e "s|^ reset_single_ip_dhcp \"\\\$1\"\$| ip neigh del \"\$master\" dev \"\$1\" >/dev/null 2>\&1 # $MARK: instead of the DHCP reset|" \ + "$BACKUP" > "$TMP" || { rm -f "$TMP"; die "editing failed, nothing changed."; } + +# Drop the blank line an empty $ACL leaves behind +[ -z "$MAC" ] && sed -i '/^wpa=0$/{n;/^$/d}' "$TMP" + +sh -n "$TMP" || { rm -f "$TMP"; die "syntax error in the result, nothing changed."; } +[ "$(grep -c "$MARK" "$TMP")" -ge 4 ] || { rm -f "$TMP"; die "not all spots replaced, nothing changed."; } +grep -q '^disassoc_low_ack=0$' "$TMP" || { rm -f "$TMP"; die "disassoc_low_ack not replaced, nothing changed."; } + +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)."; } +echo "Installed. Backup: $BACKUP" + +killall -q udhcpd 2>/dev/null +restart_ap +exit 0 From 88bae1dd02a0b59ecb088ef79014f50d722baf04 Mon Sep 17 00:00:00 2001 From: dawitt50 Date: Mon, 19 Oct 2026 22:57:01 +0200 Subject: [PATCH 04/10] APFPV mode: fix review findings (races, reassembly, TCP, recovery) Addresses the Qodo findings and the blocking items from the maintainer review. WfbngLink - Guard rtl_devices with dev_mutex; stop() and the TX power setter work on a shared_ptr copy taken under the lock, so run() releasing the device can no longer free it under them (use-after-free / std::map race). run() keeps its own reference and drops it before libusb_exit(). - current_fd is atomic and only set in runStation() once the adapter is accepted as the station, so a rejected second adapter cannot clobber it. - TX power in AP mode is queued to the StaLink worker (single control thread, no race with retunes). - The recovery counter reported to Java is monotonic across StaLink instances (ap_recovery_base), so a fast restart still triggers a key-frame request. StaLink - IPv4 reassembly tracks received 8-byte units; duplicate or overlapping fragments can no longer complete a datagram with holes. - A failed retune no longer updates channel/width/offset. - A dropout that ends between two worker ticks is detected in onRx() as well. - tcpRequest waits for the single session slot instead of failing at once. - A cut-off HTTP reply (missing headers or body per Content-Length) is not accepted after the idle timeout and fails at the deadline so callers retry. - Bound untrusted input: ICMP echo <= 1500 bytes, TCP reply <= 64 KiB, at most 64 remembered networks while searching. CameraControlDialog - cgi() retries when the JSON body is unreadable. Not included: tools/apfpv-open-static-cam.sh, README notes, clang-format run and the rebase onto master. --- .../pixelpilot/CameraControlDialog.java | 4 +- app/wfbngrtl8812/src/main/cpp/StaLink.cpp | 97 ++++++++++++++++--- app/wfbngrtl8812/src/main/cpp/StaLink.h | 12 ++- app/wfbngrtl8812/src/main/cpp/WfbngLink.cpp | 83 ++++++++++------ app/wfbngrtl8812/src/main/cpp/WfbngLink.hpp | 46 +++++++-- 5 files changed, 189 insertions(+), 53 deletions(-) diff --git a/app/src/main/java/com/openipc/pixelpilot/CameraControlDialog.java b/app/src/main/java/com/openipc/pixelpilot/CameraControlDialog.java index 82b41d84..8f99df3d 100644 --- a/app/src/main/java/com/openipc/pixelpilot/CameraControlDialog.java +++ b/app/src/main/java/com/openipc/pixelpilot/CameraControlDialog.java @@ -534,8 +534,10 @@ private JSONObject cgi(String pathAndQuery) { try { return new JSONObject(reply.substring(brace).trim()); } catch (Exception e) { + // Most likely a reply that was cut off on the way: ask again. Log.w(TAG, "camera " + pathAndQuery + ": unreadable answer", e); - return null; + if (!pause(400)) break; + continue; } } if (!pause(400)) break; diff --git a/app/wfbngrtl8812/src/main/cpp/StaLink.cpp b/app/wfbngrtl8812/src/main/cpp/StaLink.cpp index 1fb8aad0..8f03bcea 100644 --- a/app/wfbngrtl8812/src/main/cpp/StaLink.cpp +++ b/app/wfbngrtl8812/src/main/cpp/StaLink.cpp @@ -6,6 +6,8 @@ #include #include #include +#include +#include #include #include #include @@ -106,6 +108,27 @@ constexpr auto kTcpRetransmit = 400ms; // air_man_ap answers once it is done; when it keeps the connection open after // that, this much silence after the first reply bytes ends the request. constexpr auto kTcpReplyIdle = 1500ms; +// Replies are small status lines or JSON; anything beyond this is not from the camera. +constexpr size_t kTcpMaxReply = 64 * 1024; +// ICMP echo requests larger than a normal MTU are not answered (they would have to go +// out as one oversized MPDU). +constexpr size_t kMaxEchoDatagram = 1500; +// Networks remembered while searching (diagnostics only). +constexpr size_t kMaxSeenNetworks = 64; + +// True for an HTTP reply whose headers or body are not all there yet. Anything that +// is not HTTP (air_man_ap speaks plain lines) counts as complete. +bool httpReplyIncomplete(const std::string &rx) { + if (rx.compare(0, 5, "HTTP/") != 0) return false; + const auto hdr_end = rx.find("\r\n\r\n"); + if (hdr_end == std::string::npos) return true; + std::string head = rx.substr(0, hdr_end); + std::transform(head.begin(), head.end(), head.begin(), [](unsigned char ch) { return std::tolower(ch); }); + const auto cl = head.find("\r\ncontent-length:"); + if (cl == std::string::npos) return false; // delimited by the peer closing + const size_t want = std::strtoul(head.c_str() + cl + 17, nullptr, 10); + return rx.size() - (hdr_end + 4) < want; +} const char *stateName(StaLink::State s) { switch (s) { @@ -170,6 +193,7 @@ void StaLink::stop() { // Release a caller blocked in tcpRequest(); nothing will answer it any more. std::lock_guard lock(mu_); tcpFinish(false); + tcp_cv_.notify_all(); // callers queued for the session slot give up too } uint8_t StaLink::connectedChannel() const { @@ -270,7 +294,17 @@ void StaLink::worker() { if (dev_->send_packet(f.data(), f.size())) tx_frames_++; } + // Control-path calls stay on this thread (see IRtlDevice: single control thread). + if (const int power = pending_tx_power_.exchange(-1); power >= 0) { + try { + dev_->SetTxPower(power); + } catch (const std::exception &e) { + __android_log_print(ANDROID_LOG_ERROR, TAG, "SetTxPower(%d) failed: %s", power, e.what()); + } + } + if (retune > 0) { + bool tuned = true; // Outside mu_: a retune takes milliseconds (more on a band change) and the // RX thread must keep draining meanwhile. devourer's own hopping RX does the // same from a second thread while StartRxLoop runs. @@ -295,12 +329,18 @@ void StaLink::worker() { retune_width == 40 ? (retune_offset == 2 ? " (HT40-)" : " (HT40+)") : ""); } } catch (const std::exception &e) { + tuned = false; __android_log_print(ANDROID_LOG_ERROR, TAG, "retune to ch %d failed: %s", retune, e.what()); } std::lock_guard lock(mu_); - channel_ = static_cast(retune); - width_mhz_ = retune_width; - chan_offset_ = retune_offset; + // Only record what the radio really switched to; after a failure the old + // channel/width stay in force, so the search and probe logic keeps working + // from the truth and simply tries again after the dwell time. + if (tuned) { + channel_ = static_cast(retune); + width_mhz_ = retune_width; + chan_offset_ = retune_offset; + } dwell_start_ = Clock::now(); last_tx_attempt_ = Clock::time_point{}; // probe right away on the new channel } @@ -685,7 +725,10 @@ void StaLink::onRx(const Packet &packet) { const bool from_ap = state_.load() != State::Searching && macEq(addr2, bssid_.data()); if (from_ap) { const auto now = Clock::now(); - if (in_gap_ && state_.load() == State::Connected) { + // tick() flags a gap only every kTickInterval; judge the silence here too, so a + // dropout that ends between two ticks still triggers the key-frame request. + if (state_.load() == State::Connected && last_heard_ != Clock::time_point{} && + (in_gap_ || now - last_heard_ >= kDropoutGap)) { // Dropout over: video flows again right now, nothing to rebuild. in_gap_ = false; recoveries_++; @@ -833,6 +876,13 @@ void StaLink::handleBeacon(const uint8_t *f, size_t len, uint8_t rssi) { std::array bssid; std::memcpy(bssid.data(), f + 16, 6); + if (seen_.size() >= kMaxSeenNetworks && seen_.find(bssid) == seen_.end()) { + // Forget the stalest entry rather than growing without bound on a busy band. + auto oldest = std::min_element(seen_.begin(), seen_.end(), [](const auto &a, const auto &b) { + return a.second.last < b.second.last; + }); + seen_.erase(oldest); + } seen_[bssid] = SeenNetwork{ssid, ds_channel != -1 ? ds_channel : channel_, (capability & 0x0010) != 0, rssi, Clock::now()}; @@ -1007,8 +1057,9 @@ void StaLink::handleMsdu(uint16_t ethertype, const uint8_t *payload, size_t len, } // Called with mu_ held. RFC 791 reassembly, kept small: at most 8 datagrams in -// flight, each dropped 1 s after its first fragment. Overlapping fragments are -// not expected from a single sender and are not handled specially. +// flight, each dropped 1 s after its first fragment. Which 8-byte units have +// arrived is tracked, so duplicates and overlaps cannot complete a datagram that +// still has holes. std::vector StaLink::reassemble(const uint8_t *ip, size_t ihl, size_t len) { const auto now = Clock::now(); frags_.erase(std::remove_if(frags_.begin(), frags_.end(), @@ -1023,20 +1074,28 @@ std::vector StaLink::reassemble(const uint8_t *ip, size_t ihl, size_t l const size_t off = static_cast(fo & 0x1fff) * 8; const size_t plen = len - ihl; if (off + plen + ihl > 65535) return {}; + if (more && (plen % 8) != 0) return {}; // only the last fragment may end off a unit boundary auto it = std::find_if(frags_.begin(), frags_.end(), [src, id](const IpFragments &f) { return f.src == src && f.id == id; }); if (it == frags_.end()) { if (frags_.size() >= 8) frags_.erase(frags_.begin()); - frags_.push_back(IpFragments{src, id, {}, {}, 0, 0, now}); + frags_.push_back(IpFragments{src, id, {}, {}, {}, 0, now}); it = frags_.end() - 1; } if (it->payload.size() < off + plen) it->payload.resize(off + plen); std::memcpy(it->payload.data() + off, ip + ihl, plen); - it->received += plen; + const size_t first_unit = off / 8, end_unit = (off + plen + 7) / 8; + if (it->got.size() < end_unit) it->got.resize(end_unit, false); + for (size_t u = first_unit; u < end_unit; u++) it->got[u] = true; if (off == 0) it->header.assign(ip, ip + ihl); if (!more) it->total = off + plen; - if (it->total == 0 || it->header.empty() || it->received < it->total) return {}; + if (it->total == 0 || it->header.empty()) return {}; + const size_t units = (it->total + 7) / 8; + if (it->got.size() < units) return {}; + for (size_t u = 0; u < units; u++) { + if (!it->got[u]) return {}; + } std::vector whole(it->header); whole.insert(whole.end(), it->payload.begin(), it->payload.begin() + it->total); @@ -1158,7 +1217,7 @@ void StaLink::handleIpv4(const uint8_t *ip, size_t len, const uint8_t *src_mac) ip[12], ip[13], ip[14], ip[15], dport); } - } else if (proto == 1 && for_us && l4len >= 8 && l4[0] == 8) { // ICMP echo request + } else if (proto == 1 && for_us && l4len >= 8 && len <= kMaxEchoDatagram && l4[0] == 8) { // ICMP echo request std::vector r(ip, ip + len); std::memcpy(r.data() + 16, ip + 12, 4); // dst = original src std::memcpy(r.data() + 12, cfg_.own_ip.data(), 4); @@ -1205,7 +1264,11 @@ bool StaLink::tcpRequest(const std::array &ip, std::string &reply) { using St = TcpSession::St; std::unique_lock lock(mu_); - if (!running_ || state_.load() != State::Connected || tcp_.st != St::Idle) return false; + // One session slot: a caller that finds it busy (e.g. the key-frame request after a + // recovery) waits its turn instead of being told the camera did not answer. + const auto usable = [this] { return running_ && state_.load() == State::Connected; }; + if (!tcp_cv_.wait_for(lock, timeout, [&] { return !usable() || tcp_.st == St::Idle; })) return false; + if (!usable()) return false; std::random_device rd; TcpSession s; @@ -1248,8 +1311,10 @@ bool StaLink::tcpRequest(const std::array &ip, port, tcp_.st == St::Done ? "done" : "failed", reply.c_str()); + const bool ok = tcp_.st == St::Done && !reply.empty(); tcp_.st = St::Idle; - return !reply.empty(); + tcp_cv_.notify_all(); // the slot is free for the next caller + return ok; } void StaLink::tcpFinish(bool ok) { @@ -1269,10 +1334,12 @@ void StaLink::tcpTick(Clock::time_point now) { if (tcp_.st != St::Resolving && tcp_.st != St::SynSent && tcp_.st != St::Established) return; if (now >= tcp_.deadline) { __android_log_print(ANDROID_LOG_WARN, TAG, "TCP request timed out"); - tcpFinish(!tcp_.rx.empty()); + // A cut-off HTTP reply is a failure, so the caller retries instead of parsing half a body. + tcpFinish(!tcp_.rx.empty() && !httpReplyIncomplete(tcp_.rx)); return; } - if (tcp_.st == St::Established && !tcp_.rx.empty() && now - tcp_.last_rx >= kTcpReplyIdle) { + if (tcp_.st == St::Established && !tcp_.rx.empty() && now - tcp_.last_rx >= kTcpReplyIdle && + !httpReplyIncomplete(tcp_.rx)) { tcpFinish(true); return; } @@ -1344,7 +1411,7 @@ void StaLink::handleTcp(const uint8_t *ip, size_t ihl, size_t len) { } if ((flags & kTcpAck) && static_cast(ack - our_end) >= 0) tcp_.data_acked = true; - if (plen > 0 && seq == tcp_.rcv_nxt) { + if (plen > 0 && seq == tcp_.rcv_nxt && tcp_.rx.size() + plen <= kTcpMaxReply) { tcp_.rx.append(reinterpret_cast(payload), plen); tcp_.rcv_nxt += static_cast(plen); tcp_.last_rx = now; diff --git a/app/wfbngrtl8812/src/main/cpp/StaLink.h b/app/wfbngrtl8812/src/main/cpp/StaLink.h index 8e6a9417..86ae7ed9 100644 --- a/app/wfbngrtl8812/src/main/cpp/StaLink.h +++ b/app/wfbngrtl8812/src/main/cpp/StaLink.h @@ -84,11 +84,16 @@ class StaLink { // wait for the next scheduled one. uint32_t recoveries() const { return recoveries_.load(); } + // Queues a TX power change. It is applied by the worker thread, the only one that + // may talk to the device's control path (retunes run there too). + void requestTxPower(int power) { pending_tx_power_ = power; } + // Opens a TCP connection to ip:port, sends `request` and collects the reply // until the peer closes, goes quiet after answering, or `timeout` passes. // half_close ends our sending side with the request (as `echo cmd | nc` does). - // Blocks the caller (not an RX or worker thread). Returns false when not - // connected, another request is running, or nothing came back. + // Blocks the caller (not an RX or worker thread). Waits (within `timeout`) for a + // request that is already running. Returns false when not connected, the slot + // stayed busy, the reply is missing, or an HTTP reply was cut off. bool tcpRequest(const std::array &ip, uint16_t port, const std::string &request, @@ -201,6 +206,7 @@ class StaLink { bool in_gap_{false}; // connected, but nothing heard from the AP for kDropoutGap uint64_t last_tsf_{0}; // AP's beacon timestamp; jumping back means the AP restarted std::atomic recoveries_{0}; + std::atomic pending_tx_power_{-1}; // -1: nothing queued // Diagnostics, so a user can tell why nothing connects. struct SeenNetwork { @@ -227,7 +233,7 @@ class StaLink { uint16_t id; std::vector header; // from the offset-0 fragment std::vector payload; - size_t received{0}; + std::vector got; // one flag per 8-byte unit of payload that has arrived size_t total{0}; // known once the last fragment arrived Clock::time_point start; }; diff --git a/app/wfbngrtl8812/src/main/cpp/WfbngLink.cpp b/app/wfbngrtl8812/src/main/cpp/WfbngLink.cpp index 6645f351..b94b0b65 100644 --- a/app/wfbngrtl8812/src/main/cpp/WfbngLink.cpp +++ b/app/wfbngrtl8812/src/main/cpp/WfbngLink.cpp @@ -132,23 +132,29 @@ int WfbngLink::run(JNIEnv *env, jobject context, jint wifiChannel, jint bw, jint cfg.tx.retry_limit = 7; } - rtl_devices[fd] = wifi_driver->CreateRtlDevice(dev_handle, ctx, nullptr, cfg); - if (!rtl_devices.at(fd)) { + // The local copy is dropped (device.reset()) before libusb is torn down below. + std::shared_ptr device = wifi_driver->CreateRtlDevice(dev_handle, ctx, nullptr, cfg); + if (!device) { libusb_exit(ctx); __android_log_print(ANDROID_LOG_ERROR, TAG, "CreateRtlDevice error"); return -1; } + { + std::lock_guard lock(dev_mutex); + rtl_devices[fd] = device; + } if (stop_requested(fd)) { __android_log_print(ANDROID_LOG_WARN, TAG, "stop requested for fd=%d before bring-up, aborting", fd); - rtl_devices.erase(fd); + releaseDevice(fd); + device.reset(); libusb_release_interface(dev_handle, 0); libusb_exit(ctx); return -1; } if (ap_mode) { - return runStation(dev_handle, ctx, fd, wifiChannel); + return runStation(dev_handle, ctx, fd, wifiChannel, std::move(device)); } uint8_t *video_channel_id_be8 = reinterpret_cast(&video_channel_id_be); @@ -214,7 +220,7 @@ int WfbngLink::run(JNIEnv *env, jobject context, jint wifiChannel, jint bw, jint // Store the current fd for later TX power updates. current_fd = fd; - IRtlDevice *current_device = rtl_devices.at(fd).get(); + IRtlDevice *current_device = device.get(); // TX-capable bring-up with RX enabled (cfg.rx.enable_with_tx). On // Jaguar3 (RTL8812EU/8822EU) this also starts the coex runtime thread @@ -271,11 +277,11 @@ int WfbngLink::run(JNIEnv *env, jobject context, jint wifiChannel, jint bw, jint destroy_thread(usb_tx_thread); stop_adaptive_link(); - auto dev = rtl_devices.at(fd).get(); - if (dev) { - dev->Stop(); + if (device) { + device->Stop(); } releaseDevice(fd); + device.reset(); // before libusb_exit(): the device must not outlive its context libusb_release_interface(dev_handle, 0); libusb_exit(ctx); return -1; @@ -289,11 +295,11 @@ int WfbngLink::run(JNIEnv *env, jobject context, jint wifiChannel, jint bw, jint // Clean shutdown: halt TRX DMA and power the chip down before releasing // the USB interface. - auto dev = rtl_devices.at(fd).get(); - if (dev) { - dev->Stop(); + if (device) { + device->Stop(); } releaseDevice(fd); + device.reset(); // before libusb_exit(): the device must not outlive its context r = libusb_release_interface(dev_handle, 0); __android_log_print(ANDROID_LOG_DEBUG, TAG, "libusb_release_interface: %d", r); @@ -301,9 +307,12 @@ int WfbngLink::run(JNIEnv *env, jobject context, jint wifiChannel, jint bw, jint return 0; } -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; +int WfbngLink::runStation(libusb_device_handle *dev_handle, + libusb_context *ctx, + int fd, + int wifiChannel, + std::shared_ptr device) { + IRtlDevice *dev = device.get(); StaLink *sta = nullptr; devourer::MacAddr own_mac; @@ -320,12 +329,16 @@ int WfbngLink::runStation(libusb_device_handle *dev_handle, libusb_context *ctx, own_mac.bytes = cfg.own_mac; sta_link = std::make_shared(dev, cfg); sta = sta_link.get(); + // Only the adapter that owns the association becomes the current one; a + // rejected second adapter must not overwrite it. + current_fd = fd; } } if (!sta) { // Never brought up, so nothing to power down; same exit as an early stop in run(). - rtl_devices.erase(fd); + releaseDevice(fd); + device.reset(); libusb_release_interface(dev_handle, 0); libusb_exit(ctx); return -1; @@ -367,12 +380,14 @@ int WfbngLink::runStation(libusb_device_handle *dev_handle, libusb_context *ctx, sta->stop(); { std::lock_guard lock(sta_mutex); + ap_recovery_base += sta_link->recoveries(); sta_link.reset(); } __android_log_print(ANDROID_LOG_DEBUG, TAG, "AP mode RX loop exited, releasing..."); dev->Stop(); releaseDevice(fd); + device.reset(); // before libusb_exit(): the device must not outlive its context int r = libusb_release_interface(dev_handle, 0); __android_log_print(ANDROID_LOG_DEBUG, TAG, "libusb_release_interface: %d", r); libusb_exit(ctx); @@ -383,18 +398,15 @@ void WfbngLink::stop(JNIEnv *env, jobject context, jint fd) { // Recorded first, and whether or not the device exists yet: run() may not have got as far // as creating it, and StartRxLoop() would clear the flag set below anyway. note_stop_requested(fd); - if (rtl_devices.find(fd) == rtl_devices.end()) { + // A copy keeps the device alive while StopRxLoop() runs, even if run() releases it meanwhile. + std::shared_ptr dev = getDevice(fd); + if (!dev) { // Happens when the adapter was already gone by the time the stop arrived, e.g. it // was unplugged or the hub re-enumerated it. Nothing left to stop. __android_log_print(ANDROID_LOG_WARN, TAG, "stop: no rtl device for fd=%d, already gone", fd); return; } - auto dev = rtl_devices.at(fd).get(); - if (dev) { - dev->StopRxLoop(); - } else { - __android_log_print(ANDROID_LOG_ERROR, TAG, "rtl_devices.at(%d) is nullptr", fd); - } + dev->StopRxLoop(); stop_adaptive_link(); } @@ -631,7 +643,7 @@ extern "C" JNIEXPORT jint JNICALL Java_com_openipc_wfbngrtl8812_WfbNgLink_native jlong wfbngLinkN) { WfbngLink *link = native(wfbngLinkN); std::lock_guard lock(link->sta_mutex); - return link->sta_link ? static_cast(link->sta_link->recoveries()) : 0; + return static_cast(link->ap_recovery_base + (link->sta_link ? link->sta_link->recoveries() : 0)); } // Sends one line to a TCP service on the camera (air_man_ap on 12355) over the AP @@ -672,11 +684,28 @@ extern "C" JNIEXPORT jstring JNICALL Java_com_openipc_wfbngrtl8812_WfbNgLink_nat } // Modified start_link_quality_thread: use adaptive_link_enabled and adaptive_tx_power +void WfbngLink::applyTxPower(int fd, int power) { + if (link_mode.load() == LinkMode::AP) { + std::shared_ptr sta; + { + std::lock_guard lock(sta_mutex); + sta = sta_link; + } + if (sta) { + sta->requestTxPower(power); + return; + } + } + if (auto dev = getDevice(fd)) { + dev->SetTxPower(power); + } +} + void WfbngLink::start_link_quality_thread(int fd) { if (link_mode.load() == LinkMode::AP) { // The adaptive link talks to the camera through the wfb-ng tunnel, which // does not exist in AP mode. Only the TX power applies. - rtl_devices.at(fd)->SetTxPower(adaptive_tx_power); + applyTxPower(fd, adaptive_tx_power); return; } auto thread_func = [this, fd]() { @@ -783,7 +812,7 @@ void WfbngLink::start_link_quality_thread(int fd) { }; init_thread(link_quality_thread, [=]() { return std::make_unique(thread_func); }); - rtl_devices.at(fd)->SetTxPower(adaptive_tx_power); + applyTxPower(fd, adaptive_tx_power); } extern "C" JNIEXPORT void JNICALL Java_com_openipc_wfbngrtl8812_WfbNgLink_nativeSetAdaptiveLinkEnabled( @@ -814,8 +843,8 @@ extern "C" JNIEXPORT void JNICALL Java_com_openipc_wfbngrtl8812_WfbNgLink_native if (link->adaptive_tx_power == power) return; link->adaptive_tx_power = power; - if (link->current_fd != -1 && link->rtl_devices.find(link->current_fd) != link->rtl_devices.end()) { - link->rtl_devices.at(link->current_fd)->SetTxPower(power); + if (const int fd = link->current_fd.load(); fd != -1) { + link->applyTxPower(fd, power); } // If adaptive mode is enabled and the adaptive thread is not running, restart it. if (link->adaptive_link_enabled) { diff --git a/app/wfbngrtl8812/src/main/cpp/WfbngLink.hpp b/app/wfbngrtl8812/src/main/cpp/WfbngLink.hpp index d405653f..84b808ae 100644 --- a/app/wfbngrtl8812/src/main/cpp/WfbngLink.hpp +++ b/app/wfbngrtl8812/src/main/cpp/WfbngLink.hpp @@ -47,6 +47,10 @@ class WfbngLink { // command can keep using it without holding sta_mutex for seconds; run() stops it // before the device goes away, after which every call on it is a no-op. std::shared_ptr sta_link; + // Recoveries of earlier StaLink instances (guarded by sta_mutex). The counter the UI + // sees keeps growing across adapter restarts, so a new association is never mistaken + // for "no change" just because a fresh StaLink starts counting at zero again. + uint32_t ap_recovery_base{0}; // FEC switching thresholds (for menu) int fec_lost_to_5 = 2; @@ -71,7 +75,8 @@ class WfbngLink { // adaptive link // TODO: move this to private section - int current_fd; + // Read from the JNI threads and written by run(); the device map below is guarded by dev_mutex. + std::atomic current_fd; bool adaptive_link_enabled; bool adaptive_link_should_stop{false}; int adaptive_tx_power; @@ -80,8 +85,22 @@ class WfbngLink { bool ldpc_enabled{true}; bool stbc_enabled{true}; + // Guarded by dev_mutex. Other threads (stop(), the TX power setter, the link-quality + // thread) must copy the shared_ptr under the lock via getDevice() and call through + // that copy, so the device cannot be destroyed under them by run() releasing it. + std::mutex dev_mutex; std::map> rtl_devices; + std::shared_ptr getDevice(int fd) { + std::lock_guard lock(dev_mutex); + auto it = rtl_devices.find(fd); + return it == rtl_devices.end() ? nullptr : it->second; + } + + // Applies a TX power. In AP mode it goes through the station's worker thread, which + // is the only one allowed to use the device's control path while the link runs. + void applyTxPower(int fd, int power); + // Set by stop() and read by run(). A StopRxLoop() only takes effect once the RX loop is // running: RtlJaguarDevice::StartRxLoop() clears should_stop on entry, so a stop that // lands anywhere before that - including the whole chip bring-up in InitWrite(), which @@ -137,21 +156,34 @@ class WfbngLink { private: // AP-mode half of run(), entered once the device exists. Owns the device // shutdown and libusb teardown, like the rest of run() does for wfb-ng mode. - int runStation(libusb_device_handle *dev_handle, libusb_context *ctx, int fd, int wifiChannel); + int runStation(libusb_device_handle *dev_handle, + libusb_context *ctx, + int fd, + int wifiChannel, + std::shared_ptr device); // Destroys the device object once run() is done with it. devourer's per-adapter // lock lives inside that object, so as long as it stays in rtl_devices the next // run() - after a mode, channel or settings change - is refused with "USB adapter // in use ... by another devourer process". Must come before libusb_exit(). + // A caller that still holds a copy from getDevice() keeps the object alive until it is done. void releaseDevice(int fd) { - rtl_devices.erase(fd); - if (current_fd == fd) current_fd = -1; + std::shared_ptr doomed; + { + std::lock_guard lock(dev_mutex); + auto it = rtl_devices.find(fd); + if (it != rtl_devices.end()) { + doomed = std::move(it->second); + rtl_devices.erase(it); + } + int expected = fd; + current_fd.compare_exchange_strong(expected, -1); + } + // `doomed` is destroyed here, outside the lock. } void stopDevice() { - if (rtl_devices.find(current_fd) == rtl_devices.end()) return; - auto dev = rtl_devices.at(current_fd).get(); - if (dev) { + if (auto dev = getDevice(current_fd.load())) { dev->StopRxLoop(); } } From 664a03d9f319200cb5834e09a2b9a8d5e7de57fc Mon Sep 17 00:00:00 2001 From: dawitt50 Date: Mon, 19 Oct 2026 23:03:22 +0200 Subject: [PATCH 05/10] apfpv-open-static-cam.sh: refuse a missing MAC, never leave a half install - --allow-mac without a value stops instead of installing without the filter. - The accept file is written (and read back) before the patched adapter replaces the original; if replacing fails, the original is restored. - Pre-flight check that the adapter defines $master, which the ARP reset that replaces reset_single_ip_dhcp relies on. Co-Authored-By: Claude Opus 5.5 --- tools/apfpv-open-static-cam.sh | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/tools/apfpv-open-static-cam.sh b/tools/apfpv-open-static-cam.sh index d3438924..b0b28d11 100755 --- a/tools/apfpv-open-static-cam.sh +++ b/tools/apfpv-open-static-cam.sh @@ -42,7 +42,10 @@ while [ $# -gt 0 ]; do case "$1" in --revert) REVERT=1 ;; --no-restart) RESTART=0 ;; - --allow-mac) shift; MAC=$(echo "$1" | tr 'A-F' 'a-f') ;; + --allow-mac) + # Without a value the filter would silently be left out. + [ -n "${2:-}" ] || die "--allow-mac needs a MAC address, nothing changed." + shift; MAC=$(echo "$1" | tr 'A-F' 'a-f') ;; *) die "unknown option: $1" ;; esac shift @@ -92,6 +95,8 @@ for pat in '^wpa_passphrase=\$pass$' '^wpa=2$' '^rsn_pairwise=CCMP$' '^disassoc_ '^ udhcpd -S /etc/udhcpd.conf$' '^ reset_single_ip_dhcp "\$1"$'; do [ "$(grep -c "$pat" "$ADAPTER")" = 1 ] || die "spot '$pat' not found exactly once - nothing changed." done +# The ARP reset that replaces reset_single_ip_dhcp uses the adapter's $master. +grep -qE '^[[:space:]]*master=' "$ADAPTER" || die "\$master not set in $ADAPTER - nothing changed." cp "$ADAPTER" "$BACKUP" || die "backup to $BACKUP failed" TMP="$ADAPTER.tmp.$$" @@ -115,8 +120,14 @@ sh -n "$TMP" || { rm -f "$TMP"; die "syntax error in the result, nothing changed [ "$(grep -c "$MARK" "$TMP")" -ge 4 ] || { rm -f "$TMP"; die "not all spots replaced, nothing changed."; } grep -q '^disassoc_low_ack=0$' "$TMP" || { rm -f "$TMP"; die "disassoc_low_ack not replaced, nothing changed."; } -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)."; } +# The accept file goes first: the patched adapter must never be in place without it. +if [ -n "$MAC" ]; then + echo "$MAC" > "$ACCEPT" && [ "$(cat "$ACCEPT")" = "$MAC" ] || + { rm -f "$TMP" "$ACCEPT"; die "writing $ACCEPT failed, nothing changed."; } +fi +mv "$TMP" "$ADAPTER" && chmod 755 "$ADAPTER" || + { cp "$BACKUP" "$ADAPTER"; rm -f "$TMP"; [ -n "$MAC" ] && rm -f "$ACCEPT"; die "writing $ADAPTER failed, original restored."; } +[ -n "$MAC" ] && echo "Only $MAC may join ($ACCEPT)." echo "Installed. Backup: $BACKUP" killall -q udhcpd 2>/dev/null From 6138ce31b4a412d018d1d1ac0cc12f8773f32653 Mon Sep 17 00:00:00 2001 From: dawitt50 Date: Mon, 19 Oct 2026 23:03:24 +0200 Subject: [PATCH 06/10] Format the APFPV sources with clang-format 18.1.8 No functional change; output of scripts/format_sources.sh for the files this branch adds or touches. Co-Authored-By: Claude Opus 5.5 --- app/wfbngrtl8812/src/main/cpp/StaLink.cpp | 203 +++++++++++++------- app/wfbngrtl8812/src/main/cpp/StaLink.h | 24 +-- app/wfbngrtl8812/src/main/cpp/WfbngLink.cpp | 57 +++--- app/wfbngrtl8812/src/main/cpp/WfbngLink.hpp | 2 +- 4 files changed, 175 insertions(+), 111 deletions(-) diff --git a/app/wfbngrtl8812/src/main/cpp/StaLink.cpp b/app/wfbngrtl8812/src/main/cpp/StaLink.cpp index 8f03bcea..1fd9a5ca 100644 --- a/app/wfbngrtl8812/src/main/cpp/StaLink.cpp +++ b/app/wfbngrtl8812/src/main/cpp/StaLink.cpp @@ -76,13 +76,15 @@ bool macEq(const uint8_t *a, const uint8_t *b) { return std::memcmp(a, b, 6) == // One's-complement sum over big-endian 16-bit words, for IP/ICMP/TCP checksums. uint32_t csumAdd(uint32_t s, const uint8_t *d, size_t len) { - for (size_t i = 0; i + 1 < len; i += 2) s += (uint32_t)(d[i] << 8) | d[i + 1]; + for (size_t i = 0; i + 1 < len; i += 2) + s += (uint32_t)(d[i] << 8) | d[i + 1]; if (len & 1) s += (uint32_t)d[len - 1] << 8; return s; } uint16_t csumFold(uint32_t s) { - while (s >> 16) s = (s & 0xffff) + (s >> 16); + while (s >> 16) + s = (s & 0xffff) + (s >> 16); return (uint16_t)~s; } @@ -147,7 +149,7 @@ const char *stateName(StaLink::State s) { } // namespace StaLink::StaLink(IRtlDevice *dev, StaConfig cfg) - : dev_(dev), cfg_(std::move(cfg)), channel_(cfg_.channel), home_channel_(cfg_.channel) { + : dev_(dev), cfg_(std::move(cfg)), channel_(cfg_.channel), home_channel_(cfg_.channel) { buildScanList(); radiotap_ = devourer::build_stream_radiotap(devourer::parse_tx_mode_str("6M")); udp_sock_ = socket(AF_INET, SOCK_DGRAM, 0); @@ -247,12 +249,16 @@ std::string StaLink::statusText() const { std::string text = buf; const auto silence = Clock::now() - last_heard_; if (s == State::Connected && silence >= kDropoutGap) { - snprintf(buf, sizeof(buf), " | no signal %.1f s", - std::chrono::duration(silence).count()); + snprintf(buf, sizeof(buf), " | no signal %.1f s", std::chrono::duration(silence).count()); text += buf; } if (have_camera_ip_) { - snprintf(buf, sizeof(buf), " | video from %d.%d.%d.%d", camera_ip_[0], camera_ip_[1], camera_ip_[2], + snprintf(buf, + sizeof(buf), + " | video from %d.%d.%d.%d", + camera_ip_[0], + camera_ip_[1], + camera_ip_[2], camera_ip_[3]); text += buf; } @@ -425,7 +431,12 @@ void StaLink::reconnect(const char *why) { TAG, "%s: rejoining %02x:%02x:%02x:%02x:%02x:%02x on ch %d", why, - bssid_[0], bssid_[1], bssid_[2], bssid_[3], bssid_[4], bssid_[5], + bssid_[0], + bssid_[1], + bssid_[2], + bssid_[3], + bssid_[4], + bssid_[5], channel_); aid_ = 0; last_rx_seq_ = -1; @@ -440,9 +451,11 @@ void StaLink::buildScanList() { } }; add(home_channel_); - for (uint8_t ch : cfg_.candidate_channels) add(ch); + for (uint8_t ch : cfg_.candidate_channels) + add(ch); if (cfg_.full_scan) { - for (uint8_t ch : kScanChannels) add(ch); + for (uint8_t ch : kScanChannels) + add(ch); } scan_index_ = 0; } @@ -550,12 +563,8 @@ void StaLink::tick() { if (csa_channel_ > 0 && state_.load() != State::Searching && now >= csa_at_) { // The AP announced a channel switch and is leaving now; go with it. The // association survives a CSA, so the state stays as it is. - __android_log_print(ANDROID_LOG_INFO, - TAG, - "following the AP: ch %d -> ch %d at %d MHz", - channel_, - csa_channel_, - csa_width_); + __android_log_print( + ANDROID_LOG_INFO, TAG, "following the AP: ch %d -> ch %d at %d MHz", channel_, csa_channel_, csa_width_); requestTune(csa_channel_, csa_width_, csa_offset_); last_width_change_ = now; // the beacon check would otherwise second-guess it mid-switch home_channel_ = static_cast(csa_channel_); @@ -650,7 +659,8 @@ void StaLink::report(Clock::time_point now) { std::string ports; for (const auto &[port, n] : udp_by_port_) { bool fwd = false; - for (const auto &f : cfg_.forward_ports) fwd |= f.first == port; + for (const auto &f : cfg_.forward_ports) + fwd |= f.first == port; char b[48]; snprintf(b, sizeof(b), " %u:%u%s", port, n, fwd ? "" : "(not forwarded)"); ports += b; @@ -732,11 +742,12 @@ void StaLink::onRx(const Packet &packet) { // Dropout over: video flows again right now, nothing to rebuild. in_gap_ = false; recoveries_++; - __android_log_print(ANDROID_LOG_INFO, - TAG, - "AP back after %lld ms on ch %d", - (long long)std::chrono::duration_cast(now - last_heard_).count(), - channel_); + __android_log_print( + ANDROID_LOG_INFO, + TAG, + "AP back after %lld ms on ch %d", + (long long)std::chrono::duration_cast(now - last_heard_).count(), + channel_); if (channel_ != home_channel_) { // Heard while peeking at a candidate: the camera lives here now. home_channel_ = channel_; @@ -759,7 +770,8 @@ void StaLink::onRx(const Packet &packet) { handleBeacon(f, len, packet.RxAtrib.rssi[0]); } else if (from_ap && len > 36) { uint64_t tsf = 0; - for (int i = 7; i >= 0; i--) tsf = (tsf << 8) | f[24 + i]; + for (int i = 7; i >= 0; i--) + tsf = (tsf << 8) | f[24 + i]; if (state_.load() == State::Connected && last_tsf_ != 0 && tsf + kTsfRestartUs < last_tsf_) { // A restarted hostapd no longer knows us; don't wait for its deauth. last_tsf_ = tsf; @@ -878,13 +890,12 @@ void StaLink::handleBeacon(const uint8_t *f, size_t len, uint8_t rssi) { std::memcpy(bssid.data(), f + 16, 6); if (seen_.size() >= kMaxSeenNetworks && seen_.find(bssid) == seen_.end()) { // Forget the stalest entry rather than growing without bound on a busy band. - auto oldest = std::min_element(seen_.begin(), seen_.end(), [](const auto &a, const auto &b) { - return a.second.last < b.second.last; - }); + auto oldest = std::min_element( + seen_.begin(), seen_.end(), [](const auto &a, const auto &b) { return a.second.last < b.second.last; }); seen_.erase(oldest); } - seen_[bssid] = SeenNetwork{ssid, ds_channel != -1 ? ds_channel : channel_, (capability & 0x0010) != 0, - rssi, Clock::now()}; + seen_[bssid] = + SeenNetwork{ssid, ds_channel != -1 ? ds_channel : channel_, (capability & 0x0010) != 0, rssi, Clock::now()}; if (!ssid_match) return; // On 2.4 GHz neighbouring channels leak in; only join on the channel we are tuned to. @@ -892,10 +903,8 @@ void StaLink::handleBeacon(const uint8_t *f, size_t len, uint8_t rssi) { if (capability & 0x0010) { if (!warned_encrypted_) { warned_encrypted_ = true; - __android_log_print(ANDROID_LOG_ERROR, - TAG, - "\"%s\" is encrypted, only open networks are supported", - cfg_.ssid.c_str()); + __android_log_print( + ANDROID_LOG_ERROR, TAG, "\"%s\" is encrypted, only open networks are supported", cfg_.ssid.c_str()); } return; } @@ -930,7 +939,8 @@ void StaLink::handleBeacon(const uint8_t *f, size_t len, uint8_t rssi) { vht, wmm); last_tsf_ = 0; - for (int i = 7; i >= 0; i--) last_tsf_ = (last_tsf_ << 8) | f[24 + i]; + for (int i = 7; i >= 0; i--) + last_tsf_ = (last_tsf_ << 8) | f[24 + i]; // Straight from the RX callback (only queued here; the worker sends it), which // saves up to one tick per handshake step. beginAuth(); @@ -1062,9 +1072,9 @@ void StaLink::handleMsdu(uint16_t ethertype, const uint8_t *payload, size_t len, // still has holes. std::vector StaLink::reassemble(const uint8_t *ip, size_t ihl, size_t len) { const auto now = Clock::now(); - frags_.erase(std::remove_if(frags_.begin(), frags_.end(), - [now](const IpFragments &f) { return now - f.start > 1s; }), - frags_.end()); + frags_.erase( + std::remove_if(frags_.begin(), frags_.end(), [now](const IpFragments &f) { return now - f.start > 1s; }), + frags_.end()); uint32_t src; std::memcpy(&src, ip + 12, 4); @@ -1076,8 +1086,8 @@ std::vector StaLink::reassemble(const uint8_t *ip, size_t ihl, size_t l if (off + plen + ihl > 65535) return {}; if (more && (plen % 8) != 0) return {}; // only the last fragment may end off a unit boundary - auto it = std::find_if(frags_.begin(), frags_.end(), - [src, id](const IpFragments &f) { return f.src == src && f.id == id; }); + auto it = std::find_if( + frags_.begin(), frags_.end(), [src, id](const IpFragments &f) { return f.src == src && f.id == id; }); if (it == frags_.end()) { if (frags_.size() >= 8) frags_.erase(frags_.begin()); frags_.push_back(IpFragments{src, id, {}, {}, {}, 0, now}); @@ -1087,7 +1097,8 @@ std::vector StaLink::reassemble(const uint8_t *ip, size_t ihl, size_t l std::memcpy(it->payload.data() + off, ip + ihl, plen); const size_t first_unit = off / 8, end_unit = (off + plen + 7) / 8; if (it->got.size() < end_unit) it->got.resize(end_unit, false); - for (size_t u = first_unit; u < end_unit; u++) it->got[u] = true; + for (size_t u = first_unit; u < end_unit; u++) + it->got[u] = true; if (off == 0) it->header.assign(ip, ip + ihl); if (!more) it->total = off + plen; if (it->total == 0 || it->header.empty()) return {}; @@ -1130,9 +1141,18 @@ void StaLink::handleArp(const uint8_t *a, size_t len) { TAG, "%d.%d.%d.%d asks for %d.%d.%d.%d (our IP is %d.%d.%d.%d). " "If that is the camera's stream target, set it as the phone IP.", - spa[0], spa[1], spa[2], spa[3], - tpa[0], tpa[1], tpa[2], tpa[3], - cfg_.own_ip[0], cfg_.own_ip[1], cfg_.own_ip[2], cfg_.own_ip[3]); + spa[0], + spa[1], + spa[2], + spa[3], + tpa[0], + tpa[1], + tpa[2], + tpa[3], + cfg_.own_ip[0], + cfg_.own_ip[1], + cfg_.own_ip[2], + cfg_.own_ip[3]); } } @@ -1181,7 +1201,10 @@ void StaLink::handleIpv4(const uint8_t *ip, size_t len, const uint8_t *src_mac) TAG, "first UDP datagram for port %d: from %d.%d.%d.%d:%d, %zu bytes", dport, - ip[12], ip[13], ip[14], ip[15], + ip[12], + ip[13], + ip[14], + ip[15], (l4[0] << 8) | l4[1], ulen - 8); } @@ -1196,7 +1219,10 @@ void StaLink::handleIpv4(const uint8_t *ip, size_t len, const uint8_t *src_mac) __android_log_print(ANDROID_LOG_INFO, TAG, "receiving UDP from %d.%d.%d.%d:%d on port %d", - ip[12], ip[13], ip[14], ip[15], + ip[12], + ip[13], + ip[14], + ip[15], (l4[0] << 8) | l4[1], dport); } @@ -1214,7 +1240,10 @@ void StaLink::handleIpv4(const uint8_t *ip, size_t len, const uint8_t *src_mac) __android_log_print(ANDROID_LOG_WARN, TAG, "UDP from %d.%d.%d.%d to port %d is not forwarded (video port setting?)", - ip[12], ip[13], ip[14], ip[15], + ip[12], + ip[13], + ip[14], + ip[15], dport); } } else if (proto == 1 && for_us && l4len >= 8 && len <= kMaxEchoDatagram && l4[0] == 8) { // ICMP echo request @@ -1283,12 +1312,8 @@ bool StaLink::tcpRequest(const std::array &ip, s.last_tx = Clock::now(); tcp_ = std::move(s); - __android_log_print(ANDROID_LOG_INFO, - TAG, - "TCP %d.%d.%d.%d:%d <- \"%s\"", - ip[0], ip[1], ip[2], ip[3], - port, - request.c_str()); + __android_log_print( + ANDROID_LOG_INFO, TAG, "TCP %d.%d.%d.%d:%d <- \"%s\"", ip[0], ip[1], ip[2], ip[3], port, request.c_str()); auto known = arp_cache_.find(ipKey(ip.data())); if (known != arp_cache_.end()) { tcp_.mac = known->second; @@ -1307,7 +1332,10 @@ bool StaLink::tcpRequest(const std::array &ip, __android_log_print(ANDROID_LOG_INFO, TAG, "TCP %d.%d.%d.%d:%d -> %s \"%s\"", - ip[0], ip[1], ip[2], ip[3], + ip[0], + ip[1], + ip[2], + ip[3], port, tcp_.st == St::Done ? "done" : "failed", reply.c_str()); @@ -1355,10 +1383,8 @@ void StaLink::tcpTick(Clock::time_point now) { break; case St::Established: if (!tcp_.data_acked) { - tcpSend(kTcpAck | kTcpPsh | (tcp_.half_close ? kTcpFin : 0), - tcp_.snd_nxt, - tcp_.data.data(), - tcp_.data.size()); + tcpSend( + kTcpAck | kTcpPsh | (tcp_.half_close ? kTcpFin : 0), tcp_.snd_nxt, tcp_.data.data(), tcp_.data.size()); } break; default: @@ -1403,10 +1429,7 @@ void StaLink::handleTcp(const uint8_t *ip, size_t ihl, size_t len) { // The ACK of the handshake rides on the request. A half-closing request ends // our side right away, like `echo cmd | nc` does: some line servers only act // on end of input. - tcpSend(kTcpAck | kTcpPsh | (tcp_.half_close ? kTcpFin : 0), - tcp_.snd_nxt, - tcp_.data.data(), - tcp_.data.size()); + tcpSend(kTcpAck | kTcpPsh | (tcp_.half_close ? kTcpFin : 0), tcp_.snd_nxt, tcp_.data.data(), tcp_.data.size()); return; } @@ -1503,10 +1526,30 @@ uint16_t StaLink::nextSeq() { std::vector StaLink::mgmtHeader(uint8_t fc0, const uint8_t *addr1, const uint8_t *addr3) { const uint8_t *own = cfg_.own_mac.data(); const uint16_t seq = nextSeq(); - return {fc0, 0x00, 0x00, 0x00, addr1[0], addr1[1], addr1[2], - addr1[3], addr1[4], addr1[5], own[0], own[1], own[2], own[3], - own[4], own[5], addr3[0], addr3[1], addr3[2], addr3[3], addr3[4], - addr3[5], (uint8_t)(seq & 0xff), (uint8_t)(seq >> 8)}; + return {fc0, + 0x00, + 0x00, + 0x00, + addr1[0], + addr1[1], + addr1[2], + addr1[3], + addr1[4], + addr1[5], + own[0], + own[1], + own[2], + own[3], + own[4], + own[5], + addr3[0], + addr3[1], + addr3[2], + addr3[3], + addr3[4], + addr3[5], + (uint8_t)(seq & 0xff), + (uint8_t)(seq >> 8)}; } // Supported Rates (max 8) + Extended Supported Rates. Mirrors the AP's own set @@ -1585,12 +1628,34 @@ void StaLink::sendAssocRequest() { void StaLink::sendArp(uint16_t oper, const uint8_t *target_mac, const uint8_t *target_ip, const uint8_t *dst_mac) { const uint8_t *own = cfg_.own_mac.data(); const uint8_t *ip = cfg_.own_ip.data(); - const uint8_t a[28] = {0x00, 0x01, 0x08, 0x00, 6, - 4, (uint8_t)(oper >> 8), (uint8_t)(oper & 0xff), own[0], - own[1], own[2], own[3], own[4], own[5], - ip[0], ip[1], ip[2], ip[3], target_mac[0], - target_mac[1], target_mac[2], target_mac[3], target_mac[4], target_mac[5], - target_ip[0], target_ip[1], target_ip[2], target_ip[3]}; + const uint8_t a[28] = {0x00, + 0x01, + 0x08, + 0x00, + 6, + 4, + (uint8_t)(oper >> 8), + (uint8_t)(oper & 0xff), + own[0], + own[1], + own[2], + own[3], + own[4], + own[5], + ip[0], + ip[1], + ip[2], + ip[3], + target_mac[0], + target_mac[1], + target_mac[2], + target_mac[3], + target_mac[4], + target_mac[5], + target_ip[0], + target_ip[1], + target_ip[2], + target_ip[3]}; sendData(dst_mac, kEthArp, a, sizeof(a)); } diff --git a/app/wfbngrtl8812/src/main/cpp/StaLink.h b/app/wfbngrtl8812/src/main/cpp/StaLink.h index 86ae7ed9..5f993830 100644 --- a/app/wfbngrtl8812/src/main/cpp/StaLink.h +++ b/app/wfbngrtl8812/src/main/cpp/StaLink.h @@ -57,10 +57,10 @@ class StaLink { enum class State { Searching, Authenticating, Associating, Connected }; struct Stats { - uint32_t rx_data; // data frames from the AP addressed to us - uint32_t rx_udp; // UDP datagrams forwarded to localhost - uint32_t rx_dropped; // data frames we could not use (fragments, encrypted, ...) - uint32_t tx_frames; // frames handed to the driver + uint32_t rx_data; // data frames from the AP addressed to us + uint32_t rx_udp; // UDP datagrams forwarded to localhost + uint32_t rx_dropped; // data frames we could not use (fragments, encrypted, ...) + uint32_t tx_frames; // frames handed to the driver }; StaLink(IRtlDevice *dev, StaConfig cfg); @@ -182,17 +182,17 @@ class StaLink { std::vector scan_list_; // scan_list_[0] == home_channel_ size_t scan_index_{0}; Clock::time_point dwell_start_{}; - int retune_to_{-1}; // set via requestTune(), carried out by the worker outside mu_ + int retune_to_{-1}; // set via requestTune(), carried out by the worker outside mu_ int retune_width_{20}; uint8_t retune_offset_{0}; Clock::time_point last_width_change_{}; - bool ap_vht_{false}; // AP advertises VHT capabilities (needed for 80 MHz) - int csa_channel_{-1}; // announced by the AP, switched to at csa_at_ - int csa_width_{20}; // ... at this width (from the CSA's width elements) + bool ap_vht_{false}; // AP advertises VHT capabilities (needed for 80 MHz) + int csa_channel_{-1}; // announced by the AP, switched to at csa_at_ + int csa_width_{20}; // ... at this width (from the CSA's width elements) uint8_t csa_offset_{0}; Clock::time_point csa_at_{}; std::array bssid_{}; - std::vector ap_rates_; // AP's supported + extended rates (raw rate bytes) + std::vector ap_rates_; // AP's supported + extended rates (raw rate bytes) bool ap_ht_{false}; bool ap_wmm_{false}; bool warned_encrypted_{false}; @@ -221,8 +221,8 @@ class StaLink { Clock::time_point last_seen_log_{}; uint32_t frames_at_last_report_{0}; uint32_t frames_per_sec_{0}; - std::set warned_ports_; // UDP ports for us that nothing listens on - std::set warned_arp_targets_; // IPs the camera looks for that are not ours + std::set warned_ports_; // UDP ports for us that nothing listens on + std::set warned_arp_targets_; // IPs the camera looks for that are not ours bool have_camera_ip_{false}; std::array camera_ip_{}; std::atomic rx_frames_total_{0}; @@ -234,7 +234,7 @@ class StaLink { std::vector header; // from the offset-0 fragment std::vector payload; std::vector got; // one flag per 8-byte unit of payload that has arrived - size_t total{0}; // known once the last fragment arrived + size_t total{0}; // known once the last fragment arrived Clock::time_point start; }; std::vector frags_; diff --git a/app/wfbngrtl8812/src/main/cpp/WfbngLink.cpp b/app/wfbngrtl8812/src/main/cpp/WfbngLink.cpp index b94b0b65..704912ab 100644 --- a/app/wfbngrtl8812/src/main/cpp/WfbngLink.cpp +++ b/app/wfbngrtl8812/src/main/cpp/WfbngLink.cpp @@ -555,24 +555,24 @@ extern "C" JNIEXPORT void JNICALL Java_com_openipc_wfbngrtl8812_WfbNgLink_native } extern "C" JNIEXPORT void JNICALL Java_com_openipc_wfbngrtl8812_WfbNgLink_nativeSetLinkMode(JNIEnv *env, - jclass clazz, - jlong wfbngLinkN, - jint mode) { + jclass clazz, + jlong wfbngLinkN, + jint mode) { native(wfbngLinkN)->link_mode = (mode == static_cast(LinkMode::AP)) ? LinkMode::AP : LinkMode::WFB; } // Returns false (and keeps the previous config) when the IP or MAC does not parse. -extern "C" JNIEXPORT jboolean JNICALL Java_com_openipc_wfbngrtl8812_WfbNgLink_nativeSetApConfig( - JNIEnv *env, - jclass clazz, - jlong wfbngLinkN, - jstring ssid, - jstring ownIp, - jstring ownMac, - jint videoPort, - jintArray candidateChannels, - jboolean fullScan, - jint maxWidthMhz) { +extern "C" JNIEXPORT jboolean JNICALL +Java_com_openipc_wfbngrtl8812_WfbNgLink_nativeSetApConfig(JNIEnv *env, + jclass clazz, + jlong wfbngLinkN, + jstring ssid, + jstring ownIp, + jstring ownMac, + jint videoPort, + jintArray candidateChannels, + jboolean fullScan, + jint maxWidthMhz) { auto str = [env](jstring s) { const char *c = env->GetStringUTFChars(s, nullptr); std::string out(c ? c : ""); @@ -618,8 +618,8 @@ static std::string printableAscii(std::string s) { } extern "C" JNIEXPORT jstring JNICALL Java_com_openipc_wfbngrtl8812_WfbNgLink_nativeGetApStatus(JNIEnv *env, - jclass clazz, - jlong wfbngLinkN) { + jclass clazz, + jlong wfbngLinkN) { WfbngLink *link = native(wfbngLinkN); std::string text; // empty: no adapter running in AP mode { @@ -630,8 +630,8 @@ extern "C" JNIEXPORT jstring JNICALL Java_com_openipc_wfbngrtl8812_WfbNgLink_nat } extern "C" JNIEXPORT jint JNICALL Java_com_openipc_wfbngrtl8812_WfbNgLink_nativeGetApChannel(JNIEnv *env, - jclass clazz, - jlong wfbngLinkN) { + jclass clazz, + jlong wfbngLinkN) { WfbngLink *link = native(wfbngLinkN); std::lock_guard lock(link->sta_mutex); return link->sta_link ? link->sta_link->connectedChannel() : 0; @@ -639,8 +639,8 @@ extern "C" JNIEXPORT jint JNICALL Java_com_openipc_wfbngrtl8812_WfbNgLink_native // Grows each time video can flow again in AP mode (association, end of a dropout). extern "C" JNIEXPORT jint JNICALL Java_com_openipc_wfbngrtl8812_WfbNgLink_nativeGetApRecoveries(JNIEnv *env, - jclass clazz, - jlong wfbngLinkN) { + jclass clazz, + jlong wfbngLinkN) { WfbngLink *link = native(wfbngLinkN); std::lock_guard lock(link->sta_mutex); return static_cast(link->ap_recovery_base + (link->sta_link ? link->sta_link->recoveries() : 0)); @@ -648,15 +648,14 @@ extern "C" JNIEXPORT jint JNICALL Java_com_openipc_wfbngrtl8812_WfbNgLink_native // Sends one line to a TCP service on the camera (air_man_ap on 12355) over the AP // link and returns its reply, or null. Blocks: call from a background thread. -extern "C" JNIEXPORT jstring JNICALL Java_com_openipc_wfbngrtl8812_WfbNgLink_nativeCameraCommand( - JNIEnv *env, - jclass clazz, - jlong wfbngLinkN, - jstring cameraIp, - jint port, - jstring command, - jint timeoutMs, - jboolean halfClose) { +extern "C" JNIEXPORT jstring JNICALL Java_com_openipc_wfbngrtl8812_WfbNgLink_nativeCameraCommand(JNIEnv *env, + jclass clazz, + jlong wfbngLinkN, + jstring cameraIp, + jint port, + jstring command, + jint timeoutMs, + jboolean halfClose) { WfbngLink *link = native(wfbngLinkN); std::shared_ptr sta; { diff --git a/app/wfbngrtl8812/src/main/cpp/WfbngLink.hpp b/app/wfbngrtl8812/src/main/cpp/WfbngLink.hpp index 84b808ae..9c9dc214 100644 --- a/app/wfbngrtl8812/src/main/cpp/WfbngLink.hpp +++ b/app/wfbngrtl8812/src/main/cpp/WfbngLink.hpp @@ -42,7 +42,7 @@ class WfbngLink { // Takes effect on the next run(); the Java side restarts the adapters after changing it. std::atomic link_mode{LinkMode::WFB}; std::mutex sta_mutex; - StaConfig sta_config; // guarded by sta_mutex + StaConfig sta_config; // guarded by sta_mutex // Guarded by sta_mutex; set while an AP-mode run() is active. Shared so a camera // command can keep using it without holding sta_mutex for seconds; run() stops it // before the device goes away, after which every call on it is a no-op. From fced01571c1f7aa62104359578257cadea3994ff Mon Sep 17 00:00:00 2001 From: dawitt50 Date: Mon, 19 Oct 2026 23:03:44 +0200 Subject: [PATCH 07/10] APFPV mode: tighten the review fixes (TX power, TCP deadline, cut-off replies) - AP mode never calls SetTxPower on the device from a JNI thread: without a running StaLink the run thread is bringing the adapter up or tearing it down, and runStation() applies adaptive_tx_power itself during bring-up. - tcpRequest(): one deadline covers waiting for the session slot and the exchange, so a caller no longer waits up to twice its timeout. - A reply over 64 KiB ends the request at once instead of re-ACKing until the deadline and then returning a truncated reply as success. - An HTTP reply that is cut off is a failure on every path: fewer bytes than "HTTP/", and a FIN or RST before Content-Length is reached. - CameraControlDialog.cgi() also retries a reply without a JSON body. Co-Authored-By: Claude Opus 5.5 --- .../pixelpilot/CameraControlDialog.java | 3 ++- app/wfbngrtl8812/src/main/cpp/StaLink.cpp | 24 ++++++++++++++----- app/wfbngrtl8812/src/main/cpp/StaLink.h | 7 +++--- app/wfbngrtl8812/src/main/cpp/WfbngLink.cpp | 8 +++---- 4 files changed, 28 insertions(+), 14 deletions(-) diff --git a/app/src/main/java/com/openipc/pixelpilot/CameraControlDialog.java b/app/src/main/java/com/openipc/pixelpilot/CameraControlDialog.java index 8f99df3d..873bdbcb 100644 --- a/app/src/main/java/com/openipc/pixelpilot/CameraControlDialog.java +++ b/app/src/main/java/com/openipc/pixelpilot/CameraControlDialog.java @@ -23,6 +23,7 @@ import com.openipc.wfbngrtl8812.WfbNgLink; +import org.json.JSONException; import org.json.JSONObject; import java.net.URLEncoder; @@ -530,8 +531,8 @@ private JSONObject cgi(String pathAndQuery) { return null; } int brace = reply.indexOf('{', reply.indexOf("\r\n\r\n") + 1); - if (brace < 0) return null; try { + if (brace < 0) throw new JSONException("no JSON body"); return new JSONObject(reply.substring(brace).trim()); } catch (Exception e) { // Most likely a reply that was cut off on the way: ask again. diff --git a/app/wfbngrtl8812/src/main/cpp/StaLink.cpp b/app/wfbngrtl8812/src/main/cpp/StaLink.cpp index 1fd9a5ca..35dae8f7 100644 --- a/app/wfbngrtl8812/src/main/cpp/StaLink.cpp +++ b/app/wfbngrtl8812/src/main/cpp/StaLink.cpp @@ -121,7 +121,10 @@ constexpr size_t kMaxSeenNetworks = 64; // True for an HTTP reply whose headers or body are not all there yet. Anything that // is not HTTP (air_man_ap speaks plain lines) counts as complete. bool httpReplyIncomplete(const std::string &rx) { - if (rx.compare(0, 5, "HTTP/") != 0) return false; + static const std::string kHttp = "HTTP/"; + // Fewer bytes than the status line prefix: still HTTP if they match its start. + if (rx.size() < kHttp.size()) return !rx.empty() && kHttp.compare(0, rx.size(), rx) == 0; + if (rx.compare(0, kHttp.size(), kHttp) != 0) return false; const auto hdr_end = rx.find("\r\n\r\n"); if (hdr_end == std::string::npos) return true; std::string head = rx.substr(0, hdr_end); @@ -1295,8 +1298,10 @@ bool StaLink::tcpRequest(const std::array &ip, std::unique_lock lock(mu_); // One session slot: a caller that finds it busy (e.g. the key-frame request after a // recovery) waits its turn instead of being told the camera did not answer. + // `timeout` covers the wait and the exchange together, so the caller never waits longer. + const auto deadline = Clock::now() + timeout; const auto usable = [this] { return running_ && state_.load() == State::Connected; }; - if (!tcp_cv_.wait_for(lock, timeout, [&] { return !usable() || tcp_.st == St::Idle; })) return false; + if (!tcp_cv_.wait_until(lock, deadline, [&] { return !usable() || tcp_.st == St::Idle; })) return false; if (!usable()) return false; std::random_device rd; @@ -1308,7 +1313,7 @@ bool StaLink::tcpRequest(const std::array &ip, s.snd_nxt = s.iss + 1; s.half_close = half_close; s.data.assign(request.begin(), request.end()); - s.deadline = Clock::now() + timeout; + s.deadline = deadline; s.last_tx = Clock::now(); tcp_ = std::move(s); @@ -1415,7 +1420,7 @@ void StaLink::handleTcp(const uint8_t *ip, size_t ihl, size_t len) { if (flags & kTcpRst) { __android_log_print(ANDROID_LOG_WARN, TAG, "TCP connection reset by the peer"); - tcp_.st = tcp_.rx.empty() ? St::Failed : St::Done; + tcp_.st = tcp_.rx.empty() || httpReplyIncomplete(tcp_.rx) ? St::Failed : St::Done; tcp_cv_.notify_all(); return; } @@ -1434,7 +1439,13 @@ void StaLink::handleTcp(const uint8_t *ip, size_t ihl, size_t len) { } if ((flags & kTcpAck) && static_cast(ack - our_end) >= 0) tcp_.data_acked = true; - if (plen > 0 && seq == tcp_.rcv_nxt && tcp_.rx.size() + plen <= kTcpMaxReply) { + if (plen > 0 && seq == tcp_.rcv_nxt) { + if (tcp_.rx.size() + plen > kTcpMaxReply) { + // Not a camera reply; give up now instead of re-ACKing until the deadline. + __android_log_print(ANDROID_LOG_WARN, TAG, "TCP reply over %zu bytes, dropping it", kTcpMaxReply); + tcpFinish(false); + return; + } tcp_.rx.append(reinterpret_cast(payload), plen); tcp_.rcv_nxt += static_cast(plen); tcp_.last_rx = now; @@ -1448,7 +1459,8 @@ void StaLink::handleTcp(const uint8_t *ip, size_t ihl, size_t len) { } else { tcpSend(kTcpFin | kTcpAck, data_end, nullptr, 0); } - tcp_.st = St::Done; + // Closed before the Content-Length was reached: a cut-off reply, not an answer. + tcp_.st = httpReplyIncomplete(tcp_.rx) ? St::Failed : St::Done; tcp_cv_.notify_all(); return; } diff --git a/app/wfbngrtl8812/src/main/cpp/StaLink.h b/app/wfbngrtl8812/src/main/cpp/StaLink.h index 5f993830..9a43c10f 100644 --- a/app/wfbngrtl8812/src/main/cpp/StaLink.h +++ b/app/wfbngrtl8812/src/main/cpp/StaLink.h @@ -91,9 +91,10 @@ class StaLink { // Opens a TCP connection to ip:port, sends `request` and collects the reply // until the peer closes, goes quiet after answering, or `timeout` passes. // half_close ends our sending side with the request (as `echo cmd | nc` does). - // Blocks the caller (not an RX or worker thread). Waits (within `timeout`) for a - // request that is already running. Returns false when not connected, the slot - // stayed busy, the reply is missing, or an HTTP reply was cut off. + // Blocks the caller (not an RX or worker thread). Waits for a request that is + // already running; `timeout` covers that wait and the exchange together. + // Returns false when not connected, the slot stayed busy, the reply is + // missing, or an HTTP reply was cut off. bool tcpRequest(const std::array &ip, uint16_t port, const std::string &request, diff --git a/app/wfbngrtl8812/src/main/cpp/WfbngLink.cpp b/app/wfbngrtl8812/src/main/cpp/WfbngLink.cpp index 704912ab..4967334f 100644 --- a/app/wfbngrtl8812/src/main/cpp/WfbngLink.cpp +++ b/app/wfbngrtl8812/src/main/cpp/WfbngLink.cpp @@ -690,10 +690,10 @@ void WfbngLink::applyTxPower(int fd, int power) { std::lock_guard lock(sta_mutex); sta = sta_link; } - if (sta) { - sta->requestTxPower(power); - return; - } + if (sta) sta->requestTxPower(power); + // Without a running station the device is being brought up or torn down on the + // run thread; runStation() applies adaptive_tx_power itself during bring-up. + return; } if (auto dev = getDevice(fd)) { dev->SetTxPower(power); From 1ec4e2e9f6d0501ae79605abc6e424f26ccad62e Mon Sep 17 00:00:00 2001 From: dawitt50 Date: Mon, 19 Oct 2026 23:28:09 +0200 Subject: [PATCH 08/10] README: describe the security model of the APFPV link mode Open access point without PMF (readable and forgeable traffic, spoofed deauth/beacons), the --allow-mac install and its limits, and the Drone login going out as cleartext Basic auth, also with every automatic key-frame request. Co-Authored-By: Claude Opus 5.5 --- README.md | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/README.md b/README.md index 4f0a83e1..dc06fcbb 100644 --- a/README.md +++ b/README.md @@ -128,6 +128,20 @@ ARP and ping for its IP and sends a keepalive every 5 s. On RTL8812AU adapters d frame without hardware retries, so the few frames the phone sends (association, ARP) rely on software retries; the video direction is acknowledged in hardware on all chips. +Security: the camera's access point is open, without encryption and without protected +management frames (PMF), so everything on it can be read and forged by anyone in radio range. +Video and MAVLink go out unencrypted. A spoofed deauth makes the app rejoin, and a spoofed beacon +with the camera's SSID can draw it to another channel or access point. Install with a MAC filter +for the adapter (its address is in **APFPV settings**), so other stations cannot join the camera: +``` +ssh root@192.168.0.1 sh -s -- --allow-mac aa:bb:cc:dd:ee:ff < tools/apfpv-open-static-cam.sh +``` +The filter only keeps honest stations out; an attacker can copy the adapter's address from the air. +The Drone login is sent as HTTP Basic auth, i.e. in cleartext, with every camera control request +and with the key-frame request after each (re)association and each end of a dropout. On the +apfpv firmware that is usually the camera's root login: use APFPV mode only where nobody else is +listening, or give the camera a password that is not used anywhere else. + ## Quick Reference: Auto-FEC Levels 0 – 5 | Level | Denominator | Added Redundancy | From e0b0cdd52d6363372690e0d7742f3cd6d39673da Mon Sep 17 00:00:00 2001 From: dawitt50 Date: Mon, 19 Oct 2026 23:43:53 +0200 Subject: [PATCH 09/10] Camera controls: send rebooting actions only once Reboot, the hostapd start channel and the SSID change reboot the camera, which often cuts its own answer off. cgi() retried on that and could reboot the camera a second time. These actions now go out once; a missing answer is reported as "may already be rebooting", and a new SSID is not adopted unless the camera confirmed it. Co-Authored-By: Claude Opus 5.5 --- .../pixelpilot/CameraControlDialog.java | 36 ++++++++++++++----- 1 file changed, 27 insertions(+), 9 deletions(-) diff --git a/app/src/main/java/com/openipc/pixelpilot/CameraControlDialog.java b/app/src/main/java/com/openipc/pixelpilot/CameraControlDialog.java index 873bdbcb..67b00748 100644 --- a/app/src/main/java/com/openipc/pixelpilot/CameraControlDialog.java +++ b/app/src/main/java/com/openipc/pixelpilot/CameraControlDialog.java @@ -313,9 +313,12 @@ private void buildSystemSection(LinearLayout root) { () -> run("SSID", () -> { // The CGI insists on a passphrase; the open-network adapter ignores it. String pass = wlanPass.isEmpty() ? "12345678" : wlanPass; - String r = result(cgi(WLAN + "?action=set&ssid=" + enc(ssid) + "&pass=" + enc(pass))); + JSONObject reply = cgiOnce(WLAN + "?action=set&ssid=" + enc(ssid) + "&pass=" + enc(pass)); + String r = result(reply); if (r != null && r.contains("ok")) main.post(() -> listener.onSsidChanged(ssid)); - return r; + // Not adopted blindly: if the camera did not take it, the app would lose it. + return reply != null ? r : rebootResult(null) + "; if it comes back as \"" + ssid + + "\", set that SSID in APFPV settings"; })); })); root.addView(ssidLine); @@ -324,11 +327,11 @@ private void buildSystemSection(LinearLayout root) { "Start the access point on channel " + value + "?", "Changes the channel hostapd starts on (before the switch to the stored channel) and " + "reboots the camera.", - () -> run("Start channel", () -> result(cgi(BOOT_CHANNEL + "?action=set&channel=" + value))))); + () -> run("Start channel", () -> rebootResult(cgiOnce(BOOT_CHANNEL + "?action=set&channel=" + value))))); LinearLayout bootLine = line(); bootLine.addView(button("Restore 157", v -> confirm("Restore the default start channel 157?", "The camera reboots.", - () -> run("Start channel", () -> result(cgi(BOOT_CHANNEL + "?action=restore")))))); + () -> run("Start channel", () -> rebootResult(cgiOnce(BOOT_CHANNEL + "?action=restore")))))); root.addView(bootLine); LinearLayout rebootLine = line(); @@ -338,7 +341,7 @@ private void buildSystemSection(LinearLayout root) { })); rebootLine.addView(button("Reboot camera", v -> confirm("Reboot the camera?", "The link drops and the app rejoins once the camera is back.", - () -> run("Reboot", () -> result(cgi(MAIN_PAGE + "?action=reboot")))))); + () -> run("Reboot", () -> rebootResult(cgiOnce(MAIN_PAGE + "?action=reboot")))))); root.addView(rebootLine); } @@ -517,11 +520,29 @@ private String airmanOr(String command, String fallback) { /** GET on the camera's web API with the drone login; the JSON body, or null. */ private JSONObject cgi(String pathAndQuery) { + return cgi(pathAndQuery, 3); + } + + /** + * Like cgi(), but sent only once: for actions that reboot the camera. Its answer is often cut + * off by the reboot itself, and asking again would reboot it a second time. + */ + private JSONObject cgiOnce(String pathAndQuery) { + return cgi(pathAndQuery, 1); + } + + /** Result text of a rebooting action; a missing answer does not mean it was not done. */ + private String rebootResult(JSONObject reply) { + return reply != null ? result(reply) : "sent, no complete answer - the camera may already be rebooting"; + } + + private JSONObject cgi(String pathAndQuery, int attempts) { String request = "GET " + pathAndQuery + " HTTP/1.0\r\n" + "Host: " + cameraIp + "\r\n" + "Authorization: Basic " + auth + "\r\n" + "Connection: close\r\n\r\n"; - for (int attempt = 0; attempt < 3; attempt++) { + for (int attempt = 0; attempt < attempts; attempt++) { + if (attempt > 0 && !pause(400)) break; String reply = transport.request(cameraIp, HTTP_PORT, request, false, TIMEOUT_MS); if (reply != null) { int nl = reply.indexOf('\n'); @@ -537,11 +558,8 @@ private JSONObject cgi(String pathAndQuery) { } catch (Exception e) { // Most likely a reply that was cut off on the way: ask again. Log.w(TAG, "camera " + pathAndQuery + ": unreadable answer", e); - if (!pause(400)) break; - continue; } } - if (!pause(400)) break; } return null; } From e5d34f756c16f1f5faf9e7b2adec6ad69104fa84 Mon Sep 17 00:00:00 2001 From: dawitt50 Date: Mon, 19 Oct 2026 23:43:54 +0200 Subject: [PATCH 10/10] APFPV mode: retry refused retunes, end HTTP replies on FIN, stop under the device lock - A channel switch the driver refuses is requested again (up to 3 times) unless a newer one is pending. After a refused switch to a channel the camera announced, the station would otherwise stay behind: the search after a dropout only looks at the camera's boot channels. - HTTP requests (HTTP/1.0, Connection: close) count as answered only when the camera closes the connection, not after the idle gap used for air_man_ap. Replaces the Content-Length parsing. - stop() and the TX power setter call into the device under dev_mutex (StopRxLoop() only sets a flag) instead of holding shared_ptr copies, so the device is always destroyed on the run thread before libusb_exit(). Co-Authored-By: Claude Opus 5.5 --- app/wfbngrtl8812/src/main/cpp/StaLink.cpp | 50 ++++++++++----------- app/wfbngrtl8812/src/main/cpp/StaLink.h | 9 ++-- app/wfbngrtl8812/src/main/cpp/WfbngLink.cpp | 10 ++--- app/wfbngrtl8812/src/main/cpp/WfbngLink.hpp | 35 +++++++-------- 4 files changed, 49 insertions(+), 55 deletions(-) diff --git a/app/wfbngrtl8812/src/main/cpp/StaLink.cpp b/app/wfbngrtl8812/src/main/cpp/StaLink.cpp index 35dae8f7..fc2138b3 100644 --- a/app/wfbngrtl8812/src/main/cpp/StaLink.cpp +++ b/app/wfbngrtl8812/src/main/cpp/StaLink.cpp @@ -6,8 +6,6 @@ #include #include #include -#include -#include #include #include #include @@ -110,6 +108,8 @@ constexpr auto kTcpRetransmit = 400ms; // air_man_ap answers once it is done; when it keeps the connection open after // that, this much silence after the first reply bytes ends the request. constexpr auto kTcpReplyIdle = 1500ms; +// Repeats of a channel switch the driver refused, before the station stays where it is. +constexpr int kMaxRetuneRetries = 3; // Replies are small status lines or JSON; anything beyond this is not from the camera. constexpr size_t kTcpMaxReply = 64 * 1024; // ICMP echo requests larger than a normal MTU are not answered (they would have to go @@ -118,21 +118,12 @@ constexpr size_t kMaxEchoDatagram = 1500; // Networks remembered while searching (diagnostics only). constexpr size_t kMaxSeenNetworks = 64; -// True for an HTTP reply whose headers or body are not all there yet. Anything that -// is not HTTP (air_man_ap speaks plain lines) counts as complete. -bool httpReplyIncomplete(const std::string &rx) { - static const std::string kHttp = "HTTP/"; - // Fewer bytes than the status line prefix: still HTTP if they match its start. - if (rx.size() < kHttp.size()) return !rx.empty() && kHttp.compare(0, rx.size(), rx) == 0; - if (rx.compare(0, kHttp.size(), kHttp) != 0) return false; - const auto hdr_end = rx.find("\r\n\r\n"); - if (hdr_end == std::string::npos) return true; - std::string head = rx.substr(0, hdr_end); - std::transform(head.begin(), head.end(), head.begin(), [](unsigned char ch) { return std::tolower(ch); }); - const auto cl = head.find("\r\ncontent-length:"); - if (cl == std::string::npos) return false; // delimited by the peer closing - const size_t want = std::strtoul(head.c_str() + cl + 17, nullptr, 10); - return rx.size() - (hdr_end + 4) < want; +// True for an HTTP/1.x request. The app sends those as HTTP/1.0 with "Connection: close", +// so the camera ends its reply by closing; nothing short of that is a whole reply. +bool isHttpRequest(const std::string &request) { + const auto line_end = request.find("\r\n"); + const auto version = request.find(" HTTP/1."); + return version != std::string::npos && version < line_end; } const char *stateName(StaLink::State s) { @@ -344,11 +335,19 @@ void StaLink::worker() { std::lock_guard lock(mu_); // Only record what the radio really switched to; after a failure the old // channel/width stay in force, so the search and probe logic keeps working - // from the truth and simply tries again after the dwell time. + // from the truth. if (tuned) { channel_ = static_cast(retune); width_mhz_ = retune_width; chan_offset_ = retune_offset; + retune_failures_ = 0; + } else if (retune_to_ < 0 && ++retune_failures_ <= kMaxRetuneRetries) { + // Nothing newer was asked for meanwhile: try the same switch again on the next + // tick. A camera that announced a channel switch is only found again this way; + // the search after a dropout looks at its boot channels, not the new one. + requestTune(retune, retune_width, retune_offset); + } else { + retune_failures_ = 0; } dwell_start_ = Clock::now(); last_tx_attempt_ = Clock::time_point{}; // probe right away on the new channel @@ -1312,6 +1311,7 @@ bool StaLink::tcpRequest(const std::array &ip, s.iss = rd(); s.snd_nxt = s.iss + 1; s.half_close = half_close; + s.until_close = isHttpRequest(request); s.data.assign(request.begin(), request.end()); s.deadline = deadline; s.last_tx = Clock::now(); @@ -1367,12 +1367,12 @@ void StaLink::tcpTick(Clock::time_point now) { if (tcp_.st != St::Resolving && tcp_.st != St::SynSent && tcp_.st != St::Established) return; if (now >= tcp_.deadline) { __android_log_print(ANDROID_LOG_WARN, TAG, "TCP request timed out"); - // A cut-off HTTP reply is a failure, so the caller retries instead of parsing half a body. - tcpFinish(!tcp_.rx.empty() && !httpReplyIncomplete(tcp_.rx)); + // An HTTP reply the camera never closed may be cut off: a failure, so the caller + // retries instead of parsing half a body. + tcpFinish(!tcp_.rx.empty() && !tcp_.until_close); return; } - if (tcp_.st == St::Established && !tcp_.rx.empty() && now - tcp_.last_rx >= kTcpReplyIdle && - !httpReplyIncomplete(tcp_.rx)) { + if (tcp_.st == St::Established && !tcp_.until_close && !tcp_.rx.empty() && now - tcp_.last_rx >= kTcpReplyIdle) { tcpFinish(true); return; } @@ -1420,7 +1420,8 @@ void StaLink::handleTcp(const uint8_t *ip, size_t ihl, size_t len) { if (flags & kTcpRst) { __android_log_print(ANDROID_LOG_WARN, TAG, "TCP connection reset by the peer"); - tcp_.st = tcp_.rx.empty() || httpReplyIncomplete(tcp_.rx) ? St::Failed : St::Done; + // A reset is no orderly end, so it cannot vouch for an HTTP reply being whole. + tcp_.st = tcp_.rx.empty() || tcp_.until_close ? St::Failed : St::Done; tcp_cv_.notify_all(); return; } @@ -1459,8 +1460,7 @@ void StaLink::handleTcp(const uint8_t *ip, size_t ihl, size_t len) { } else { tcpSend(kTcpFin | kTcpAck, data_end, nullptr, 0); } - // Closed before the Content-Length was reached: a cut-off reply, not an answer. - tcp_.st = httpReplyIncomplete(tcp_.rx) ? St::Failed : St::Done; + tcp_.st = St::Done; tcp_cv_.notify_all(); return; } diff --git a/app/wfbngrtl8812/src/main/cpp/StaLink.h b/app/wfbngrtl8812/src/main/cpp/StaLink.h index 9a43c10f..aad51e4d 100644 --- a/app/wfbngrtl8812/src/main/cpp/StaLink.h +++ b/app/wfbngrtl8812/src/main/cpp/StaLink.h @@ -89,12 +89,13 @@ class StaLink { void requestTxPower(int power) { pending_tx_power_ = power; } // Opens a TCP connection to ip:port, sends `request` and collects the reply - // until the peer closes, goes quiet after answering, or `timeout` passes. + // until the peer closes, goes quiet after answering, or `timeout` passes. An + // HTTP/1.x request only counts as answered once the peer closes. // half_close ends our sending side with the request (as `echo cmd | nc` does). // Blocks the caller (not an RX or worker thread). Waits for a request that is // already running; `timeout` covers that wait and the exchange together. // Returns false when not connected, the slot stayed busy, the reply is - // missing, or an HTTP reply was cut off. + // missing, or an HTTP reply was not closed by the peer. bool tcpRequest(const std::array &ip, uint16_t port, const std::string &request, @@ -114,7 +115,8 @@ class StaLink { uint32_t iss{0}, snd_nxt{0}, rcv_nxt{0}; std::vector data; // the request bool data_acked{false}; - bool half_close{false}; // FIN goes out with the request + bool half_close{false}; // FIN goes out with the request + bool until_close{false}; // HTTP: only the peer's FIN ends the reply, not kTcpReplyIdle std::string rx; int retries{0}; Clock::time_point last_tx{}, last_rx{}, deadline{}; @@ -186,6 +188,7 @@ class StaLink { int retune_to_{-1}; // set via requestTune(), carried out by the worker outside mu_ int retune_width_{20}; uint8_t retune_offset_{0}; + int retune_failures_{0}; // failed attempts at the pending switch, see worker() Clock::time_point last_width_change_{}; bool ap_vht_{false}; // AP advertises VHT capabilities (needed for 80 MHz) int csa_channel_{-1}; // announced by the AP, switched to at csa_at_ diff --git a/app/wfbngrtl8812/src/main/cpp/WfbngLink.cpp b/app/wfbngrtl8812/src/main/cpp/WfbngLink.cpp index 4967334f..1ce0c8dd 100644 --- a/app/wfbngrtl8812/src/main/cpp/WfbngLink.cpp +++ b/app/wfbngrtl8812/src/main/cpp/WfbngLink.cpp @@ -398,15 +398,13 @@ void WfbngLink::stop(JNIEnv *env, jobject context, jint fd) { // Recorded first, and whether or not the device exists yet: run() may not have got as far // as creating it, and StartRxLoop() would clear the flag set below anyway. note_stop_requested(fd); - // A copy keeps the device alive while StopRxLoop() runs, even if run() releases it meanwhile. - std::shared_ptr dev = getDevice(fd); - if (!dev) { + // Under dev_mutex, so run() cannot release the device while StopRxLoop() (a flag) runs. + if (!withDevice(fd, [](IRtlDevice &dev) { dev.StopRxLoop(); })) { // Happens when the adapter was already gone by the time the stop arrived, e.g. it // was unplugged or the hub re-enumerated it. Nothing left to stop. __android_log_print(ANDROID_LOG_WARN, TAG, "stop: no rtl device for fd=%d, already gone", fd); return; } - dev->StopRxLoop(); stop_adaptive_link(); } @@ -695,9 +693,7 @@ void WfbngLink::applyTxPower(int fd, int power) { // run thread; runStation() applies adaptive_tx_power itself during bring-up. return; } - if (auto dev = getDevice(fd)) { - dev->SetTxPower(power); - } + withDevice(fd, [power](IRtlDevice &dev) { dev.SetTxPower(power); }); } void WfbngLink::start_link_quality_thread(int fd) { diff --git a/app/wfbngrtl8812/src/main/cpp/WfbngLink.hpp b/app/wfbngrtl8812/src/main/cpp/WfbngLink.hpp index 9c9dc214..f761e54e 100644 --- a/app/wfbngrtl8812/src/main/cpp/WfbngLink.hpp +++ b/app/wfbngrtl8812/src/main/cpp/WfbngLink.hpp @@ -86,15 +86,20 @@ class WfbngLink { bool stbc_enabled{true}; // Guarded by dev_mutex. Other threads (stop(), the TX power setter, the link-quality - // thread) must copy the shared_ptr under the lock via getDevice() and call through - // that copy, so the device cannot be destroyed under them by run() releasing it. + // thread) reach a device only through withDevice(), which holds the lock for the call: + // run() cannot release the device meanwhile, and no other thread ever holds a + // reference, so the device is always destroyed on the run thread before libusb_exit(). std::mutex dev_mutex; std::map> rtl_devices; - std::shared_ptr getDevice(int fd) { + // Calls fn(device) under dev_mutex; false when there is no device for fd. Keep fn short + // (a flag, a register write): releaseDevice() waits for it. + template bool withDevice(int fd, Fn &&fn) { std::lock_guard lock(dev_mutex); auto it = rtl_devices.find(fd); - return it == rtl_devices.end() ? nullptr : it->second; + if (it == rtl_devices.end() || !it->second) return false; + fn(*it->second); + return true; } // Applies a TX power. In AP mode it goes through the station's worker thread, which @@ -166,26 +171,16 @@ class WfbngLink { // lock lives inside that object, so as long as it stays in rtl_devices the next // run() - after a mode, channel or settings change - is refused with "USB adapter // in use ... by another devourer process". Must come before libusb_exit(). - // A caller that still holds a copy from getDevice() keeps the object alive until it is done. + // run() keeps its own reference and drops it right after, so the object dies there. void releaseDevice(int fd) { - std::shared_ptr doomed; - { - std::lock_guard lock(dev_mutex); - auto it = rtl_devices.find(fd); - if (it != rtl_devices.end()) { - doomed = std::move(it->second); - rtl_devices.erase(it); - } - int expected = fd; - current_fd.compare_exchange_strong(expected, -1); - } - // `doomed` is destroyed here, outside the lock. + std::lock_guard lock(dev_mutex); + rtl_devices.erase(fd); + int expected = fd; + current_fd.compare_exchange_strong(expected, -1); } void stopDevice() { - if (auto dev = getDevice(current_fd.load())) { - dev->StopRxLoop(); - } + withDevice(current_fd.load(), [](IRtlDevice &dev) { dev.StopRxLoop(); }); } const char *keyPath = "/data/user/0/com.openipc.pixelpilot/files/gs.key";