diff --git a/README.md b/README.md index b4aac9f2..dc06fcbb 100644 --- a/README.md +++ b/README.md @@ -51,6 +51,97 @@ 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. + +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: + 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. + +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 | 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..67b00748 --- /dev/null +++ b/app/src/main/java/com/openipc/pixelpilot/CameraControlDialog.java @@ -0,0 +1,758 @@ +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.JSONException; +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: + * + * 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; + 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)); + // 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); + + 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", () -> 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", () -> rebootResult(cgiOnce(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", () -> rebootResult(cgiOnce(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) { + 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 < 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'); + 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); + 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. + Log.w(TAG, "camera " + pathAndQuery + ": unreadable answer", e); + } + } + } + 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..fc2138b3 --- /dev/null +++ b/app/wfbngrtl8812/src/main/cpp/StaLink.cpp @@ -0,0 +1,1695 @@ +#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; +// 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 +// 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/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) { + 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); + tcp_cv_.notify_all(); // callers queued for the session slot give up too +} + +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_++; + } + + // 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. + 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) { + tuned = false; + __android_log_print(ANDROID_LOG_ERROR, TAG, "retune to ch %d failed: %s", retune, e.what()); + } + 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. + 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 + } + } +} + +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(); + // 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_++; + __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); + 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()}; + + 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. 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(), [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 {}; + 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, 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); + 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()) 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); + 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 && 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); + 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_); + // 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_until(lock, deadline, [&] { return !usable() || tcp_.st == St::Idle; })) return false; + if (!usable()) 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.until_close = isHttpRequest(request); + s.data.assign(request.begin(), request.end()); + s.deadline = deadline; + 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()); + const bool ok = tcp_.st == St::Done && !reply.empty(); + tcp_.st = St::Idle; + tcp_cv_.notify_all(); // the slot is free for the next caller + return ok; +} + +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"); + // 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_.until_close && !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"); + // 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; + } + + 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) { + 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; + } + 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..aad51e4d --- /dev/null +++ b/app/wfbngrtl8812/src/main/cpp/StaLink.h @@ -0,0 +1,272 @@ +#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(); } + + // 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. 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 not closed by the peer. + 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 + 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{}; + }; + 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}; + 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_ + 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}; + std::atomic pending_tx_power_{-1}; // -1: nothing queued + + // 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; + 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; + }; + 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..1ce0c8dd 100644 --- a/app/wfbngrtl8812/src/main/cpp/WfbngLink.cpp +++ b/app/wfbngrtl8812/src/main/cpp/WfbngLink.cpp @@ -125,22 +125,38 @@ 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)) { + // 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, std::move(device)); + } + 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); @@ -204,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 @@ -261,10 +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; @@ -278,10 +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); @@ -289,22 +307,104 @@ 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, + std::shared_ptr device) { + IRtlDevice *dev = device.get(); + + 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(); + // 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(). + releaseDevice(fd); + device.reset(); + 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); + 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); + 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. note_stop_requested(fd); - if (rtl_devices.find(fd) == rtl_devices.end()) { + // 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; } - 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); - } stop_adaptive_link(); } @@ -392,23 +492,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 +552,157 @@ 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 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 +// 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::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); + // 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; + } + withDevice(fd, [power](IRtlDevice &dev) { 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. + applyTxPower(fd, 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"; @@ -533,7 +807,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( @@ -564,8 +838,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 287027ef..f761e54e 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,34 @@ 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; + // 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; int fec_recovered_to_4 = 30; @@ -50,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; @@ -59,8 +85,27 @@ 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) 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; + // 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); + 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 + // 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 @@ -114,12 +159,28 @@ 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, + 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(). + // run() keeps its own reference and drops it right after, so the object dies there. + void releaseDevice(int fd) { + std::lock_guard lock(dev_mutex); + rtl_devices.erase(fd); + int expected = fd; + current_fd.compare_exchange_strong(expected, -1); + } + void stopDevice() { - if (rtl_devices.find(current_fd) == rtl_devices.end()) return; - auto dev = rtl_devices.at(current_fd).get(); - if (dev) { - dev->StopRxLoop(); - } + withDevice(current_fd.load(), [](IRtlDevice &dev) { dev.StopRxLoop(); }); } const char *keyPath = "/data/user/0/com.openipc.pixelpilot/files/gs.key"; 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. diff --git a/tools/apfpv-open-static-cam.sh b/tools/apfpv-open-static-cam.sh new file mode 100755 index 00000000..b0b28d11 --- /dev/null +++ b/tools/apfpv-open-static-cam.sh @@ -0,0 +1,135 @@ +#!/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) + # 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 +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 +# 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.$$" + +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."; } + +# 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 +restart_ap +exit 0