From 13940c2acc981a21e3d017f5da6539ca391de2ea Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 14 Oct 2026 10:05:40 +0000 Subject: [PATCH 01/99] =?UTF-8?q?docs:=20=C3=9Cberblick=20und=20Machbarkei?= =?UTF-8?q?tsanalyse=20(OWL/OpenDrop=20auf=20ESP32)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Analyse von OWL, OpenDrop und dem iOS-26-tauglichen airdrop-mt7921-Projekt, ESP-IDF-Fähigkeiten für Raw-TX/Promiscuous-RX, C3 vs. C5, Architektur- optionen für Linux/Windows und ein Fahrplan beginnend mit Hardware-Spikes. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01XzgaN4Bv1m8Ka9iqo7QVUN --- docs/UEBERBLICK.md | 236 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 236 insertions(+) create mode 100644 docs/UEBERBLICK.md diff --git a/docs/UEBERBLICK.md b/docs/UEBERBLICK.md new file mode 100644 index 0000000..dd56360 --- /dev/null +++ b/docs/UEBERBLICK.md @@ -0,0 +1,236 @@ +# EspDrop – Überblick und Machbarkeitsanalyse + +Stand: Oktober 2026. Ziel: AirDrop unter Linux und Windows. Ein ESP32 (C3, später +evtl. C5) dient dabei als billiger WLAN- und Bluetooth-Adapter mit den Fähigkeiten, +die normale PC-WLAN-Karten bzw. -Treiber nicht bieten. + +--- + +## 1. Wie AirDrop technisch funktioniert + +``` + ┌───────────────────────────────────────────────────────────────┐ + │ AirDrop-Anwendung: HTTPS (TLS) POST /Discover, /Ask, /Upload │ OpenDrop (Python) + │ Dienstsuche: mDNS/DNS-SD "_airdrop._tcp" über IPv6 link-local │ + ├───────────────────────────────────────────────────────────────┤ + │ IPv6 (fe80::/64, Adresse aus MAC per EUI-64 abgeleitet) │ Betriebssystem + ├───────────────────────────────────────────────────────────────┤ + │ AWDL – Apple Wireless Direct Link │ OWL (C) + │ • Action-Frames (PSF/MIF) mit TLVs: Sync, Election, Kanal-Seq. │ + │ • Datenframes: 802.11 + LLC/SNAP (OUI 00:17:f2, PID 0x0800) │ + │ • feste BSSID 00:25:00:ff:94:73 │ + │ • Zeitraster: AW = 16 TU, 4 AW = 1 Slot (64 TU ≈ 65,5 ms), │ + │ 16 Slots = 1 Kanalsequenz (≈ 1,05 s) │ + ├───────────────────────────────────────────────────────────────┤ + │ 802.11 PHY auf Kanal 6 (2,4 GHz) und 149 bzw. 44 (5 GHz) │ WLAN-Hardware + └───────────────────────────────────────────────────────────────┘ + Daneben: Bluetooth LE "Continuity"-Advertisement (Apple, Typ 0x05), + weckt das AWDL-Interface von iPhones/Macs auf. +``` + +### Startpunkte + +| Projekt | Inhalt | Zustand | +|---|---|---| +| [seemoo-lab/owl](https://github.com/seemoo-lab/owl) | AWDL in C, Userspace, Linux/macOS. libpcap + libev + libnl, erzeugt ein TAP-Interface `awdl0` | nicht mehr gepflegt | +| [seemoo-lab/opendrop](https://github.com/seemoo-lab/opendrop) | AirDrop-Protokoll in Python (zeroconf, HTTPS, libarchive) | nicht mehr gepflegt, mit iOS 26 ohne Patches defekt | +| [jedbillyb/airdrop-mt7921](https://github.com/jedbillyb/airdrop-mt7921) + [jedbillyb/owl](https://github.com/jedbillyb/owl) | OWL-Fork und OpenDrop-Patches, **mit iOS 26 in beide Richtungen nachgewiesen** (Okt. 2026 aktiv) | **wichtigste aktuelle Quelle** | + +Alle drei stehen unter GPLv3, genau wie dieses Repo. Code kann also übernommen werden. + +### Aufbau von OWL (≈ 6 200 Zeilen C) + +- `src/` (≈ 2 500 Zeilen) ist **plattformunabhängig**: Frame-Bau (`tx.c`), TLV-Parsing (`rx.c`), + Sync (`sync.c`), Election (`election.c`), Kanalsequenz (`channel.c`), Peer-Tabelle (`peers.c`), + Sende-Fenster (`schedule.c`). Verwendet wird nur libc plus `ether_addr` und Endian-Makros. + Ausnahme ist das Radiotap-Parsing im RX-Pfad. + → **Lässt sich fast unverändert auf den ESP32 portieren.** +- `daemon/` ist Linux/macOS-spezifisch: libev-Eventloop (`core.c`), pcap-Injection und + TUN/TAP (`io.c`), nl80211-Kanalwechsel und Neighbor-Table (`netutils.c`). + → **Muss für den ESP32 neu geschrieben werden** (FreeRTOS + `esp_timer`, Promiscuous-RX, + `esp_wifi_80211_tx`). + +Bekannte Schwächen von Upstream-OWL, die im Fork von jedbillyb behoben sind: +- Die eigene Kanalsequenz ist statisch (16× derselbe Kanal). **iOS 26 sendet keinen Unicast + an einen Peer mit konstanter Sequenz** (FINDINGS §25). Man muss die Sequenz des iPhones + übernehmen (Strategie „verbatim“, ggf. „widen“). +- `peer->sync_offset` wurde nie gesetzt (§21). +- Die Peer-Timeouts stehen als „ms“ kommentiert, sind aber µs (effektiv 2 s Timeout). +- Die TX-Rate ist fest auf 12 Mbit/s OFDM (`tx.c`). + +### Erkenntnisse zu iOS 26 (aus airdrop-mt7921/docs/FINDINGS.md) + +- Das iPhone muss auf **„Jeder für 10 Minuten“** stehen. „Nur Kontakte“ braucht ein + Apple-signiertes Validation Record, das man nur von einem echten Mac extrahieren kann. +- **Empfangen** (iPhone → PC): Das Share-Sheet muss offen sein, dann ist AWDL wach. +- **Senden** (PC → iPhone): Das iPhone annonciert `_airdrop._tcp` nur, solange ein + BLE-Continuity-AirDrop-Advertisement läuft (§27, nachgewiesen). Bei „Jeder“ dürfen die + Hash-Felder Nullen sein. +- Nötige OpenDrop-Änderungen: Chunked Requests ohne `Content-Length`, Container + `application/x-dvzip` (Blöcke mit 4-Byte-BE-Header, Bit 31 = stored, sonst zlib, + 128-KiB-Blöcke → ODC-cpio), `TransferID` + `TransferType` schon im `/Ask`. + Ohne Apple-ID werden Uploads akzeptiert (§37). +- Kanalsequenz eines iPhones: Im Leerlauf z. B. `132,0,149,0,0,0,0,0,6,0,149,0,…` + (2 Slots auf 149, 1 Slot auf 6). Während eines Transfers wächst sie auf bis zu 11 Slots + auf 149 und 5 auf 6. `0` heißt „nicht anwesend“. Slot 0 ist meist der Infrastruktur-Kanal. +- Gemessener Durchsatz MT7921: ~3 Mbit/s innerhalb eines Bursts, ~45–60 kB/s im Mittel. + Begrenzt wird das durch den Duty-Cycle (wie viele Slots man mit dem Peer teilt), nicht + durch die PHY-Rate. +- In der EU ist der 5-GHz-Sozialkanal teils **44** statt 149 (noch zu verifizieren). + +Unabhängige Bestätigung, dass eine Nicht-Apple-Implementierung funktioniert: Seit Nov. 2025 +spricht Google Quick Share auf dem Pixel 10 mit AirDrop, ebenfalls nur im Modus +„Jeder für 10 Minuten“. + +--- + +## 2. Was der ESP32 können muss, und was er kann + +### Kernanforderung von OWL: „aktiver Monitor-Modus“ + +1. Beliebige 802.11-Frames senden (Action- und Datenframes mit eigener BSSID). +2. Alle Frames mit der AWDL-BSSID empfangen, inklusive RSSI und möglichst genauem Zeitstempel. +3. **Unicast-Frames an die eigene AWDL-MAC in Hardware ACKen** (innerhalb SIFS = 10/16 µs). + Das schafft nur die Hardware selbst. Über USB vom Host aus ist es unmöglich. +4. Schnelle Kanalwechsel im AWDL-Raster. + +Für Windows gibt es praktisch keine Treiber mit Monitor-Mode und Injection. Deshalb muss +die **AWDL-Schicht für beide Betriebssysteme auf dem ESP32 laufen**, nicht erst für Windows. + +### ESP-IDF-APIs (geprüft an esp-idf `master`, Okt. 2026) + +| Anforderung | ESP-IDF | Bewertung | +|---|---|---| +| Senden | `esp_wifi_80211_tx()`: Beacon, Probe, **Action**, **Non-QoS-Data**; max. 1500 Byte; `en_sys_seq=false` → eigene Sequenznummern | AWDL-PSF/MIF sind Vendor-Action-Frames, AWDL-Daten sind Non-QoS-Data. Passt. MTU muss ≤ ~1460 sein | +| TX-Rate | `esp_wifi_config_80211_tx_rate()` bzw. `esp_wifi_config_80211_tx()` (vor `esp_wifi_start`) | Fest pro Interface. Mögliche Idee: STA-IF langsam für Action-Frames, AP-IF schnell für Daten (Nachteil: Das AP-IF sendet Beacons) | +| TX-Status | `esp_wifi_register_80211_tx_cb()` → `tx_status` SUCCESS/FAIL | Deutet auf ACK-Auswertung hin, noch zu verifizieren | +| Empfang | `esp_wifi_set_promiscuous()` + Filter (MGMT/DATA), `rx_ctrl`: RSSI, Kanal, `timestamp` (µs), `sig_len` inkl. FCS | Gut. Der Zeitstempel ist genauer als bei OWL (dort `clock_gettime` bei pcap-Auslieferung) | +| Kanalwechsel | `esp_wifi_set_channel()` | Latenz unbekannt, messen (MT7921: 8,5 ms) | +| BLE-Advertisement | NimBLE/Bluedroid, Manufacturer Data 0x004C | Unproblematisch. Wi-Fi/BLE-Koexistenz teilt sich das Radio, also BLE nur bei Bedarf aktivieren | + +### Größtes technisches Risiko: ACK und Empfang an die eigene MAC + +Ein Forenbericht (IDF 5.5.2) sagt: Datenframes an die MAC des aktiven Interfaces werden +**ge-ACKt, aber nicht an den Promiscuous-Callback geliefert**, weil der WLAN-Stack sie +verschluckt. Falls das zutrifft: + +- **Variante A** – AWDL-MAC = Interface-MAC: Es wird ge-ACKt, aber die Frames müssen auf + anderem Weg abgegriffen werden. Zu prüfen sind interne RX-Hooks und das AP-Interface. +- **Variante B** – AWDL-MAC ≠ Interface-MAC: Alles kommt im Callback an, aber es gibt keine + ACKs. Das iPhone wiederholt dann jeden Frame bis zu ~7×. Das funktioniert, ist aber langsam + (siehe OWL-README). Außerdem erkennt der ESP die ACKs des iPhones auf *unsere* Frames + womöglich nicht und wiederholt selbst. + +→ **Das muss als Allererstes auf echter Hardware getestet werden.** Davon hängen Durchsatz +und Zuverlässigkeit ab, nicht aber die grundsätzliche Machbarkeit. + +### ESP32-C3 vs. ESP32-C5 + +| | ESP32-C3 | ESP32-C5 | +|---|---|---| +| WLAN | 802.11 b/g/n, **nur 2,4 GHz** | 802.11ax, **2,4 + 5 GHz** (5180–5885 MHz → Kanäle 36/44/149) | +| BLE | 5.0 | 5.0 | +| CPU / RAM | RISC-V 160 MHz, 400 KB SRAM | RISC-V 240 MHz, 384 KB SRAM (PSRAM optional) | +| USB | USB-Serial/JTAG (CDC-ACM, Full-Speed), **kein OTG** | ebenfalls USB-Serial/JTAG, kein OTG | +| AWDL-Kanäle | nur 6 | 6, 44, 149 | + +Folge: Mit dem **C3** sieht man ein iPhone nur in dessen Kanal-6-Slots, im Leerlauf +1 von 16, im Transfer-Peak bis zu 5 von 16. Discovery und kleine Dateien sind +wahrscheinlich machbar, der Durchsatz wäre aber deutlich niedriger als auf 149. +Unklar ist auch, ob iOS eine Sequenz akzeptiert, die nur Kanal 6 enthält. +**Der C5 ist für alltagstaugliche Nutzung sehr wahrscheinlich nötig.** Die API ist +identisch, der Code bleibt also derselbe. + +USB ist kein Flaschenhals: Full-Speed-CDC liefert grob mehrere 100 kB/s bis ~1 MB/s +(messen), AWDL realistisch 50–250 kB/s. + +--- + +## 3. Architektur: Was läuft wo? + +### Immer auf dem ESP32 (beide Betriebssysteme) + +- AWDL komplett: portierter OWL-Kern (`src/`) mit den Fixes von jedbillyb, dazu neuer + Radio-Treiber (Promiscuous-RX, Raw-TX, Kanal-Scheduler über `esp_timer`), PSF/MIF-Timer, + Peer-Verwaltung. +- BLE-Continuity-Advertisement (AirDrop, Typ 0x05). +- Host-Link über USB-CDC mit Paket-Framing (SLIP/COBS): Datenframes + Steuerkanal + Log. + +### Wie die IP-Schicht zum Host kommt – drei Optionen + +| | A: „AWDL-Netzwerkkarte“ (L2) | C: „Socket-Proxy“ (IP-Stack auf ESP) | B: „AirDrop-Appliance“ | +|---|---|---|---| +| ESP zusätzlich | – | lwIP (IPv6 link-local) + mDNS, TCP-Sockets werden zum Host getunnelt | zusätzlich TLS, HTTP, plist, dvzip/cpio | +| Linux-Host | Bridge USB ↔ TAP `awdl0`, gepatchtes OpenDrop **unverändert** | Host-App mit Socket-Shim | einfache Host-App | +| Windows-Host | Bridge USB ↔ Wintun (L3, IPv6 → MAC per EUI-64 trivial) oder TAP-Windows6. **Treiber + Admin nötig**, Konflikte mit Windows-eigenem mDNS/NDP/LLMNR-Traffic | **Keine Treiber, kein Admin**, reine Userspace-App | keine Treiber. Sogar Browser-UI per WebSerial denkbar | +| App-Protokoll | Host (leicht zu aktualisieren) | Host (leicht zu aktualisieren) | Firmware (iOS-Änderungen = Firmware-Update) | +| Aufwand Firmware | gering | mittel | hoch (RAM-knapp, v. a. TLS + Puffer) | + +**Empfehlung:** +1. **Zuerst A**: kleinste Firmware, und unter Linux lässt sich mit Wireshark auf `awdl0` + und dem bereits gepatchten OpenDrop am besten debuggen. +2. **Für Windows dann C**: Der ESP bekommt lwIP + mDNS (hat er ohnehin), der Host spricht + TLS/HTTP/AirDrop in Userspace. Kein Treiber, und dieselbe Host-App läuft auch unter Linux. + Die Vorstellung „ein Großteil des Codes auf dem ESP32“ trifft damit für die + zeitkritischen Teile zu. Die Anwendungslogik, die sich mit jeder iOS-Version ändert + (siehe die 17 OpenDrop-Patches für iOS 26), bleibt besser auf dem Host. +3. B nur, falls ein komplett treiber- und installationsfreier Betrieb gewünscht ist. + +--- + +## 4. Vorgeschlagener Fahrplan + +**M0 – Hardware-Spikes (entscheiden über alles Weitere)** +1. Raw-TX: einen AWDL-PSF/MIF mit `esp_wifi_80211_tx` senden. Taucht er in einer + Wireshark-Aufzeichnung auf, und reagiert ein Mac/iPhone darauf? +2. RX: AWDL-Action-Frames im Promiscuous-Mode empfangen (Filter auf BSSID + 00:25:00:ff:94:73) und Kanalsequenz-TLV eines iPhones dekodieren. +3. ACK-Test: Unicast-Datenframes an die AWDL-MAC. Werden sie ge-ACKt, kommen sie im + Callback an? Varianten A/B aus Abschnitt 2 durchspielen. +4. Latenz von `esp_wifi_set_channel` und den Zeitbezug `rx_ctrl.timestamp` ↔ + `esp_timer_get_time()` messen. +5. USB-CDC-Durchsatz messen. + +**M1 – AWDL auf dem ESP:** OWL-`src/` als ESP-IDF-Komponente (Basis: jedbillyb/owl), +neuer Scheduler. Ziel: iPhone als Peer sehen, Sync + Election, Log über USB. + +**M2 – Linux-Pfad (Option A):** Host-Bridge USB ↔ TAP `awdl0`. Dann `ping6` zum iPhone, +danach mit dem gepatchten OpenDrop eine Datei **empfangen**. + +**M3 – BLE-Wake auf dem ESP:** Datei an das iPhone **senden**. + +**M4 – Windows (Option C):** lwIP + mDNS auf dem ESP, Socket-Proxy-Protokoll, Host-App +(z. B. Python oder Rust) mit OpenDrop-Logik inkl. iOS-26-Patches. + +**M5 – C5 / 5 GHz und Durchsatz:** Kanäle 44/149, Strategie für die Kanalsequenz +(verbatim/widen), höhere TX-Rate (HT-MCS statt 12 Mbit/s OFDM). + +### Vorgeschlagene Repo-Struktur + +``` +firmware/ ESP-IDF-Projekt (C) + components/awdl/ portierter OWL-Kern (GPLv3) + main/ Radio-I/O, Scheduler, BLE, Host-Link +host/ + common/ Host-Link-Protokoll + linux/ espdrop-bridge (USB ↔ TAP awdl0) + windows/ später +docs/ +``` + +--- + +## 5. Offene Fragen und Risiken + +| Risiko | Auswirkung | Gegenmaßnahme | +|---|---|---| +| ESP liefert ge-ACKte Frames an eigene MAC nicht an den Sniffer | Durchsatz und Stabilität | M0-Test, Variante A/B, interne Hooks | +| iOS akzeptiert keine reine Kanal-6-Sequenz (C3) | C3 nur eingeschränkt nutzbar | früh testen, C5 einplanen | +| Kanalwechsel-Latenz / Timing-Jitter auf FreeRTOS | verpasste Availability Windows | `esp_timer`, Tick 1000 Hz, Wechsel vorziehen | +| Wi-Fi/BLE-Koexistenz auf einem Radio | verpasste AWs während BLE aktiv ist | BLE nur zur Discovery | +| Apple ändert AirDrop/AWDL (iOS 27 …) | Protokollbruch | App-Logik auf dem Host halten (leicht aktualisierbar) | +| Regulatorik 5 GHz (149 in DE?) | Kanalwahl | 44 als EU-Kanal prüfen | + +Langfristig ließe sich auch Wi-Fi Aware (NAN) nutzen: ESP-IDF unterstützt NAN, und Apple +öffnet es für Apps. Das wäre aber kein AirDrop, sondern bräuchte eine eigene App auf dem +iPhone, und gehört daher nicht zum Projektumfang. From 076dc6610bc7eb328fa799735bb11862410a6c0e Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 14 Oct 2026 10:47:41 +0000 Subject: [PATCH 02/99] chore: .gitignore for build output and local captures/logs Captures and serial logs contain MAC addresses and device names of nearby people; keep them out of the public repository. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01XzgaN4Bv1m8Ka9iqo7QVUN --- .gitignore | 35 +++++++++++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) create mode 100644 .gitignore diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..cd73161 --- /dev/null +++ b/.gitignore @@ -0,0 +1,35 @@ +# --- Build output ----------------------------------------------------------- +build/ +**/build/ +firmware/**/sdkconfig +firmware/**/sdkconfig.old +firmware/**/managed_components/ +firmware/**/dependencies.lock +*.o +*.a +*.elf +*.bin +*.map + +# --- Python ----------------------------------------------------------------- +__pycache__/ +*.pyc +.venv/ +venv/ +.pytest_cache/ + +# --- Editors ---------------------------------------------------------------- +.vscode/ +.idea/ +*.swp +.DS_Store + +# --- Privacy: never commit captures or logs --------------------------------- +# Wi-Fi/BLE captures and serial logs contain MAC addresses and device names +# ("Anna's iPhone") of everyone nearby. Keep them local. +*.pcap +*.pcapng +*.cap +captures/ +logs/ +*.log From 7b15d5cd33abfe3523ca93f65884a452648f3d3a Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 14 Oct 2026 12:20:06 +0000 Subject: [PATCH 03/99] awdl: platform-independent AWDL frame build/parse + host test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Portiert aus OWL (GPL-3.0): Aufbau von PSF/MIF-Action-Frames und AWDL-Datenframes sowie Parsing empfangener Action-Frames (Kanalsequenz, ARPA-Name, Version, Election-Master). Offset-basiert über awdl_wire.h, damit endian- und alignment-sicher und ohne ESP-IDF nativ testbar. Der Host-Test baut PSF/MIF/Daten, parst sie zurück und prueft, dass die TLVs den Puffer exakt kacheln. Der erzeugte MIF stimmt im Layout mit OWL ueberein (u.a. Sync-Params-TLV-Laenge 73, DPS-Flags 0x8f24). Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01XzgaN4Bv1m8Ka9iqo7QVUN --- firmware/spikes/components/awdl/awdl_frame.c | 444 ++++++++++++++++++ .../components/awdl/include/awdl_frame.h | 215 +++++++++ .../components/awdl/include/awdl_wire.h | 109 +++++ .../spikes/components/awdl/test/host/Makefile | 24 + .../components/awdl/test/host/test_awdl_frame | Bin 0 -> 99424 bytes .../awdl/test/host/test_awdl_frame.c | 139 ++++++ 6 files changed, 931 insertions(+) create mode 100644 firmware/spikes/components/awdl/awdl_frame.c create mode 100644 firmware/spikes/components/awdl/include/awdl_frame.h create mode 100644 firmware/spikes/components/awdl/include/awdl_wire.h create mode 100644 firmware/spikes/components/awdl/test/host/Makefile create mode 100755 firmware/spikes/components/awdl/test/host/test_awdl_frame create mode 100644 firmware/spikes/components/awdl/test/host/test_awdl_frame.c diff --git a/firmware/spikes/components/awdl/awdl_frame.c b/firmware/spikes/components/awdl/awdl_frame.c new file mode 100644 index 0000000..6812997 --- /dev/null +++ b/firmware/spikes/components/awdl/awdl_frame.c @@ -0,0 +1,444 @@ +/* + * EspDrop - AWDL frame construction and parsing (platform independent). + * + * See awdl_frame.h for provenance (OWL / The Open Wireless Link Project, Milan + * Stute, GPL-3.0) and conventions. + * + * SPDX-License-Identifier: GPL-3.0-or-later + * Copyright (C) 2026 EspDrop contributors + */ +#include "awdl_frame.h" +#include "awdl_wire.h" + +#include + +const uint8_t AWDL_BSSID[6] = {0x00, 0x25, 0x00, 0xff, 0x94, 0x73}; +const uint8_t AWDL_OUI[3] = {0x00, 0x17, 0xf2}; +const uint8_t AWDL_BROADCAST[6] = {0xff, 0xff, 0xff, 0xff, 0xff, 0xff}; + +/* ------------------------------------------------------------------------ */ +/* Small internal helpers */ +/* ------------------------------------------------------------------------ */ + +static int chan_enc_len(uint8_t enc) { + return (enc == AWDL_CHAN_ENC_SIMPLE) ? 1 : 2; +} + +uint8_t awdl_chan_number(const uint8_t enc_bytes[2], enum awdl_chan_enc enc) { + switch (enc) { + case AWDL_CHAN_ENC_SIMPLE: return enc_bytes[0]; + case AWDL_CHAN_ENC_LEGACY: return enc_bytes[1]; + case AWDL_CHAN_ENC_OPCLASS: return enc_bytes[0]; + default: return 0; + } +} + +/* Begin a TLV: write type + placeholder length, remember header offset. */ +static bool tlv_begin(uint8_t *buf, size_t cap, size_t *off, size_t *hdr, uint8_t type) { + *hdr = *off; + return wire_put_u8(buf, cap, off, type) && wire_put_le16(buf, cap, off, 0); +} + +/* Finish a TLV: backpatch length = bytes written after the 3-byte TL header. */ +static void tlv_end(uint8_t *buf, size_t *off, size_t hdr) { + uint16_t body = (uint16_t)(*off - hdr - 3); + buf[hdr + 1] = (uint8_t)(body & 0xff); + buf[hdr + 2] = (uint8_t)((body >> 8) & 0xff); +} + +/* Write an awdl_chanseq descriptor followed by the 16 encoded slot entries. */ +static bool put_chanseq(uint8_t *buf, size_t cap, size_t *off, const awdl_self_t *self) { + int enc_len = chan_enc_len(self->chan_enc); + bool ok = true; + ok &= wire_put_u8(buf, cap, off, AWDL_CHANSEQ_LEN - 1); /* count (+1) */ + ok &= wire_put_u8(buf, cap, off, self->chan_enc); /* encoding */ + ok &= wire_put_u8(buf, cap, off, 0); /* duplicate_count */ + ok &= wire_put_u8(buf, cap, off, AWDL_PRESENCE_MODE - 1); /* step_count (+1) */ + ok &= wire_put_le16(buf, cap, off, 0xffff); /* fill_channel: repeat */ + for (unsigned i = 0; i < AWDL_CHANSEQ_LEN; i++) + ok &= wire_put_bytes(buf, cap, off, self->seq[i], (size_t)enc_len); + return ok; +} + +/* ------------------------------------------------------------------------ */ +/* Self state */ +/* ------------------------------------------------------------------------ */ + +void awdl_self_init(awdl_self_t *self, const uint8_t self_addr[6], + const char *name, uint8_t chan_num) { + memset(self, 0, sizeof(*self)); + memcpy(self->self_addr, self_addr, 6); + + size_t n = 0; + if (name) { + n = strlen(name); + if (n > AWDL_NAME_MAX) n = AWDL_NAME_MAX; + memcpy(self->name, name, n); + } + self->name[n] = '\0'; + + self->version = AWDL_VERSION_OWL; + self->devclass = AWDL_DEVCLASS_MACOS; + self->chan_enc = AWDL_CHAN_ENC_OPCLASS; + self->master_chan = chan_num; + + uint8_t opclass = (chan_num <= 14) ? 0x51 : 0x80; + for (unsigned i = 0; i < AWDL_CHANSEQ_LEN; i++) { + self->seq[i][0] = chan_num; + self->seq[i][1] = opclass; + } +} + +/* ------------------------------------------------------------------------ */ +/* 802.11 header */ +/* ------------------------------------------------------------------------ */ + +static bool put_dot11_hdr(uint8_t *buf, size_t cap, size_t *off, uint16_t fc, + const uint8_t dst[6], const uint8_t src[6], uint16_t ieee_seq) { + bool ok = true; + ok &= wire_put_le16(buf, cap, off, fc); + ok &= wire_put_le16(buf, cap, off, 0); /* duration */ + ok &= wire_put_bytes(buf, cap, off, dst, 6); /* addr1 */ + ok &= wire_put_bytes(buf, cap, off, src, 6); /* addr2 */ + ok &= wire_put_bytes(buf, cap, off, AWDL_BSSID, 6); /* addr3 */ + ok &= wire_put_le16(buf, cap, off, (uint16_t)(ieee_seq << 4)); /* seq ctrl */ + return ok; +} + +/* ------------------------------------------------------------------------ */ +/* Action frame TLV builders */ +/* ------------------------------------------------------------------------ */ + +static bool put_tlv_sync_params(uint8_t *buf, size_t cap, size_t *off, + const awdl_self_t *self, uint64_t now_us) { + /* Free-running AW clock with epoch 0 (spike: not yet synced to a master). */ + uint64_t since_tu = now_us / AWDL_TU_US; + uint32_t eaw = AWDL_EAW_TU; /* 64 */ + uint16_t tx_down = (uint16_t)(eaw - (since_tu % eaw)); /* TU to next AW */ + uint16_t cur_aw = (uint16_t)(((since_tu % eaw) / AWDL_AW_PERIOD_TU) + + AWDL_PRESENCE_MODE * (since_tu / eaw)); + uint16_t aw_com = AWDL_AW_PERIOD_TU; + uint16_t consumed = (uint16_t)(AWDL_AW_PERIOD_TU * AWDL_PRESENCE_MODE - tx_down); + uint16_t remaining = (aw_com < consumed) ? 0 : (uint16_t)(aw_com - consumed); + + size_t hdr; + bool ok = tlv_begin(buf, cap, off, &hdr, AWDL_TLV_SYNC_PARAMS); + ok &= wire_put_u8(buf, cap, off, self->master_chan); /* next_aw_channel */ + ok &= wire_put_le16(buf, cap, off, tx_down); /* tx_down_counter */ + ok &= wire_put_u8(buf, cap, off, self->master_chan); /* master_channel */ + ok &= wire_put_u8(buf, cap, off, 0); /* guard_time */ + ok &= wire_put_le16(buf, cap, off, AWDL_AW_PERIOD_TU); /* aw_period */ + ok &= wire_put_le16(buf, cap, off, AWDL_PSF_INTERVAL_TU); /* af_period */ + ok &= wire_put_le16(buf, cap, off, 0x1800); /* flags */ + ok &= wire_put_le16(buf, cap, off, AWDL_AW_PERIOD_TU); /* aw_ext_length */ + ok &= wire_put_le16(buf, cap, off, aw_com); /* aw_com_length */ + ok &= wire_put_le16(buf, cap, off, remaining); /* remaining_aw_length */ + ok &= wire_put_u8(buf, cap, off, AWDL_PRESENCE_MODE - 1); /* min_ext */ + ok &= wire_put_u8(buf, cap, off, AWDL_PRESENCE_MODE - 1); /* max_ext_multicast */ + ok &= wire_put_u8(buf, cap, off, AWDL_PRESENCE_MODE - 1); /* max_ext_unicast */ + ok &= wire_put_u8(buf, cap, off, AWDL_PRESENCE_MODE - 1); /* max_ext_af */ + ok &= wire_put_bytes(buf, cap, off, self->self_addr, 6); /* master_addr (=self) */ + ok &= wire_put_u8(buf, cap, off, AWDL_PRESENCE_MODE); /* presence_mode */ + ok &= wire_put_u8(buf, cap, off, 0); /* reserved */ + ok &= wire_put_le16(buf, cap, off, cur_aw); /* next_aw_seq */ + ok &= wire_put_le16(buf, cap, off, cur_aw); /* ap_alignment */ + ok &= put_chanseq(buf, cap, off, self); + ok &= wire_put_le16(buf, cap, off, 0); /* padding */ + tlv_end(buf, off, hdr); + return ok; +} + +static bool put_tlv_election_params(uint8_t *buf, size_t cap, size_t *off, + const awdl_self_t *self) { + size_t hdr; + bool ok = tlv_begin(buf, cap, off, &hdr, AWDL_TLV_ELECTION_PARAMS); + ok &= wire_put_u8(buf, cap, off, 0); /* flags */ + ok &= wire_put_le16(buf, cap, off, 0); /* id */ + ok &= wire_put_u8(buf, cap, off, 0); /* distancetop (height) */ + ok &= wire_put_u8(buf, cap, off, 0); /* unknown */ + ok &= wire_put_bytes(buf, cap, off, self->self_addr, 6); /* top_master_addr */ + ok &= wire_put_le32(buf, cap, off, AWDL_ELECTION_METRIC_INIT); /* top_master_metric */ + ok &= wire_put_le32(buf, cap, off, AWDL_ELECTION_METRIC_INIT); /* self_metric */ + ok &= wire_put_le16(buf, cap, off, 0); /* pad */ + tlv_end(buf, off, hdr); + return ok; +} + +static bool put_tlv_chanseq(uint8_t *buf, size_t cap, size_t *off, const awdl_self_t *self) { + size_t hdr; + bool ok = tlv_begin(buf, cap, off, &hdr, AWDL_TLV_CHAN_SEQ); + ok &= put_chanseq(buf, cap, off, self); + ok &= wire_put_u8(buf, cap, off, 0); + ok &= wire_put_u8(buf, cap, off, 0); + ok &= wire_put_u8(buf, cap, off, 0); /* padding (3 bytes, as OWL) */ + tlv_end(buf, off, hdr); + return ok; +} + +static bool put_tlv_election_params_v2(uint8_t *buf, size_t cap, size_t *off, + const awdl_self_t *self) { + size_t hdr; + bool ok = tlv_begin(buf, cap, off, &hdr, AWDL_TLV_ELECTION_PARAMS_V2); + ok &= wire_put_bytes(buf, cap, off, self->self_addr, 6); /* master_addr */ + ok &= wire_put_bytes(buf, cap, off, self->self_addr, 6); /* sync_addr */ + ok &= wire_put_le32(buf, cap, off, 0); /* master_counter */ + ok &= wire_put_le32(buf, cap, off, 0); /* distance_to_master */ + ok &= wire_put_le32(buf, cap, off, AWDL_ELECTION_METRIC_INIT); /* master_metric */ + ok &= wire_put_le32(buf, cap, off, AWDL_ELECTION_METRIC_INIT); /* self_metric */ + ok &= wire_put_le32(buf, cap, off, 0); /* unknown */ + ok &= wire_put_le32(buf, cap, off, 0); /* reserved */ + ok &= wire_put_le32(buf, cap, off, 0); /* self_counter */ + tlv_end(buf, off, hdr); + return ok; +} + +static bool put_tlv_service_params(uint8_t *buf, size_t cap, size_t *off) { + size_t hdr; + bool ok = tlv_begin(buf, cap, off, &hdr, AWDL_TLV_SERVICE_PARAMS); + ok &= wire_put_u8(buf, cap, off, 0); + ok &= wire_put_u8(buf, cap, off, 0); + ok &= wire_put_u8(buf, cap, off, 0); /* unknown[3] */ + ok &= wire_put_le16(buf, cap, off, 0); /* sui */ + ok &= wire_put_le32(buf, cap, off, 0); /* bitmask */ + tlv_end(buf, off, hdr); + return ok; +} + +static bool put_tlv_ht_capabilities(uint8_t *buf, size_t cap, size_t *off) { + size_t hdr; + bool ok = tlv_begin(buf, cap, off, &hdr, AWDL_TLV_HT_CAPABILITIES); + ok &= wire_put_le16(buf, cap, off, 0); /* unknown */ + ok &= wire_put_le16(buf, cap, off, 0x11ce); /* ht_capabilities */ + ok &= wire_put_u8(buf, cap, off, 0x1b); /* ampdu_params */ + ok &= wire_put_u8(buf, cap, off, 0xff); /* rx_mcs: one stream, all MCS */ + ok &= wire_put_le16(buf, cap, off, 0); /* unknown2 */ + tlv_end(buf, off, hdr); + return ok; +} + +static bool put_tlv_arpa(uint8_t *buf, size_t cap, size_t *off, const awdl_self_t *self) { + size_t hdr; + size_t nlen = strlen(self->name); + bool ok = tlv_begin(buf, cap, off, &hdr, AWDL_TLV_ARPA); + ok &= wire_put_u8(buf, cap, off, 3); /* flags */ + ok &= wire_put_u8(buf, cap, off, (uint8_t)nlen); /* name_length */ + ok &= wire_put_bytes(buf, cap, off, self->name, nlen); + ok &= wire_put_be16(buf, cap, off, 0xc00c); /* compressed ".local" */ + tlv_end(buf, off, hdr); + return ok; +} + +static bool put_tlv_data_path_state(uint8_t *buf, size_t cap, size_t *off, + const awdl_self_t *self) { + uint16_t social; + switch (self->master_chan) { + case 6: social = 0x0001; break; + case 44: social = 0x0002; break; + default: social = 0x0004; break; /* 149 */ + } + size_t hdr; + bool ok = tlv_begin(buf, cap, off, &hdr, AWDL_TLV_DATA_PATH_STATE); + ok &= wire_put_le16(buf, cap, off, AWDL_DPS_FLAGS_OWL); /* flags */ + ok &= wire_put_u8(buf, cap, off, 'X'); + ok &= wire_put_u8(buf, cap, off, '0'); + ok &= wire_put_u8(buf, cap, off, 0); /* country_code[3] */ + ok &= wire_put_le16(buf, cap, off, social); /* social_channels */ + ok &= wire_put_bytes(buf, cap, off, self->self_addr, 6); /* awdl_addr */ + ok &= wire_put_le16(buf, cap, off, 0); /* ext_flags */ + tlv_end(buf, off, hdr); + return ok; +} + +static bool put_tlv_version(uint8_t *buf, size_t cap, size_t *off, const awdl_self_t *self) { + size_t hdr; + bool ok = tlv_begin(buf, cap, off, &hdr, AWDL_TLV_VERSION); + ok &= wire_put_u8(buf, cap, off, self->version); + ok &= wire_put_u8(buf, cap, off, self->devclass); + tlv_end(buf, off, hdr); + return ok; +} + +/* ------------------------------------------------------------------------ */ +/* Public builders */ +/* ------------------------------------------------------------------------ */ + +int awdl_build_action(uint8_t *buf, size_t cap, awdl_self_t *self, + uint8_t subtype, uint64_t now_us) { + if (!buf || !self) return AWDL_ERR_ARG; + + size_t off = 0; + bool ok = put_dot11_hdr(buf, cap, &off, FC_ACTION, AWDL_BROADCAST, + self->self_addr, self->ieee_seq_num); + self->ieee_seq_num = (self->ieee_seq_num + 1) & 0x0fff; + + /* AWDL action header */ + ok &= wire_put_u8(buf, cap, &off, AWDL_CATEGORY_VENDOR); + ok &= wire_put_bytes(buf, cap, &off, AWDL_OUI, 3); + ok &= wire_put_u8(buf, cap, &off, AWDL_ACTION_TYPE); + ok &= wire_put_u8(buf, cap, &off, AWDL_ACTION_VERSION); + ok &= wire_put_u8(buf, cap, &off, subtype); + ok &= wire_put_u8(buf, cap, &off, 0); /* reserved */ + ok &= wire_put_le32(buf, cap, &off, (uint32_t)now_us); /* phy_tx */ + ok &= wire_put_le32(buf, cap, &off, (uint32_t)now_us); /* target_tx */ + + ok &= put_tlv_sync_params(buf, cap, &off, self, now_us); + ok &= put_tlv_election_params(buf, cap, &off, self); + ok &= put_tlv_chanseq(buf, cap, &off, self); + ok &= put_tlv_election_params_v2(buf, cap, &off, self); + ok &= put_tlv_service_params(buf, cap, &off); + if (subtype == AWDL_SUBTYPE_MIF) { + ok &= put_tlv_ht_capabilities(buf, cap, &off); + ok &= put_tlv_arpa(buf, cap, &off, self); + } + ok &= put_tlv_data_path_state(buf, cap, &off, self); + ok &= put_tlv_version(buf, cap, &off, self); + + if (!ok) return AWDL_ERR_ARG; /* ran out of buffer */ + return (int)off; +} + +int awdl_build_data(uint8_t *buf, size_t cap, awdl_self_t *self, + const uint8_t dst[6], const uint8_t *payload, size_t plen) { + if (!buf || !self || !dst || (!payload && plen)) return AWDL_ERR_ARG; + + size_t off = 0; + bool ok = put_dot11_hdr(buf, cap, &off, FC_DATA, dst, self->self_addr, + self->ieee_seq_num); + self->ieee_seq_num = (self->ieee_seq_num + 1) & 0x0fff; + + /* LLC / SNAP */ + ok &= wire_put_u8(buf, cap, &off, 0xaa); + ok &= wire_put_u8(buf, cap, &off, 0xaa); + ok &= wire_put_u8(buf, cap, &off, 0x03); + ok &= wire_put_bytes(buf, cap, &off, AWDL_OUI, 3); + ok &= wire_put_be16(buf, cap, &off, AWDL_LLC_PID); + + /* AWDL data header */ + ok &= wire_put_le16(buf, cap, &off, AWDL_DATA_HEAD); + ok &= wire_put_le16(buf, cap, &off, self->seq_num++); + ok &= wire_put_le16(buf, cap, &off, 0); /* pad */ + ok &= wire_put_be16(buf, cap, &off, ETHERTYPE_IPV6); + + ok &= wire_put_bytes(buf, cap, &off, payload, plen); + + if (!ok) return AWDL_ERR_ARG; + return (int)off; +} + +/* ------------------------------------------------------------------------ */ +/* Parsing */ +/* ------------------------------------------------------------------------ */ + +static void parse_chanseq_tlv(const uint8_t *v, size_t vlen, awdl_peer_view_t *out) { + uint8_t enc; + if (!wire_get_u8(v, vlen, 1, &enc)) return; + int elen = chan_enc_len(enc); + size_t need = 6 + (size_t)elen * AWDL_CHANSEQ_LEN; + if (vlen < need) return; + out->chanseq_enc = enc; + for (unsigned i = 0; i < AWDL_CHANSEQ_LEN; i++) { + uint8_t eb[2] = {0, 0}; + wire_get_bytes(v, vlen, 6 + (size_t)i * elen, eb, (size_t)elen); + out->chanseq[i] = awdl_chan_number(eb, (enum awdl_chan_enc)enc); + } + out->has_chanseq = true; +} + +int awdl_parse_action(const uint8_t *buf, size_t len, awdl_peer_view_t *out) { + if (!buf || !out) return AWDL_ERR_ARG; + memset(out, 0, sizeof(*out)); + + if (len < IEEE80211_HDR_LEN + 16) return AWDL_ERR_SHORT; + + /* transmitter = addr2 */ + wire_get_bytes(buf, len, 10, out->src_addr, 6); + + /* AWDL action header at offset 24 */ + size_t a = IEEE80211_HDR_LEN; + uint8_t category, type; + wire_get_u8(buf, len, a + 0, &category); + if (category != AWDL_CATEGORY_VENDOR) return AWDL_ERR_NOT_AWDL; + if (memcmp(buf + a + 1, AWDL_OUI, 3) != 0) return AWDL_ERR_NOT_AWDL; + wire_get_u8(buf, len, a + 4, &type); + if (type != AWDL_ACTION_TYPE) return AWDL_ERR_NOT_AWDL; + wire_get_u8(buf, len, a + 6, &out->subtype); + + /* TLVs start after the 16-byte action header */ + size_t off = a + 16; + bool election_v2 = false; + while (off + 3 <= len) { + uint8_t t = buf[off]; + uint16_t l = (uint16_t)(buf[off + 1] | ((uint16_t)buf[off + 2] << 8)); + const uint8_t *v = buf + off + 3; + if (off + 3 + l > len) break; /* truncated TLV */ + + switch (t) { + case AWDL_TLV_CHAN_SEQ: + parse_chanseq_tlv(v, l, out); + break; + case AWDL_TLV_ARPA: { + uint8_t nlen; + if (wire_get_u8(v, l, 1, &nlen)) { + size_t cpy = nlen; + if (cpy > AWDL_NAME_MAX) cpy = AWDL_NAME_MAX; + if (2 + cpy <= l) { + memcpy(out->name, v + 2, cpy); + out->name[cpy] = '\0'; + out->has_name = true; + } + } + break; + } + case AWDL_TLV_VERSION: + if (wire_get_u8(v, l, 0, &out->version) && + wire_get_u8(v, l, 1, &out->devclass)) + out->has_version = true; + break; + case AWDL_TLV_ELECTION_PARAMS_V2: + if (wire_get_bytes(v, l, 0, out->master_addr, 6)) { + out->has_election = true; + election_v2 = true; + } + break; + case AWDL_TLV_ELECTION_PARAMS: + /* v1: top_master_addr at offset 5; ignored if v2 already seen */ + if (!election_v2 && wire_get_bytes(v, l, 5, out->master_addr, 6)) + out->has_election = true; + break; + default: + break; + } + off += 3 + l; + } + return AWDL_OK; +} + +bool awdl_parse_data(const uint8_t *buf, size_t len, + size_t *payload_off, uint16_t *ethertype) { + if (!buf || len < IEEE80211_HDR_LEN + 8 + 8) return false; + + uint16_t fc; + wire_get_le16(buf, len, 0, &fc); + if (fc != FC_DATA) return false; + + size_t llc = IEEE80211_HDR_LEN; + uint8_t dsap, ssap, ctrl; + wire_get_u8(buf, len, llc + 0, &dsap); + wire_get_u8(buf, len, llc + 1, &ssap); + wire_get_u8(buf, len, llc + 2, &ctrl); + if (dsap != 0xaa || ssap != 0xaa || ctrl != 0x03) return false; + if (memcmp(buf + llc + 3, AWDL_OUI, 3) != 0) return false; + + uint16_t pid, head, et; + wire_get_be16(buf, len, llc + 6, &pid); + if (pid != AWDL_LLC_PID) return false; + + size_t adata = llc + 8; + wire_get_le16(buf, len, adata + 0, &head); + if (head != AWDL_DATA_HEAD) return false; + wire_get_be16(buf, len, adata + 6, &et); + + if (ethertype) *ethertype = et; + if (payload_off) *payload_off = adata + 8; + return true; +} diff --git a/firmware/spikes/components/awdl/include/awdl_frame.h b/firmware/spikes/components/awdl/include/awdl_frame.h new file mode 100644 index 0000000..f1c48a3 --- /dev/null +++ b/firmware/spikes/components/awdl/include/awdl_frame.h @@ -0,0 +1,215 @@ +/* + * EspDrop - AWDL frame construction and parsing (platform independent). + * + * Builds and parses the frames of Apple Wireless Direct Link (AWDL), the + * ad-hoc Wi-Fi link that AirDrop runs on. The protocol was documented by the + * Open Wireless Link project (TU Darmstadt) through reverse engineering and + * implemented in OWL (https://github.com/seemoo-lab/owl). The frame layouts, + * TLV type numbers and field offsets here follow OWL's src/frame.h, src/tx.c + * and src/rx.c: + * Copyright (C) 2018 The Open Wireless Link Project + * Copyright (C) 2018 Milan Stute + * Reimplemented for EspDrop against an offset-based wire API so it is endian- + * and alignment-safe and testable on a host: + * Copyright (C) 2026 EspDrop contributors + * + * SPDX-License-Identifier: GPL-3.0-or-later + * + * This module depends only on the C standard library (via awdl_wire.h). It can + * be compiled and unit tested natively; the ESP-IDF radio glue lives elsewhere. + * + * Buffers passed in and out start at the IEEE 802.11 MAC header. There is never + * a radiotap header (the ESP32 promiscuous callback delivers the MAC frame and + * reports PHY metadata separately) and never a trailing FCS (hardware appends + * it on transmit; the caller strips it before parsing). + */ +#ifndef ESPDROP_AWDL_FRAME_H +#define ESPDROP_AWDL_FRAME_H + +#include +#include +#include + +#ifdef __cplusplus +extern "C" { +#endif + +/* ------------------------------------------------------------------------ */ +/* Constants */ +/* ------------------------------------------------------------------------ */ + +#define AWDL_TU_US 1024u /* one IEEE 802.11 time unit, microseconds */ +#define AWDL_AW_PERIOD_TU 16u /* availability window length */ +#define AWDL_PRESENCE_MODE 4u /* AWs per extended AW (EAW) */ +#define AWDL_EAW_TU (AWDL_AW_PERIOD_TU * AWDL_PRESENCE_MODE) /* 64 TU, one slot */ +#define AWDL_CHANSEQ_LEN 16u /* slots in a channel sequence */ +#define AWDL_PSF_INTERVAL_TU 110u /* OWL master PSF interval */ + +#define AWDL_NAME_MAX 63u /* max host name length we store (excl. NUL) */ + +#define AWDL_SUBTYPE_PSF 0u /* periodic synchronization frame */ +#define AWDL_SUBTYPE_MIF 3u /* master indication frame */ + +#define AWDL_VERSION_OWL 0x34u /* AWDL 3.4, as announced by OWL's version TLV */ +#define AWDL_ACTION_VERSION 0x10u /* version byte inside the action header (1.0) */ +#define AWDL_ACTION_TYPE 0x08u /* AWDL action type */ +#define AWDL_CATEGORY_VENDOR 127u /* IEEE 802.11 vendor-specific action category */ + +#define AWDL_DEVCLASS_MACOS 1u +#define AWDL_DEVCLASS_IOS 2u +#define AWDL_DEVCLASS_TVOS 8u + +#define AWDL_ELECTION_METRIC_INIT 60u /* OWL default: stays a slave */ +#define AWDL_ELECTION_COUNTER_INIT 0u + +#define AWDL_LLC_PID 0x0800u /* protocol id in the LLC/SNAP header */ +#define AWDL_DATA_HEAD 0x0403u /* first field of the AWDL data header */ +#define ETHERTYPE_IPV6 0x86ddu + +/* IEEE 802.11 frame control, as the little-endian value of the first 2 bytes */ +#define FC_ACTION 0x00d0u /* type management, subtype action (13) */ +#define FC_DATA 0x0008u /* type data, subtype data (0) */ +#define IEEE80211_HDR_LEN 24u + +/* AWDL TLV types (subset; full list in OWL src/frame.h) */ +enum awdl_tlv_type { + AWDL_TLV_SYNC_PARAMS = 4, + AWDL_TLV_ELECTION_PARAMS = 5, + AWDL_TLV_SERVICE_PARAMS = 6, + AWDL_TLV_HT_CAPABILITIES = 7, + AWDL_TLV_DATA_PATH_STATE = 12, + AWDL_TLV_ARPA = 16, + AWDL_TLV_CHAN_SEQ = 18, + AWDL_TLV_VERSION = 21, + AWDL_TLV_ELECTION_PARAMS_V2 = 24, +}; + +/* Channel sequence encodings */ +enum awdl_chan_enc { + AWDL_CHAN_ENC_SIMPLE = 0, /* 1 byte: channel number */ + AWDL_CHAN_ENC_LEGACY = 1, /* 2 bytes: flags, channel number */ + AWDL_CHAN_ENC_OPCLASS = 3, /* 2 bytes: channel number, operating class */ +}; + +/* Data path state TLV flags (what OWL sets) */ +#define AWDL_DPS_FLAGS_OWL 0x8f24u + +/* Return codes (negative = error) */ +enum awdl_result { + AWDL_OK = 0, + AWDL_ERR_SHORT = -1, /* buffer/TLV truncated */ + AWDL_ERR_NOT_AWDL = -2, /* wrong frame control / category / OUI / type */ + AWDL_ERR_ARG = -3, /* bad argument or output buffer too small */ +}; + +extern const uint8_t AWDL_BSSID[6]; /* 00:25:00:ff:94:73 */ +extern const uint8_t AWDL_OUI[3]; /* 00:17:f2 */ +extern const uint8_t AWDL_BROADCAST[6]; /* ff:ff:ff:ff:ff:ff */ + +/* ------------------------------------------------------------------------ */ +/* Transmitter state (what the frame builder needs to know about us) */ +/* ------------------------------------------------------------------------ */ + +/* + * The spike firmware does not yet synchronise to a master. Its AW clock runs + * freely from a monotonic microsecond source, and it advertises itself as its + * own master with OWL's low election metric, so a real Apple device will win + * any election. That is enough to be discovered and to emit well-formed PSF/MIF + * frames; proper synchronisation is a later milestone. + */ +typedef struct { + uint8_t self_addr[6]; /* our AWDL MAC: addr2, election self, DPS address */ + char name[AWDL_NAME_MAX + 1]; /* host name advertised in the ARPA TLV */ + uint8_t version; /* version TLV value, e.g. AWDL_VERSION_OWL */ + uint8_t devclass; /* version TLV device class */ + uint8_t chan_enc; /* enum awdl_chan_enc for our own sequence */ + uint8_t seq[AWDL_CHANSEQ_LEN][2]; /* our channel sequence, encoded bytes */ + uint8_t master_chan; /* channel number we call our master channel */ + uint16_t seq_num; /* AWDL data sequence number (incremented on data) */ + uint16_t ieee_seq_num; /* IEEE 802.11 sequence number (12 bits) */ +} awdl_self_t; + +/* + * Initialise self state with OWL-compatible defaults and a static single-channel + * sequence (all 16 slots on chan_num, opclass encoding). chan_num is the social + * channel to park on, e.g. 6 on the 2.4 GHz-only ESP32-C3. + */ +void awdl_self_init(awdl_self_t *self, const uint8_t self_addr[6], + const char *name, uint8_t chan_num); + +/* ------------------------------------------------------------------------ */ +/* Parsed peer view (what the parser extracts from a received action frame) */ +/* ------------------------------------------------------------------------ */ + +typedef struct { + uint8_t src_addr[6]; /* transmitter address (addr2) */ + uint8_t subtype; /* AWDL_SUBTYPE_PSF or _MIF */ + + bool has_name; + char name[AWDL_NAME_MAX + 1]; + + bool has_version; + uint8_t version; + uint8_t devclass; + + bool has_chanseq; + uint8_t chanseq_enc; /* enum awdl_chan_enc of the decoded sequence */ + uint8_t chanseq[AWDL_CHANSEQ_LEN]; /* decoded channel NUMBERS, 0 = absent */ + + bool has_election; + uint8_t master_addr[6]; /* currently elected master (v2 if present, else v1) */ +} awdl_peer_view_t; + +/* ------------------------------------------------------------------------ */ +/* Building */ +/* ------------------------------------------------------------------------ */ + +/* + * Build a complete AWDL action frame (PSF or MIF) starting at the 802.11 MAC + * header, ready to hand to esp_wifi_80211_tx() (no radiotap, no FCS). The + * destination is the AWDL broadcast target. now_us is a monotonic microsecond + * timestamp used to fill the sync-parameter timing fields. + * + * Returns the number of bytes written, or a negative enum awdl_result. + */ +int awdl_build_action(uint8_t *buf, size_t cap, awdl_self_t *self, + uint8_t subtype, uint64_t now_us); + +/* + * Build a complete AWDL data frame carrying an IPv6 payload: 802.11 data header + * + LLC/SNAP + AWDL data header + payload. dst is the destination AWDL MAC. + * Increments self->seq_num and self->ieee_seq_num. + * + * Returns bytes written, or a negative enum awdl_result. + */ +int awdl_build_data(uint8_t *buf, size_t cap, awdl_self_t *self, + const uint8_t dst[6], const uint8_t *payload, size_t plen); + +/* ------------------------------------------------------------------------ */ +/* Parsing */ +/* ------------------------------------------------------------------------ */ + +/* + * Parse a received AWDL action frame (802.11 MAC header onward, FCS already + * stripped) into out. Fills the has_* flags for the TLVs it recognises. Returns + * AWDL_OK, or a negative enum awdl_result if the frame is not a well-formed + * AWDL action frame. + */ +int awdl_parse_action(const uint8_t *buf, size_t len, awdl_peer_view_t *out); + +/* + * True if the frame looks like an AWDL data frame (802.11 data, AWDL LLC/SNAP). + * On success, *payload_off is the offset of the IPv6 payload and *ethertype the + * carried ethertype (expected ETHERTYPE_IPV6). + */ +bool awdl_parse_data(const uint8_t *buf, size_t len, + size_t *payload_off, uint16_t *ethertype); + +/* Map an operating-class-encoded or legacy channel pair to a channel number. */ +uint8_t awdl_chan_number(const uint8_t enc_bytes[2], enum awdl_chan_enc enc); + +#ifdef __cplusplus +} +#endif + +#endif /* ESPDROP_AWDL_FRAME_H */ diff --git a/firmware/spikes/components/awdl/include/awdl_wire.h b/firmware/spikes/components/awdl/include/awdl_wire.h new file mode 100644 index 0000000..e374b4c --- /dev/null +++ b/firmware/spikes/components/awdl/include/awdl_wire.h @@ -0,0 +1,109 @@ +/* + * EspDrop - safe byte reading/writing helpers (platform independent). + * + * Small helpers for reading and writing the primitive integer types used in + * AWDL / IEEE 802.11 frames at explicit byte offsets, without relying on struct + * packing or the host's alignment/endianness. All functions are bounds-checked + * against an explicit buffer capacity. + * + * Inspired by OWL's src/wire.{c,h} (The Open Wireless Link Project / Milan + * Stute, GPL-3.0), reimplemented here with a simpler offset-based API. + * + * SPDX-License-Identifier: GPL-3.0-or-later + * Copyright (C) 2026 EspDrop contributors + */ +#ifndef ESPDROP_AWDL_WIRE_H +#define ESPDROP_AWDL_WIRE_H + +#include +#include +#include +#include + +#ifdef __cplusplus +extern "C" { +#endif + +/* ---- writing: append at *off, advance *off, return false on overflow ---- */ + +static inline bool wire_put_u8(uint8_t *buf, size_t cap, size_t *off, uint8_t v) { + if (*off + 1 > cap) return false; + buf[*off] = v; + *off += 1; + return true; +} + +static inline bool wire_put_le16(uint8_t *buf, size_t cap, size_t *off, uint16_t v) { + if (*off + 2 > cap) return false; + buf[*off + 0] = (uint8_t)(v & 0xff); + buf[*off + 1] = (uint8_t)((v >> 8) & 0xff); + *off += 2; + return true; +} + +static inline bool wire_put_be16(uint8_t *buf, size_t cap, size_t *off, uint16_t v) { + if (*off + 2 > cap) return false; + buf[*off + 0] = (uint8_t)((v >> 8) & 0xff); + buf[*off + 1] = (uint8_t)(v & 0xff); + *off += 2; + return true; +} + +static inline bool wire_put_le32(uint8_t *buf, size_t cap, size_t *off, uint32_t v) { + if (*off + 4 > cap) return false; + buf[*off + 0] = (uint8_t)(v & 0xff); + buf[*off + 1] = (uint8_t)((v >> 8) & 0xff); + buf[*off + 2] = (uint8_t)((v >> 16) & 0xff); + buf[*off + 3] = (uint8_t)((v >> 24) & 0xff); + *off += 4; + return true; +} + +static inline bool wire_put_bytes(uint8_t *buf, size_t cap, size_t *off, + const void *src, size_t n) { + if (*off + n > cap) return false; + memcpy(buf + *off, src, n); + *off += n; + return true; +} + +/* ---- reading: read at absolute offset, return false if out of bounds ---- */ + +static inline bool wire_get_u8(const uint8_t *buf, size_t len, size_t off, uint8_t *out) { + if (off + 1 > len) return false; + if (out) *out = buf[off]; + return true; +} + +static inline bool wire_get_le16(const uint8_t *buf, size_t len, size_t off, uint16_t *out) { + if (off + 2 > len) return false; + if (out) *out = (uint16_t)(buf[off] | ((uint16_t)buf[off + 1] << 8)); + return true; +} + +static inline bool wire_get_be16(const uint8_t *buf, size_t len, size_t off, uint16_t *out) { + if (off + 2 > len) return false; + if (out) *out = (uint16_t)(((uint16_t)buf[off] << 8) | buf[off + 1]); + return true; +} + +static inline bool wire_get_le32(const uint8_t *buf, size_t len, size_t off, uint32_t *out) { + if (off + 4 > len) return false; + if (out) + *out = (uint32_t)buf[off] | ((uint32_t)buf[off + 1] << 8) | + ((uint32_t)buf[off + 2] << 16) | ((uint32_t)buf[off + 3] << 24); + return true; +} + +static inline bool wire_get_bytes(const uint8_t *buf, size_t len, size_t off, + void *dst, size_t n) { + if (off + n > len) return false; + if (dst) memcpy(dst, buf + off, n); + return true; +} + +#ifdef __cplusplus +} +#endif + +#endif /* ESPDROP_AWDL_WIRE_H */ diff --git a/firmware/spikes/components/awdl/test/host/Makefile b/firmware/spikes/components/awdl/test/host/Makefile new file mode 100644 index 0000000..42e0983 --- /dev/null +++ b/firmware/spikes/components/awdl/test/host/Makefile @@ -0,0 +1,24 @@ +# Native host build of the platform-independent AWDL frame unit test. +# No ESP-IDF required: pure C against the component's own headers. +# +# SPDX-License-Identifier: GPL-3.0-or-later + +CC ?= cc +CFLAGS ?= -std=c11 -Wall -Wextra -Werror -O2 -g +COMP = ../.. +INCLUDE = -I$(COMP)/include + +SRCS = $(COMP)/awdl_frame.c test_awdl_frame.c +BIN = test_awdl_frame + +.PHONY: all run clean +all: run + +$(BIN): $(SRCS) $(COMP)/include/awdl_frame.h $(COMP)/include/awdl_wire.h + $(CC) $(CFLAGS) $(INCLUDE) $(SRCS) -o $(BIN) + +run: $(BIN) + ./$(BIN) + +clean: + rm -f $(BIN) diff --git a/firmware/spikes/components/awdl/test/host/test_awdl_frame b/firmware/spikes/components/awdl/test/host/test_awdl_frame new file mode 100755 index 0000000000000000000000000000000000000000..f45d6ab38a8f26fb1e50fb3bc2325a98861019f9 GIT binary patch literal 99424 zcmeFad301&7XN#1Ri#oHNQFse3>qdGB+LPU6eLiQfFPivGD8GqGJ}GGAOTDnqo8Qp ziYWD1JBhM;$hCXOwe#*@yA9#pz4la_i2Azi zdF;I#dAhv&v34_EyJPRUj+y1%RfeuHpOj4he^TBk*Dl=!JodiEfw#Q7SKess)Gq() z<#qF!Zh3#y50l;Y_wH`^zGfHch9cH^SX97F37J8 zNMmZ)$Z-}hTb1TEqvc!b$R3{Z8oBw>f5d9pqtD()7H1xOV)gpWsduhyxaf#<e z0Udh{BjI@ZN;_WqPHTT-YL ztGJ|S_Kdlt(XG>@SyVD<&Xie7nYLicq{3Nqr_7#p*$ivWj5*WhFSMkkzV2-eE65)_ zWK#F8J-YUc^I5fQE!Vwkued^wp4O!Nu_Go;pHVd9f?35SGm6HJ7&3d_+!F4u6lc| ztPNQ8!*RK$?G>OEhEL433|>v!uE)sjj*(j*BcE`T92q-qO!Uexwo=OUXhooH2bgGV zdFE#BSzDu(voq)C$3CvQxUFSXebJgfm$tF2g4l;o<`~7V3d8dfzVA-`I_7514|iOi zxs-I>^-!eyI__21H?^#?!$(R+ZCQ*M+mW^PDb+Jt*|;gT(aK&ePDSf{Y8iVQ>D4S1 zZd9sW)uHx*FP_!Mp{UMU>}()o3C{)tXS<9cLfZC&w7_{Oc#iZ!Oy zrbjz9GR;`pCVJ)5TXE&Q36b-6VBW2c`FzX|K2?>^jm}(-meZeEU*^a5PK?GrjFx@; z^{BCVSzEJqM=J-6Mz5yvG!8btFYM~+lO7`_TA8p+x9r0ujTwqV#WNI>Y7T@?Tq@ih z?j8N{M!(mYx^+AfjeQ;6c4$y^+hKpy-WlC{q@)o5ja`6H%>ieedG;EwO9v?1D(y5b zS~lR@0@!HmlakbE#eg3Xs>c6bQ&Tk^Uw0;aiEK~6HeSB$t1l(f^(QhmKei`7_WZbL z?1wSY3STt#>(Yf$dqgv8Jj%{5+vbaAzY$*c1BL7iB!=@V(xa7w?Pz6@FF$65H|9s9 z**}Dry+L+)K@&TkeY&!v*)N8d=|OmBZWG%&PTuWqo)~aU z(g^r#P}rI4w{=HF*{4@F3guT0&(DiJmtVGHRy6zdqCV6!YVXTivAd*E)+@0$%C_6# zji+Vhl%LiuH~W>MLs4oo!VcwUKVQ-mAR%B@{DRW+vOXrgv?@1tzzmhz{vD+}^ulGu zG#mWE<%PBVpIb3DGZZa5Vux4i6tX-_8%7>iRd(hk+Ll@rf1@#9v@%Llz7$^eoT*Zx zvi@5oh)QuM>E>naH|lDE+}Pf29|X!%6R|0uooMIA_C?ElKJ{y)A{4a`$3l_trf8rr z77auzlB1Q8AazPrso5_T{VP(DjyAbBmYpYpx&h?~{9W;i+X2O}k=nItzp(mEwg%8vL;l9{GImIUN?cw-{B z9PY#H9d+7M<1;cg5_=(QD?u^#YqV@vC>nc<5T731HS92rVR4^m?q#1Hj&}UHY}?#z z)&B3I_g4gC{_v)u)Nn|Eu({%uk_)4;H>0uFtIqzVrslChGOZj=3-5`Bhi#8-FFPCx zFMC?4730XcJS{4~*qC5!d$gjYF>FbF6`*0#@x`(a510KCDr&+#Yj^CWvTdP)irij= z;Xt44y+wy2OMeS9LW9=ko^*aByzw>M7}{)W*@3+~heZ8lTbJgRPn#9q_-r)xY;@a! zP&8?ebNip9SRgYR%gqcC=>wVJ`a|-e@ho!b?pV#SHUvsL5CWH%sNpx3S~=y#!5_E# z!po;AVN*%tBZXAwNcC96ic%|99HffBk@Q=2gruY5U*zn_S&?yy46o4#Wnc7-rDoPy zm~t~asCjF`YqpPA@o{*~*3kCyv6+eC6|GZM^G&&#vq!{sj);9g_Z6^sWfW8<{DU=Z z%O16L?1$*C+{_GE+V1mFRgeExQ&Ui}D6>Osmt#u=C~R93E{ss&+4+^j?8rIM?499d z_i;RuUwKAg@XkR0C2EZf&5yFXNLHxIs?i)>wi&UYa)FPa!MGVq;n}~3m-P%Qj5H*6 zjq(@77ReOd>(C8v>`Bw2H@LiKe+&agKj7#KH~|*|uFW&yWfuxR_RUbV%R4vRR^2o= z_U+*Eg8u$(pQqzpXmI)1{!Dk{9ntTqG|k?g{`|_blk#IPM`Hze6#H>=I@M=U*>)(o z?9)LVzb1gx4-bAeS{Z0)`Z*HY4VbT>vSo_#^rmFX8c{jSuet}^YT?YwB=3|z+vQ|r zryrJ*Bbya}7=s0w#6|SNcu}6((9C3-s@AML49d3S?`U6vTJ{aoDUra=KuUUn%#4Oyd6b;T=9&WW zv~X~&QA4bP#_CSS&tn!l2xC$hYvd=A5u!1BfvoVR#+S5HiFFKIfMl|D4O`%G$z zt5-RS&_dm>=9n!~k($X$WMEBfF35C(b8L^ss?jS#56pnGK6vGiq`arNUTh!@Sr|<9Vdbk%HI_LG1j@ z^s3auY`!X}LDr826(rDHEE2C5HP8AnTDi~xFO2m}$&LLOjqQ!berdBa_G|$Lq1ej> zl|38gc062G<&TuJvOXOtR|naQQonIvG7c0}Mi@US3C4p0bzMJN$r+kkS(F&1!MdZ= z45Stq$`P7_dxY*VO-#jGVQg9vOtlu-^p;?K*tiYl4QTWyrIeri_+t*A^kXtO2;ORDYz+*T$Q?*sg-g z!HjjlpX0bSYpa=fg)!zYV49Gzt+73Y46F2fd!O58e$!?wsDo*H+KdLtwJFG>Sp-607d~U7_jGjGf}bwmX%l0GBaA4 z>SqwM6`>-zv1WPUP3JRRa%1D2ah-zRANF45D3oZi-*`qNzJ+!%Moe@u6N<`hVg&6X zpeeC(6oq(0>+fXbRL<$qoK;htaO+7`_yf*I@5CA+wzgY)ga$1!*uBC-_)6JgADtcJ!84j2- zZYDIs{Kn(%k-D0g*U!Pox{tAs*BBf6?sf+ z*V3PZCE>iRSC;@Kf4SZ^-k(B|bX&O^rLFGAWnp9Jng6wz0%icGs3lbD! zDrXXEw3M9h?g1JwPqzr%t4NDXIeOiQ%6YbFpz%Bo=U3+0^7@?G7E+)YQMuSB2pW(bq#A~$AiplEM5(Af3pnW84t8b1g=TX!ac zOrH`p674)K-b0Odt!RSW@_+OW_xx-D~#-#{xR)KU+^-HQTK1WwOcAoE_sV=eS5UU&iTxb7| zGes5oobK$dZbW&jRKJz9i&sC%Ly_qKrn}GN*FP%1rg{i~HE$KTC7T+}>SB6uRH1f_(Xc05X$ zwb0DU-BSQV>Y5nt!mShWhs>O7_s$)RWH_1U$g~1XKBzt_59NYEz-2 zX0!o*u!!t(Dy@*|Mie}Iz!(oT5s}_E`voROCJq}VYBIRIQIAOM$AZe!G!KgA^W&y+|i$Xj59$ z3V9aFlYb-g*{X5vnsS!loX%?X3DFnkEFrpLe|XKd&~xDx zGc~?eYz;4;1T$jA))BF%3t}&lIby|bb_2`zhF9c~6W%m@HixwNvA5W4M|TA>8Lign zE$q0gsu^G4osQ-rv1h`YhRpU36=FkXN7;UlHzx|`s2uaG*W|vaj#zX&H%f_Y#yRaI zk;gb4e`>_<41`&>SPLo#v+dm(F8hFj5LiyWG;lg2jXb8}0cCLrWHMbFH8H3)%G=@( zkOwB|sfoGxbs_e_yvnFe%J2&f%G+qN4u^ANJAet5FKWWGlTMb!g^5;JHsnGU*)I>qqOs@Pb7jQp zS2Se~erm?aK^{T3AAB7J{n>tyHGC;foA)P?vpH{fj-1SSQ}n3AroL3zJ#5--4x7R& zE@7XS&(T0Wp@fr?*!NxtRNH)ZIDZ%2MN4=5Ie%B=e6eNQraNbZ9JI6#ugt+dQjwD! ziP<~|=A?1f6bVF@E@-->G^sFh<$`8blaj7m@SZW0o7tC^37EGD;T5u^br{b_ZFq7l ztXq6r`aHLmq0*<>%jUF>lp#>#HYM74p{N@$km1NcqM2lx3GZ$RWQ?DN#F8o`3qm+ueT4 zFZ;#5#mzuM7eKSs+w@Hj@8l1 z#lb?>hLiFuFCL))&{Qi$2dyql)|ctyL@Nt2 zdylBh9bm2avgAaq-Jz_loab&;1$(CFl=rNkoBegsms*QLawoj}6PgCjUy_)$HE;R; z(gbaadzaHcJMF0b6*HyuHY!7rVa_j4qQ1jz&5EYDlBwmYL-n1E2ik`=39q~fKD_Z_ z`zdp%oPC4?<)5OJS5V={97ZcOYuy;o-bhOga% zZd6NqP2@>uu6D^~t`=0b{fs@}!4%n54)ta4Tf88D+8dFwT{hj&*caY7wy~}0wuf1n zS6LGD=VpJm`1LJqfa6$>uX{FDyWmRN$M0> zmXOZtRXcm%qH>T+PmYw;BwR6{_!O!Wo$$KsNQaW~j_X@aR$VF^FXzL&pti?G{-~q_Tt?c$aD)yn2fkULdm^$k#aA_8`;HqeiY7 zkWZ6Yb@+g((Pec2KOw0sVXy$JPh*V4_iN*}+~}aU!&AK_ui_cD&ib%Ca~}!B{mSz* z=Wos(Cr=*D{9HD%_oI~$WPS}>$jkIY+;=TEIllTQ^{Bex6Rk^cvz%t_e)1h2e)anL z2#Zy02V0M8A0(K@-c#D=40*i^p7x!j0(L;;ueb>~9*cukctsafohRfKd!5wbR!Jf$ zBW^4iy=4kXG|u-_milkv#e&y3-@LCNGB$Gr>B)5(?Jd$tI*$r_?Y)zvvV@a)K~Ge2 zs$6GL8ixbC=2pv`H(|$?2~LU2oD%cul(@t}o=cL4Jljb+MEPFQcvINMuIh_sHO%zM z4q%o8_&qNZyef2cth?6%Y~du`RtGR-0B0D$_Xty4^!koAIY14m==wT<@4n}>35TS; z_SQ*%hIEpK)j@rjq_TvtL0v4=@ebolwKGZO&PGXS*`F$PVCmG29bWzP~ z==Q3vB-D91$vhwTm_EY3g{C&6@#bSkV|jcurf=2Tf z@eoJA*>9Jev!%oVAK-vz)B!(NAbNC&y``|y>oO0mD#wA}UZ^xdf8~-h(PGNgM0`L(IkUfvT+;2R-g7UqFdfQ1lzfLK;NGeOnbV^~Em_Zlcla8U) znEo1yOn-O3v}LYS=J`&UgX)yI5VY{dYckiN;T9U+xW)_vyt@m{s9zax=~g89N&QW;~HvUH=IC6=!H>=P~5IL9Xtrpi<=c_aCpC6nzWt z5vumTZCYeFZg?&7tdn$l9dG_kQdvR+c~h-|w#;$h<^TuMD3H7+z8Y0C`5Sn5Fj zRHu}w2J)$ng538TlRt}uRgtsJTxaAT#613x@_$%|__WsMcMqu&-a7PEv~uln*P;6! zf1xUc;JAgS3a0Dl1c}Jz#xI_iXl8rWd=|w>>}f4WAF)6&Rli1WPk6y4-f-@w7DrbG|EW}hkX$~y30kW`kCFZf(N zroNv)?Te~QIX%#lYu;N{y~S|_kMUY{%T-lp5zpp>5X;}D;kFEQYWNC$Ne#!dZvT>_d2-s4eneQ zH__sgVVh?Z&!1j2Z+@3(HnAwYJW>8fox=2r5zrfX-QYFh%WtZ%g3Lq{EKM|egECEX zRy|B6`aSEEcsogD3BR>-Y7%vJA^AN^&f1dil<^=fkB#0Gdu3eYmJwC0@=Y0K@iIm_ zW$f`1*bVkKW!$VX^sF&lar-uw2_E=)gdITv2ScorZeGdsk(DK#M?{tm43?a+r2sED zQFn&}j%*-iRaL)1|AsfF<;FfY#h>hzR`oJDXpQy~DE>brl_fM&@%d%XK)5xPPI4DM&R_LG_Tqv4)pW1gf}{;I7uJXNg8QNyT2`^xD;F!WyeAvUq=gilx`u%lOvVSkh z9vt4tTfW#Kukeq_;N8zpdp3kip9g$`2cEUn7(VS7mhOs_eH;$2XsDP^eYN?mvlGp;wWYt)6c5mrz@LhbUQ^5hTI+#=i|dZo14S1!*W*alP*mWbr8Ljs9we0| ztjeTmDiYpgUV6c2MeJ8C2MN1K(`pb zKj^h8ik*R|nu7-X-86?+v2&dyJra0H!$>MixQj<8XUtcJwdCYEnCF5C#JfyouSJ`n z&uDhd$vmvGYzMfk4)CE@1w359)jH|j($~QZI+(FKm^)C-jeS^)St!hZkP5!K({uI~ zgZa}*!rc8c>)0$3%sHE$XZ3KngF^ni{%nE&KeT|&1Jh|#WFH6qA>M!eFErED)%!9} zKU#;c;w_JJSxvsxXJYwCY`Qfxl3$R~wzyy0>HRXS84Kpmm{u}ldd9#3y)(uZoK@U8 zMA#n=k-92XXq8k|}i|9k~egjA`@cmK4pKozb>i_XQ%$ zktGfHO8C~{os)^h<03un)pGCgC?tl~LSN~X=UyqyXm z*>PY*{!vvDe2u#YNLH zq+MH}YCfQS+v4_WhXJTuGNY(?*1Wl*1 z2b|)qw#6A8+D^}yy0BzMamRkiR`JYvMI{-9rU~WpnCKa2js z>R9f&8aX%1zU)lPUXX6LNKFi_0#oTNq4F1NYT8LBH!)qeOsFT6^E^H&AuWAqTGP|R zDVK(pT7#NrpVA}qBqhsUuefHSXAG6drSuYLGU++oy{j|KHBgAU_o94ja9a8`zF}!i zSNe0)GRpX%gq@t$G?JD+I4zXV6NxbY12v_MT1cNuX?vhd+N-S+xVk|fLph{fD!rk? z#pRX$h;T=yb@Mj@IwRtt=cSB7ttqu^m%Zd$jM z!AM%)vc%l9`M&eg7R*SS?_ZykoYpsz)-96O0ZlBZ1r85Qv2uW!4Q4qf&&}uyVOIL` z(wbf)KUM~?C`%ZYmgAd{7A;K6@t=fG9mv5g2P49#BQbjjjOq0EVfCN!3yivcQF`33 zrMB-*92)Xp4&|k&AM}}$ro4PDgZD0#(Fp%mk$x5V>WBZTdux{qZ)5&1H zR#j88Om(LFR{BPyH7)ZGOIw=eZ|-(!BrSxtW%M;}doo_XQvHnna7X{4dj1NZGX&6& zO1}WwivIVE>s@TG9bZTOQF{zaTbJfv8#u1FdBW=nrBiNTw_A5JR_FnDtPD##VEe}( zHCT+_nEiSE*#dvIz@IJfXAAs)qy@a+-SK{BM@yZqkeC%UsRfyZ5_3+Dqy3@_#r|qc>O9Lt5v!I4CjKvz(sj(sHJ2pPTP(>|b}1 zEv;9&_*Rm+8oPJz=9*dSCOIiuesxVYf1Q1pSPpc|#fPBHrGq|k9IXl9CjIbvs+P~W4(N1_q8kk&CTb4$mDzVaN+-7yLrF+b}V2w z?f1BH-1{i^KH0rHx&Qm$&}2t<*pMOpGCGW#S~|C+G$X4AzkAiKOYc%6cE6%~&#v8i zcFpSOW|)nT(_v0?=J0_BU4CzrIb!_pX>dL(Yq&SOMs;h zmI;<^3hSFom$h<7xplmf(?baxFt2|ie*$kbiEv8~C3eB#>_7s)42G1jUWw^}CrC`U zL(kHS>Au8sBn1+?o5bLAB-YotVnVwn6Qu4-s8-SjWlfPOy2So5i1jNw7tu zyWmgm&1g#$`tX5hq>aWTkoJ`dP1`2(v~~>t#I&p7Q%zf@r?o@7LE70EHcnegO`2LK zFCyEZoHJP{|DXUX?J_j%v{T6PTPLgk5~BQ>S}u*&npBy)v0jon6TK@k=aaCQhZ$}+ zqj5q6Cc$bY?Y3j!XDQWWZ}PHzR;zoIeJL;(Dz)w3DEJ~NcAdLm0IA9S;1`OslK;iu z%SP}gnEW^;Ualq@n%o#i7Kt5}d?lVP78~u)5s#I4Mal%U^OJ8!dx_F#CBKKt($`=w zOnwXYO0l!8rpgqu>vU6fhir{J$jLAEnj-Jw4e6`mItJo;-|%wMs2W z4w8CX6H-Sc4+7_QDNIOiP3k%oJduIr+fJIPS$i1cbJ(iCAM?aJ{L4{lcN^;AB>$8g z(zx2`X9Cib{1bDGXtm=L>{k6G|2eLF+NZQwC?{2epbW?pYyb3;MWad*yC`wMz+CFH z>6uUKTC^Jw%(?gQmy;Gcbm-D{7`&I}h$v+lA})Wdqp(rb#y?Ag;SdxM=M zsHRQcz=M134mmPCqhlWqz4O~5iuB9-^yw-2E8Y=#_Fvp7?fB=cB&w3vVSEjJliU^Q&q`Mq%?!Vmslls16AZIqRN_mBys_M4K{}Z7$w+HK1HnDI7Dk)YlkL<5QdZUC`u2@bdXGY@aHL{CzR4&DJ}T(9FjJa34;33g|e)l zf$~jl*des)f;V-xIPD8#9y_#9_j~EVKi!(N}@Kc(3&dd=x#k*aC$8o_zwvJZhUY=Xzu7PsLbd)~ic|_&4Zg@FuDkyE;+*$T zafV&*8A1Mz)|KclkxjQkd}%H>FMz2aDF$`B7@;kxLyBPht2Zt&H(PhATwS98dqjKeeXiBh5+G>r&^3BFxdhfjbY3+jy!X}HB{y^ zQUaP_0gWG96Y5n=Uxv8aY^R=W+?nOG&O-kzyRrQ~snuwFqC#Fp2nGRy%+*F?zS=di zteY^p(Qay{j=pZ%0;(PE9gwuutDQPMkg_0C)$igkchsq>!5V;0e_6(-P84YTlV*bLdzZmwUrSC2xdsJQoF-jSfp9O&!f&Fyg>^o6$X9uK+z{nG^?=<{I4OAvG+%ybEY zo)0q@a-eUDLwg*Nz+AI!pN~0iyuJ+m1p*NCN|@ym1ic#OIthZl73Qyy)p2td2c+e2 z?v1qR&DBpp*adbydnu?{msnl3yymSBnAE;nF|)KW7^Q8&DZC!DMmURq_Ye#28Fny= zM)z;A8RabYJx-Zrjj_`&BDu#YGp4eINf9!a*=VnVcOMQiegdJhvvpb5-l$mSr3tLP z_5z-!%+{r|-CF!VsDM5V)OgyI`BxA4MGHsWuObKec^I*=FpthR}b0K6k&>(3Nhx0h^_;9ct z(jkdG0WCFzK2>i9Obq5K2^ugrz^s;#jc^CdZ4xwKHo-gsnE}%#K45l`pox5E`C#>!OsF4mkGIkJWlAHiH$~}5DZ<VWomY~pcDohs%3O(5{eIO@R{N}>>tzp15vO|H!f}cml!=2v;X&suw=zEKBxd^J)Z3Bmf0J=5 z8aG1ry$FxNJSyQfgim1Jm#`YaW&-{yVFf}fgeH(rdlnBl!HR&~K52}QH6;3=(E~C= zq5vi;K|^8^%y|+tB#L0>LRNy-%ENI#&jZbF-^Hv%E6Bf62K6zx1?DCR2?+myxmUu^ z7;lC759E55;k|LQE(7a|(3hTPZ=&pIYug!&^J=dw}hAsab29cH2gIkyz%G6`~SEzC`j(`x6tXg=!^(3XZ`LQ|_fips;X zk#AdJo|GWpo`ZQ-f_!@o<`u|6D|XQW)(+6F487^09YEzB*$C|im~SKqErd!yYC>xP z(*$yAS}9I~^%7{yLTx!jFg5K=#wn1gX&;zg5>(R&%peJ>>2R2Q$U(bb99O`4545sS zv4?gx8Dk)WHW%hX2|~LPX0ZgJt$|quIcPh?aRsbHpe+w=^U&@kV?AWhUV?d6g3vyP z`B;L`f;tcpnLd4wWL*L42xu!pVcwRRnx2G8OF^qoBQX6X2yFt)*%E}d5JvTJ(0+@f zHRNGvWoUwjb^|Kc$wp`!VD6G2w8vpKNf6pgFwa2_T61w+ybA*Ds?d7S0_xJJG60nF&^Pi)2Wrw*;ZR0P`&5pj{eAyA-sF(0&i?eN^_#Mrhx|d@Vs}iKzIcCbZ@- zCqR}@`_5P#O{c@xg!ttE+Kr4ZsC1N#_8Ws?21?L=V;szA3E2oUVWvyae&ceOOChu0 zxXDGft?NPdwwl+FeYN!UZ1NYF8zpGJu^#4j$gSqTTyvjw8|Iav?Zz?mwxjc;j8x4x zVP2J>YJLv$sRUK?cbK0cr_r8|S923+*M`ztqo(1J4s5ERh1M3PjRc|fh3P3lXv1NK zLC%oXpw#8J^pf?u&_tnW$WBG&T-nq|*aY*4gam}=V0KE-(0v!?Ey(Jmfa{~1bOqv^ z+}5=!wAn*Gg!&h<7jiPGA>|0U5ljOKLT(My5_0_P=_1?K*Z6sTD0m`j^0Naf?PMdg z9xz!Fgf;-CzXYKTgUN#|UmsS(X)c=2`W3Vrd}o3dIA3e#DE^E_?+h8{BAf>^9!iyF za3(1;kfuSn=XrH$#CcRGHjfIIm`8=hdQ{-yf-CWYfh<4qwh0FG1}XH($rQ1coXY{5 z1KFz(9*6mdgyjgo!h9)V0YZPA>;k3AKDdFDc}R1h7LOp9J%d+BqBR7iTT)-mz*u{R zMW`)+%${L2Or-?v86JeWPlEOg+hCrMpgqHDFnb^qcW;X~*@OUlhAvkLSsm~h8Xw6- z9q=2>VF~JhFe)ihQwN*`(;Bi8v;*<{Ot$n!+HRj@MsN@Evmi5q2f~~tArJj1%uop$ z!DqsZg78dOTjQRs2J6;Pn>OfaLQF*GT*$x`!_1Q)xRo%=Bna-WFgHR@J>PcG{MP-T ztqsi+n(Fx=DjOhE&lh2KNl-njVLpgW&k4*rm&7-qPF_dcyOq6-)~WqXFmu z(@uhX>kiXRf_ys-rXS>>CA+i9Z@rCge+#9Ygqms^MI~1@LYn|HUV_l(!_1Z-v@)0_ zkP|Ok#?kcZ=l0ND28}A*gv#}@QM|k#<{k-(mruexCLtSPFU*S)6fZx9IRKe>sbH(i zwtfKFi{9 zYawT-ER6e^0@|ISTZE>eayKe>$VOA*F_?`KG*n)M*)2gsgAbZZFn3AN%y|grK?#~UkHKt)@Jt~m ztQC*Twr&9H{?HoLSrhSTbhgV#a4*BWC_!*j|*LJN?}*TEdLCNK>p2<;S@c94TMD2}!{fu$tWO?@xl2BDG-8MKKo=SYxmm%)@s z5ZX;Js~~5koEt}bGlBN-Eds4}U_ON21CSY*J7AuIQl)7I<{L<_K&+HL7w7+OrQ9-r z-gI?Yok2R0;oFHK4w3T}V4pzthX^O)N<&rd4TQ5{Mo4%LVFk?PP^#>MzmW1zqk$?n4ORrzFuJoR^K2S)zkz|0L}3I0FC{S8NU3tUh5YL8or$n+Cpad4ugqE(D0oG zGXXNg_vTirIf8X2$nCpqk(P$<uShOg&m$eInB zH+(mcf2RyILUzGylb}BR2eCvSpCO#lPWfTivw*bXiOMgAO^fsl#(Pr1mpb!(8q+*AD1$&N~2k$)V_7zv8}dOtA% zavgimHBYeqj`^lg9}ZTHV@uIlBqKR?Bg|?Ea!kJsahC)+_BhNY33BWunCBql*k^Ia z9uL+!_CDF~N?(qB4x^KGq4 zgjNo-OoGtvg87>Sp>2hE404w5hvR7a#F70}o#p#&l-`h)mhVF_UqGo+Gg0y|(vOg? z#JL05bGuLhYZf{gc4&83jQ>JT5Em`ES%J_Armch}2*Y3^P^wIV>q(h}Gyw`eh%g6c z7Gy%6ho5L&kMc9AhdTqJ6?-WfS3oA@Rlvj~DCFG&bF&1k*!RHPEkPmgUoiiKOvwA% z_1tIO19JN=4+vQy?{PG?$V4G;C(P3l6!Kn$*(>2^n)F?m{g4y#JU^4I=RxyA-e=^0 zA_IlIZ(+WcppaJs^ScCvyd;WFgz!u&b{Y>QMzB6f#Dmb#F6e1hY>ZBQxhc3)VA@F# z+z^;S5(IY^%$bmb*49PyTQ!Nq-Ow5j?IKiW$VO;a!z`B|w12|fD?w<_!#o2yXnL^I z<+qxJI35Xo=%Iav$_KI$T98yrIYR3M(^i7ehQUN2rw_)*YuXjG-Jxz8JnDlfs7#Pe zeS}M3iXV%d zZWwEFe1zHukeM8ZV7`!`$?-ePuM#vll27GX1~QYQ5ljQf3_%Y$(Rv!>_DQ>?r6JfF zrlkaRb4Qr=64cGTVR}kXH|M|%ge-IF-qo$Y&w33s@6`Qt@}n|PACH3>BSC#U6=t#o z_3><&3n4tyvCVK7%Wr)L)+?cDsSW6 zNzCuizdW>-Fij;0trtv|1fh+DIbDL#roc>qoK`E3*YhOMUJKPoO|4b}Ghc#QEe5kf zf?DlPn7>I-tNj~B9yn;X#eLInQN11-p+O_GXHj`tHbQ$JX1@fX9ftW{g3waXPlTMB z>Zw4N&wSS7jnJ(^Q@drrG?$>74uZ*+pqfsHnJ7UuT?%s<s4 zO|sJDxEp3YlqxkdIsS?C4@g(yq|=y>?mV;2Cq~-Zq14_OuOVj#YMUUt9N`z3LlPDt z4C&588D!5#xC~}4l&Vjh1lN)B2-4k9@P349m`@~ZL^uNT8)T->=kXbS7nWbAj_m=W zrcVk?q6EFP%7AGpLDOddOtu6~pR-`jl%Ta`7R(IDOdk(9!P)_G`=pSx)ODA^ERdkC zTLDuhL0xw}%qj`$y0tL3K+g0DWvH>#b^Ac`rq2fQ?~;M~?FpDI64Y;Rz`PG1^-btb>1h}ofjKNea3}ZV)DJRnQJBFJ1UDUKBIML_Xoj%l*$K(S z&d}EaP(2r+a+z#Y&vKZR5>(F{VQ!G1dfpCm8|0u($Pku%YX{nQp@C8p+Wj#1ND$g~ zm?tF&Z9mML5`=aL=5xqFE6EU+(E5Y+edrpg39Sa^cL_pE?nMZM3|bSI#u9|q2IfS_ zX}6Ub!V;Q(G4F@a+X7I#bwZ`1Y}9VOV0uVUHw=c!k)U=ffH9xjG7mL>&oE%1od?>F zp<<~`KFDArE0G%pwWd2!DmSQG%Ww9)Q^ZnP-QooVMt)t@$8(4>eDa z{kZh??C>niP6>Lbc?D(<mqKH*pneFp;drpAFdN6dZE-qR*DjXVRE2UshKEIfOI;fD^Z_5`7FcmR>0DK zcxz{e#`VRxFFEIEYW

qzPxGE87-4s(J81%^{$I!RDqD1eDdP+*t=Gev>|!vdHR$V|T=$B4Ro zmi{AM`z~#yrS^@%TqQy6y9VZZ32NUvU~ZS7_PrnGUdY*WdVVHbhd}c-otwyiL@AS9>N)II(4q=T>cd1Na%6tX}YA)(1~hn!L^2IEKcG=4r@`rWPCm>9Vc! zv3M`_aWc$k+6(it1dXP5VBVIX(R2`|T7pK?VVEB!Xf*lyfdrY+G{8mnS(kv^zRNsm zX*8vxkqnv9)C8un1dXOln3E)EGg1a2%QVD`v1G7qk;O>E04>@CVUL5Ty z&<=#omYUH11G7rIz2JKb**8mH!S^nhJ0&RiJ_Pe1 z)N+Gs=zh#EuzKB+pxoU+36I6y{T$E_Lt|zAE*aZko{+E?;cb{VBs_!g9n4n}wjcx< zvp&c|eO11gEpeg37crjl)#U$mZg#Atl zdIoe#*ne_u3Hwj2En)v@wI%Ey8~6Sl1KUvF7kf8e!h3EBD^9zHKq<_5LkH%sD?ZW13Fx=z7>J7C6drzIga8I4Va8I4Va8K=E z*bs-k*M+U1kL|xWu=-G*1N)acZTOcuZTOcu?I6A09|k^oCmNTc<;M3vw#rL?!9#UJssNinm5MH|vHREN59~nJ3VNvLPYUMofym`)P3V)TLO1zA3UWxHUu)gEsO{8K%EA*c+JjR16ZbfyFWTnckB zWVP(*8oLqPe5gFXKpocI3C^6GG_=1%JP0-XHNc8%zLIkiL!+k|8jYBO3L3qBrT|@MJ2z|1VACc2 zOqX;}>~z=MwiaXV zi};TD(%*Y%zYQrA0h1*``=0(V*%Gwx z83Gf59JIA@pLE9ACv=;KHXN0F*$8bc%$X8|c0SCx5`;D#W-8>MZH}Yq8KQ6Kpocaa zl?!Dfv`b)0BnWK@%wh>bD~DMLIY%6C$I^G8+!x!*U6wh2KT}IT|xrF6EIsO zXdAl+<^_m{kXL2hsh+LRB>T;Np_BPWma~p`>SO|!_62sQJ{qzO)A-ZbghneVml12X zb)w+*I6zzTjR(KkaI`hwba24hX4itXH7jC0eYf0hJ7x!%*6@Gv$8M1%!PdyBx7{Rk zFhNpZPP1F&>o-2Eq#pyQG%-KKVCy@Jev{jzjloH33@G1a)i%g~mU2}s-_-5~N57@u zn>tWcF<$wmnnMN?kbToSKBROsrs-gUlJ*1Q8|z@*9LE})Rf{#Yc5(KN&3AD5N}X@) zIrMmh-k=Ck9?$T|ok>mZD*gD}!XNEAZULC^$t5Td8n`Vj43Tc7B#S|6h0 z6nCtokMP7d&_U1(PX}RO=lE*n8)&`^=(NbdJkw8FmVARw)$duTMFyENtfbe4)ziV! zZrj1?*{K$*rx_>?R?l36bqOYC9>5hV=@I(Q*V(bx`I=+jxsHA3I`*C8_MOkvqG%;G zkbS0Oug7r5KC_N}W*z&?xP7K;e~wzSmD?6^`&J!m{cmOZ-toWHV9)cQ--ldo{mx}iIj!AR-VdQ!f`NMB0mV@G$G`EdO zAMJsbx{=oVZA^RZx2FT7pkR+{UZLCA=6}&`w7s6&C(!#6ALLIchtSEr`D02M-+U)G z-)bxP3ip2Gb%)F&u~&-Us>GRTp=J1QV#x$F#zQ8S%!ipRL9wI^W{CvFlABTqz?3;B_!-C8(N@!2DB!s<{(pJLFVz zi0h2s`jDgxLg&%VrkZb}^15vFeElKJ0SQ{1e}MT;LN-DT%&{U3Ik;!2ZzJtOOS)( zV8%$0gOgw;NRWfGU@m}6KV-%oOtfqJp_uH8rLTUt0%nl}d380+N{FkER-ARNeOr@+ z(po(VH`h-IFh}(B0`^#|AcqMk8ljnZ5yVM;qfWTMIh%esBQux6Teaeku7P3auD@5G zg0k~tS@ljt=Et(wijib~EUT^aV_7JySEBP{SxL^1WhFU3mX+lESXPp4ek`jVM{O2A zmNj1L&W~l)(@%x)V_B0)syBi_zz<}ph;|`t^s(&*cPfXI8BWR?mU3(Gpc3xGjNis8 z7w`Ta)*ATs>fnSxE`JlJ2K75Q9a;wwnwp=gYC4nXeMQsxBrLX)ALFK7d5X$Quo_6a z`55?FN^Sl(3cpBPi@6K*lT<-HR4f!{1@$m-*#;#B9|!VsximCrev3-%u%P)ZDzVXy z?r%|TMms-vJK9T>J}YQ`i|Q%Z3xjV_nJdN4wvv@8WY?=?RbRG79^~Y|O@A1>8lEHFSrcD)sDMsk*^nUFsR?9S)=?!Q;8c@R4BL!^S>xHYU*>dbn{Jp z{wnr#(~`GJVR(>XXsuOhK`@B%ZF5K+5gf$d+odoe*qYRJDtIDa5H&wTWh%9pOyh3Y zs<$6=eunC8lv3BzKMhmWV@du-leuw)SCbg(X;L4o5Q8bvN>kOtpVG-mImUjqlxqJ-(@MLE#7^NIg8HX{ z+9s`I+><*A&r83v9*PrVLH!s|Lapn@D`{C>{)WT$G6S_WT`9aVpdz~Lwfc7}iTa~2 zFq$MQnTfyB_ZC)N#kP8qT>t7-OIkf=m~~gQehucq!ECLeM19=JGY(iSu(-^(j5_*0 zCgW)|wnM&s2(QDuD&Yl$PhmcWti*4S`qlSJ?+Ua#q~TT~YN%01w=Se`84MaKosRs)#J_?kZjmLf~Ms&;XeY|G=k@f1s3;z8;W`Bp*Z92atW95VeYciIhZ z!U@LruCRxDx;Ljz2q6q}anhI))`|M5nFZfa*s1rabrvRzZ#MrD>+*&QG0lpsML|W% zMu*9+kA3aja25{zK&H2lD2z(S$PHNu;ZInM;q>|4o%Z<3@7(RR|HQG>f08#NS%KZZ zdS7vBkg^M1@x9_yox)vUBBg)zfudCX8p(y}vh%P0xVWL7K`%;v%7|YV*PqJ=edXvA zwACX}9fy}9>Pof2zc?uimg0&U@=JObH&q1puYRM*%$k`!q(?hg9_P)PdA`gfx09=Z z`wa45z3@cMHXLKTYI!ws*dpb2ey8hWm*WXj}SsvHWZJ%P_a~|Xr;3f$& z^Z1bQsbuQk$5rC@9bC?z!8J1+1sBjPSs$+kSsYmrjl zV9tJ$n)Y(K$+kSsYm2$Qk@eiRNXlcBLw<99g2B;a_>bFSt~)zC zJD=4ujZc{QZuF4bfO?{k&vpGc8u{lO$2+%4Jtcna$5r?0Ki5~~L1qKjN09o=kFKL3 zkE#FWc>O)Dm1AWU`Sc@zW-N>b?hHY`eq6{uu78p1xM%0HJkE2x$ajZV$}CE_K&4b2 zx0Lorj|ET3XL(#Lr2bp7ar{c)mI(6K<3b+O7Ky!`s@s;wd1GOYPruhcr|DuF{s+9i6E#;#e*Hd;r#js|=d8$6kn4woBdg@V-C6I^h#`Cubfw)$wi_rRcKPc42>Ib}nPw zt;*-lR%NWaRr$Ke>`l(DyEi$<>`kg^!1p1uHyHp-l7Agbysn09xz0CRF8}JK&Sqp? zHz&)sJkA?dQ+)qo4{G|Q@lb3b|5L8^|6y2pWoexVO>skvXXmrNAQDXR9rloQveZe) zZyp!<^&H1LcMy3>KFj00K{UlToE@ieR>xRF1o`=KA!`Sb8J7*TQRCu8r^|WupWyqK z2dOu0=Ls^))%joRKf!g}v*V}YT?EhZ2|oRPjd8q`5{gwy?r}@`dya9GKgMxSi7({- z7v$LGFf;{`UkjJ#eG*`*CJkD#2$-XB%$h(1CFUV!bg*+<695oguyN-KyKFj00 zwwUbGTT0UwPf)_+D&@xGmh$+~ZQ&{TERU;&oaRBk4&1AP+;CjTW7^`;cw2a!*A^3f zyFJJ+fjcP3SC0$%$8Et21apkw+4-#Bm=_a$=|o#oe;qyhMF)?I{Ls<$_mq5=$9eUi z=$qz2>YTfUAdegu@|gN3y8+g=JkG2Cc;9XhvKMeY1liHm{$J+>2dQQddv-p{6TFV8n5N0mi^UW>GOuMKICl3W>%I0e2J^f*LU{U{HhWBXQ%oJkdySNz|Z5T%y14 z_f%DP-^-of%jf_9-49GxpE_HeI(2I4s&2#BqI!-^S&Pi}IxXX7Fr7!nCuU?EStL2Z zgc5c)w}U*^-F$FRcjG0ma*`(*imO5KilI2Tzi~&Rw{b^0$pr@eZv(MeHIB|6G|6KX z{BeDuFCu!levsM;tw8TH*AzwfKAbDF3y|KYz$sv_((ADV9uu5)h;Wow!ZPKq{T)P0 zk2BizIM3)FXN>7_cIh7H5YyxA;%xYP(&JnP8~z;sV?EBESdTN=^f-Hdmfz+aDHLx3 z`$hb(YQ$h@cPS?HEza~@jEkMpqp^B`|I$3}!ah&_T!-&cKE^*Bn9@8Z0ON4{_y1@x zx^z76Jof8e@)*O?5_rz}!tx@^awgc-ljYkPS$-7`Crvwd3)Vgigfq|sSA z&#@`1^#pH?m2v|lTu&({Wt6hqn8UENNH|t)&?A<7XOtM@0kFG|EV*r*zf{E|V~CdL z86?-SDQk-#debm{6_(F~-Lqu5E+fm+CyE?+09Hv-Z1F>*xTW(Po3e`k(7VA(*$WAO zp_JcdlyZ5hEi97f*i^)lZ- z4gdXWgOh6B()dyl*Va%fRjuvRt2$Wm@_F5SHJjZ26D&=Jqz_ zzZL9mCd;=ovh2G2a7ih@r9+HB`H%KCTh5Py>%-*yZAQ+|L}Aij^WM*l87!{n*pxNH z(VpMO==?IczDUmb|4XbC%MLV1u47Zy3{$-p%kuAF_aRwM$;h&6Gk`|SVCg*Pc$i_T zx7l(IrhzN}&&|mB_0(oykvzwyZ26~pKJ=E5&>QS3$g(pd%QQFWeVBGM*Rd&E{$stl zmgP9G8$*`k4R)6y!FH3!ni{hN_>{IGwHhDmJz&`%3)a)g{`idSdzckN!_p$bmItv+ zRv9BSgWWu`7 zO&`9oBzrnm*{#eChNJ5|0i0pUZ>o&UF~Ayy|Nea$WP0a93DJ3LNY8JK4e`gb%+z^0 zXs43?xlHuufPOT`EH8)j`#|pwEfL~*AZ#M>-!c$q`;p!Cz^BJ}u*1)FY|6SvU7qm! zIe0B6ziNY-YQPvjY0ZNUxBP6%dh_Al_xhOv{u=z&kmpebJC$eGtr_TII!os{U!drR zd*@oto51xBay~sH=cF0T8b`KBo?}zC@P~VUvMhIi-Q#3=PDYlI0dzDA?K#k-5O3u- z&HG-BDgWzW_uphWIipRxE$gH1A?7~?!IGG-NR2_NMr_nBNy$2X?rk`q>g z_x#Mst2O1&Pr%Evu*Zwsie-O5Z8i71RP*Wb_qcPXn){n4a{fD}tOcJvUMV?P$_I46FSX|ND{0;iUW&cOT+^KF=pq$b`EuI`%UBAInd%xRnB% ziu)hpzf9Y=f@ikBeX^ESwWB-gc@S7EcKUSN2H=@MY{!2Ir1u$>T;~noRr}A?np*rX zU_6ii{xb$6?u4+NZYeSwtv23aF|ifH&{S$2z;_fh5bxLro_opnuNnB_0r(M`gzWWv z|6GWgB~Kq@qHf7N$EK_cR_lrScVPZ63ivXkfXMx{OOG_`^{!)6wnf%>`>fI>$Dz9M z-#^}9$BmJ;704Q1OjBb_W9cwrG8{^cX==P54K}730SUt?Wno4sn`5S-QDP5oEdgHq zVo0q~YCOKCN-TdASWhAQvoo@f3?of_l|gbHo3eK4?|o-kHiF$8vb-rH%aquiVi!bD zkz(mQ$EIwg>hH~hAB*B=LP95{Y|SX;%!zTAG3z-N)pKmhTBN@xOs@dL%gFTEj7%eI zI5~HKKO2=Sy%jqJY_v7ph3mEEd0)(W!Zvia-{Q}8TGnZ6W+MoAGYDrW zDei#=?9HgsdkV+dEwb3G?2Ib(N*?6C9Iv-Ks_-Ot>k+}sl3CU6?A#ycx+n;C`}xVk zau3h_e(qGxY%wvHe!|)&j+(WGfPf&ywY>wkJ)vu-$Qe)Et^_cC8Bb= zI{;Q^K~^<{9SWYgV=2Abwc@+wx-+3`cPM4ZsZ@_axUB?wPm3Nm=SIZu1@=#=_GKbq^Jx6?I1j^lD&mVn7atgU^P z*A=g=_Q#5)zgGtdanm1~?+)lunCthg=u2=Ogn!?7VG`Rw;Z3Lte18de?uKi6nDur?g_vov9$rPjT|9mjsYf1y9MfzrcsIn z-6W^P*L`Dsvkqj;X`f4#t$lrWuB@p(4F)Rze59Gzr}eQhj1wMc-6as;XT! zH+X=KCDr2L(6qW%x7mInpga~6YGS5KYn}b0#yGPi-(_PQ5H-eux-r(85QuI=cS}@a zknL9nr#IChIs^@6(;OBPjW(d`Y%IipK*D=|NUve2{zev>>YpxDyhtaua^B8`L5NqJ+N?JPLz@uW3alXa5S7%y-16u6%mQz4N!RmR1=1QJ@eZj$lT)+{uw)^f~N!JK+q>KC8)!FpTsJ;y2l0IPx$?QuTiAP9cTqbAO$=- zY6WM~VZZNZ*M(+1a~6e6vyzY(!xnJzU9Dd(GhBZLo0qL5lKoz14|NNJ>_mPLj0m!3 zqj@e6m-OM9I6D@KJBbP;{JLqt=oEg(rStQK6p$?rvZKAeqM46!jl{Ab&tFb!o?IB@ zC-T`ME-+ceLHIbRR_RxhqLG4Zo1d+t=BE-Yi~KuD z#fV7nJ9YK6I&Hx(O!h^ZsqG(fQT}ez!HpdY-OzCR+@N2Wif%ih+(e%48VveB4f-z+ zM$eiabo(&{6ly)KBEQh6Vdg&k#xupWUr#z$8w z`IH2~F(|OVJmv}iW5WKeVc4gNEL6h(FB8@8JC9;={pX!okWk?NR!4c;-_TG{g@YW# zxpxf18$yPF*8;p6w8*D+S&2gJH4}{2K!57)uhm2`uL)77(=w$-$m<6Gri1Y5@p;zc zQQ>jle9chZ7^=py(kt?VtdoPh2Hc)(G_n#lq)SLeKgOWHVOX#40{x6s?r$3Ob(zr- z*I?70sICC*D|Am=SCMI{ziAoMSqs`x(j1FJ$hVBZt5S*{jylsD{Cfud%2f2O!pZvv z{nx4JU4@em4EnD!qo=gx4-M`X;@W`=i!Hs23w_+N?HDv^XSjH z{tMEupw#B`riFn{4+L#o-DkL&8i~}^{z#AE)PcUjAw|+h72+;g5F~YIl=fvT&K@l2 z2y$oXkt5&i4x9?JVs<2t6w1jUB zYY8#`FI$2QMse4oJ!jhZG zq1y!*dSoACy0x#w^Gp?xs2|7QK|S4W5;Z*7?f$9VPE-D;b~d=(H)7zf-7c!2s1axG zkm;3ZFS=hrI$v7bN*Uzo)&><5{%7Z+ad9-9^e>gX31YJSx}qZkz-i9kSeTnKapdX^U|j z?n;9`-$jE4a<2dJx#P{G@qCY=LHs~kx0W6^bC4+SqG=n6sa7_jC<_zcm!PffUZIkh zPh8>O^ZneZTtrFh30?sg#3u9vdeHfq@#G8XD0)==|Fvt;3_mvvwZFmmc5=?Pl$+?G z$0ldNZx}93Phng3*C};pTc4LW3zXf1ECyObVZ)5X)o{pYDYDMe?e#cQ$`!_XUuyoT zN<~BBR1v6dG5LXiTd9?X;){bQx+?f8L-EgpD703zQ_L|h5XbymSPJMD42D0RIgQ@>C*du&XeOCtUjD-hvO#h4b z=K5#M3iAE~lyqU}7!K7pnlitdQf3qKXQQT5r(U-i^jA{RyNVjO8}ye`(Pb3<5d9}U zioVGpzn+SW=vBZ8M=QI-Shy2f8adlPjfTYsnnmPVsJUL+3vExyvT-#s&o9>l zRLE)NZGqf!#9_x~tEHlKg@6imTNmWkL7Ro>sr}E8o9|zGF3EJQvNHU2x>{dGwd(2b z>8Rc#^thu3coZ%Syyl?rC&2wHM@FqNE;ir zaOAzySm?gEkza**%s^tcnMT37@qxrkhHO)uObe6=ecJNCpAEV!4}<|h^vi_)qCvkS zmAmZ)Q=*gAI1rTqDClB-@vQcX;cV>6hGMaL>RyiG#2w0jN$;GSGtsI^n{b?0W7qH{Q#2V_i^C-IaC;XqV^T_rGPJ;=4G9CjA%*(=(*jkSkt5%OU>sVhK zcQLC#QYq6`uLG+NcijRo>t@%hZQo&fS~Pi1ub>8I98wP5sU%kJ2UUxu`q=c(X4Xfl zAh*nnX6(E)He=hbdBXFAr}1K~R*<0CIN3B$CG13$hT*dy%trC7BYid+jX`1&?|vh| zGz3%b3Nt@vG>+oh{{1V>jPYiZr+c9U;49%r*{;d96ArVs z5+sVo?=-9AyN$qTIqKb4)@gyS8>ZU-^>FJiF?TNvdWye~;5E(9D`m~jpC?aol2yFh zpXm=uANcS+KGwiPqObA9uDgGCI7E<@!NPddh!$GICB0buPmLGT&9(g9#qG~ z@Eqz3rYWlt`cWPALVu_i;C2Ep<^~)FSc5-i4bDc?b$`Ci*znO*^w8i~7D9_j_}dNM zo5ZsdBO8YOIV?7!5Y6^V<~S9`Zrf`yDDBiOZoOgtj8-D7NK{#T$rOu4h#gqx&h~Tc zxNwWqo^cltD$j3YpN~O0x&%5~N;R^ZqlpskD=TM>PxOG=MZPY3Vn1SV!nYn6wTu1x z7p$YAuKx><(K+z{bb?(5_wURoTR>l~OT#O(4^2B#BuNk??OY0`x=-K$s-q|H?r15I?b+55=&f z4>5cZ0xAoZ?Y;s?t2?a&Y|4WCg(Ft3TX)w=v6`I<>Zx~vqm2_1{uSPSJg}ncerlgr z6(Qhx3jzi|g@ua2y#795WK?8y0UCcHI$AysS;asA$$0X04sFxT|FuUS`T-weP16st zej#_sKziNsoaAroo+Mw@EMNb`}8rIhsTzTeX z8vzWqWVNZg%M8N)6lU6s(c9sH1Y%E+cQguM*Z(gyB&L8yUE}zREG}#_B;j8rxViqm z(EqN*y2uzYA+~J%^&C7Hih`zD*X24dhOc=+0iP_{Xpv^D)Y2P;Cv%xOMOkaCzhx*N zA8fE-b}40zDcCo}HO6Ay#vY}kW;5-skS!MB4hu8jyEp?>iG06MMIC+~KowbOIWnB#4!Uo5>)~6l;NjJQUUY z3bgQBsbGM8J^rgQg5a(IJhu&cPDh{IUPy&H&^eq9gwqo5CdOmFsPGV&m0#q)L?L=& zSd_qKm;c2m91NED`%!In)6nJTm`R8XVUZ(CmK+V_;t4$*C2RaFOo2G}>yEp8ZVs3o z41#`rkl$?XpqpsT>vS1OCOT$O%_2UxEb_OBEkak%WD=?OX&4-!YHf30m}Wehko`t4 z|8em=u_c&?=0t5FCs2OtYV_RoQURjCS8)Y0u{HBdbcTr6J2m!Dcrt=d!rw}>*qB?G zFzXa{9}@&rNagy!I#=8(+6l202S=f^H-_#En?4BkY<=9{LAzY0-I=V55}2|7FV)A0 zAfo)8=pbzsZaq)R@6{L~4?#$_|D-NMpX5UBf|(T*QW2kddP~PpyLY27u?=NRVhc?N zYplQ7VE1g?V(6Gi9yJ;m`hFUv8~UpxSH&Nea$%E4!aQ8O7WCL^<_6^n|J|6wtvyF? zoRQ6H@`p~DNcel9S}_cfz&cftbidFBOKDgSpsf=A^Xv(%udn@Ijm56F#Q!oT`oa`n zM^4bkzv!Hmw9kHP&3u+qG>oP=Nm|TJvOCn)-v-j5gxzS#{Kw8&M}Us_TktKi60Bfev8i%CmtSPJckWoc&K;?siuLXT;^bl79cce$6I8g*gB~f z8&-&JquAThCW`zUy2N%2v zi21OouTm=pqPs(W-0UZlNAST(yV%U!7cq0Hr0R257_4cOyvewjpv!oY{BeyfnyQU|_-@{tAj!6AXNiQ`_VSto`O7R-PQ>+{-EDszR6;oyI-f z_6B1UXQ41yS-3%0N8%Mxc%|I=cNIzQU2^9o=;bh`l%hRTDX_PXH70qeD{W3+pgDf$ zEO8P7VXZi-RD7&ZI6#82ofImG0^!C@3>MCq_8wFzWobEl#~Ah8 zVx;2K3?srzq{3}keCz{5Gt2%x){B!PA*df+rFMq#9qi7WIXm#MFEZI%oQFQs%U{0o zZt)=8;K{7Y5a;8EDGRnka$J=Sdt?h7dwJ&31WsX(}s`o ztg>*KF7sgcG7;BN6!c`Ql3GY4**W8KLVIhWk{4EsFVGx+Uc6#7FwVy6 z2OSV$A+JMA=Nh-`pO#6SY&ehS$^1g>q&K6-^J4t*l?3qry)hyeeqFyYlYqXuJW-xy z0wZ>rhXVA~1z<2S?GC`RwBZ(H5+s}QT|R7+O~R(V3pB@Hw9?wrFJ;UiL^k>AlE!J% zoZK&^A^|L!@J5s(=_Zm#Npa_fT3AkS;7VQGC@)srF7r^bN=ng<$zK>Ujx{Ij9~G&w z%&W z{URCbQ3p$2rX@#tG09!#LGm&!SvMKoadpqc|7V)N!{VX!xyxbo1)Ew|On zZp)1H&JZ!oQG&=-Ug4KBEy;n98T3VHp#@}+M|6tp2!_)F`DVUGkqMcgumy^Hhm}&m za+O(CVja+#qDq)!%Znz|MGh=+rnW>8Cel1K!bN`9A7)Jt{kbIS`t|G+a{S@rb-gAy z>&6Ks$&}(^{KjP#gWZ80mA@8NgAQi3PP3AeL!3cCKXB8m0)MuU4Ivo^(lfL%ht1*< z4Cp(P<6cbp#6UW{W8Fr#x=22sA41!VB7~AdXl(VyO6>xax1XCtfI|iZ{FL6{a^7%o zQ*IUm>pTcEeTG1D{EC$pCGY}k9ro zOvjjzRSrD&pl7269qieIwU{W+vVxc{^H5WRwHUetw@YN}^+O!{k%g#)?mvzRB?{Ms zKNig$jo5t}(`$T_jap&iZL}he>yN9G3YzreBadmEaZE$wq?1mVe9~XEcWB z%bMDnmb5#Im$ojfl0T5BwWGMk*3Kn!o7)_SY@EBiqq*H_U3z9?XS*XjTIXxbWzEfP zjc2qpp9xLdTUzI#G@Ve3f}754oVRp|v#6;ZB@p$UrOW0mZfXaMvl{0wJ+swW($wAo zdQq>vdGP{M>@lOs4fi_dsN-fYSfszF?A!WhA-*pY;B&fu(`Fltz{l=o7(0r znsC;khYUYtY}N3ERl|=TT{XPDWB!DBpc%fPseO6tyhUwGTbFjW5ASR}v!!+Z@Q$Xr zi<{e#+1}AK@2A6;wJq&{3758k42WQ1GN|n$n+*#iW8Q+;y)&<)ZL!ndj!z^v&2Q>x ziYju-F;gbbn0owijVDYxY0|OvtRqwfuyJYSA6pyLt}$K{ovrOH3tOA#SFtu|JX>Ev zVlCOeXlYwVRSNF1rsa#5HqCG3>yN}oj-NTTONAO6Mmz1Db32wVYj#?io0}WqT_UZu zX-TuwzI0wo(_-xz^OttQSdnAZAAS5uGaMrUe&0dxy9*eN6k2)OJg2ONSGpE^Z!mNXWmbZCR7?jzh-6I|Pe11jBiq zaG^6pRBJDz3tNRX_M(nPeCW4nZp-49juv>dZb~N|Kk3NHlj>*aTWNw*j+@+AKlRuX zj+uhbK`vdo*a|3P1jY$;V8puSb!^MbUzO+PI*pWpPxY^|PU)@vcS9P4j_DgLkyFHbT<8 zWhT%_Qx3xhBhitQW=sO%jH4UtXH1$g#W{oSsN>D3L*k~Da*0|btDkaG!_>)BtVruL zQiT+_WSKJ$#ay_wZMm*n1`e_oUw~h2Qp3M-YNf>9uGEB^EUf9y= z%v;*p-npcCeq@D)DJRvVnP~vy-f_pzXr#_*m3hpRqb5zB9oI$t(gYyNj`^W(cV{%W zwWG~zxc23^{;cN3&0|JK6>6enGxTHT{G}bEMm6$l)Yc;`$N9}?=r}0GJ$`yroTFzn zPM&naq$8#tGj+z)DfLcU^O7dCiq?f84`U{0SsQ$90Yi#deo^zhpGtJ6iCPdk`AYpRlKPAtofbrnGuVY zE@>Xo+1}hXVoLk6BioiP8?m6JZONHUZOtRvm$m$~xqSqBk!4F;n_D~DN6?o>Pz8`7 zhSb%Kz&~-Y1?bC~>*i^Y$v}U!xO0B91NLnr%*_aN6pe?B9lp4wwezgu3tKx!%x&pt z*C>ly=8jl6Z{EQuqsCbB6kggj*r4kcu_jPYjSCQ$TNbo5&lk}A`OOQEpc}Vq0BUVG zyY^Px#uT^ofR2}DJiMj#2imYPta*?!zh&uY!)od1;q#m4Eymq-c#jsJP{e;~#kgz6 zxii!?Zm^pZ68b9*U}>Eza^{MW>wiN*HLdHtE_Y2 zmENI6Mg4QW`>wpC7ytI^Taz~cg8eLrxNTfw!x%-C&8>HIn1j#e8lXGV$?k5jj}mNi z>rGv6bAWAt!0uUSuzw=h=GL3G-sS+?0D+zUQ!9o)J=~qCo*urw&Yh*!*F8}O%g$V{ zo~XNO1OQi!xOc1u+&lK6u|Rxi?2fU3?-+YP!_ydo9~jNBj8JD!)Lk$FfD6FLJw@G1 z@);oily{$ME;H5}E}-5t996k#_!nc{+3E|Oed2ml41en4y7hIi8hGA2mRGW&73nuR z^Q=;2zE$U)RSI`f|0PLGafLkkquxU4{N$kZDBrFT5V(89jiYe6anwDd{WaH|r|ud3 z{21InKjyVDa{t;GXn&$s`$We_6-LY;n(Vxhj#HTpJpL@UhX!Hr=$>tH(zFW(>SZ%4VEuhAEqR75a6N2R^Tketopx z=B-z+6YXTMxL_l_wG+%J7n${nb=U94HA1x1||n05Z!i+d~+nQk@^U(q%{Ecy`vr)rPloJ zy!Gm#QE!jZ+T4#+qSHCUsniWu-Jg!c=5a8T%}~8P>R+Q0KX(wC*NcWGE&d^yS{j?4 zq)2nen4M#2Xqy~ol5{)*?~C#!sq^m>$3#;N^CkJ%7{4tL`#v@N@7yv`!8Vg1(fgyed`?qg>@3xHU(^aV;w=#U!ITxG3^}} zCW)#MP`2o&_6&M=C}P#SLpKc5ox_G$yZJM3cu zK88AiVOWCW>cNOks|UX}7*X@J!IFKFcITMborBTW?HqhyC_1YHTI?*N`6*Tk0HXOR zmQJpa@Z@0hVNa5{!4l6l#0{1h03mK*!~1wBaO+7P#%J}H!TYG;zQJ3Dpi6mS*q?^s zvUk{fyu3H;gJJA;zCh{3cU`rA@TEii>^o6|CcS22m}YHiC~C%Mpty9%je;!pwUwb{`>$BFxo_ScXu8 zK*|q6Gc{-?+s1}rn?#sN##tK>0_D$~e(3)^H2J57yfZ}QILYeZniqnU=O zQ*26e!FUfCCVqW96p;i&R@jYD6A1-6QzGZvniuL{Qm4Q&u*2KHG`@hq`4Qr$_DN*1 z2KZB}lb2>gtxK~vS0ZR_u6(glhu0S?KdJ)YqpAZ{8gQWM?tuW@J#foF4cId9^+7sx z7*;W!FJ+_jFOg@1<%wtuo|S-qMAi+KHQ>U!!LkNGSaVNU`VZ7VWfmt*;6`Jhs4SoRZ?!BT9Fjn-r zhL!~2nyS~T0C-KC7Jvg)*H;5@eYKbtfGqq?oDuomPHFfcX2yF?K`>SjO^j~E^!m7TZa&u*No3rNX^R9F;AiB2i zEq&QoKC1et%0%?_K=hO6Nfc+=J&{pdQWDQ4Rgdrl=p)(BW;5cxSh=dV4uJ+3i%XIj zyJujdhm!hfa92-*2|AY-E8nWb-CJPg&LMk^g1i~XGrohohDbC#l9J(v@cs8IKdqz= zCK;D#K#6u|nM6hxk@4B=x3j%N*Q-^%ujq{{UV&+VP=zq)fN-FJ#-ZQNhT}>ede0EO zUV~r}SG}G61>T3renpZjhe<(elLDfrzR12Thn{EC#MDrh&!p-d<*LhaHs!EpOcIDT zh3KKPQGOq_tv2PnmIJp~(;Kbf)EH=v22mM{)u2Jt%7W@Oh`bOPUd#EEyreCWmj%ty zAo8-H1`Q%F3#!*3@M0gD_EVqcm{zkbPo*}Ji~4AQX(Xm;mgv;+^K*l9ox=vKSLf$mkc)}58L@5exSr(hc!o;#z9-GAi*)&UJ(=3urvrINk zTbndPS7xQ|>DldEXX1d-{X3ko1NyB;$FZencPJJEQQJZT5|xubEEinrok~sZGorE_ zsi-VXDuiW9g|K9)5SA|$!cwL{XnWiBJ8+-Pm56^*fxNQ*cxCm;J!+3vRvxa>70_1l z3)xrzuVA^QCmeH2&&`$A?by58wDhktq<^&_Y4cd5L6>Gh(%NRAkG*@K1xZkuA-${x zNr0Il{j3E^jF_Pt(hTWvZ4QfI(t1RDZSK}weBMVrT(zT0KJyb6L)}|-AA48zJJ_7p zZwIcb<@LhaOXYfX?OI-qAFFF~U(5BzuGgG!y`=j3YTU=O6C2jxrPf-QO%u(eUAE%2*bOSGSV#qhGJqM%RL6NOfh-@x2?G^ z<Co!HQ$r_NiIirxu4|*?VJdq?!Ku7EnP3oNT;?8{M|rF#gV|D4GkA& ziELagj%PQC_{yV<(EdQ1H6$k923Sl2(iB7X!a#Fteo0hAigd!pumdkbuB81l4O0M{wZwTKCVX z--WeTk}v`_Xb^{M7OP%^NT@#Ven}4*%Il<%3Xtw;U_?aCT+-u4qN*!vuddZ31_-f) z39SV+Xb_oLP`w5jeQ)gXpm395D62P6k3euE%oajG4H{$$A)tB70%8(~&A0dZ6YkkL&*9A5zhZuG&Yd;FsZ)@3nv*w>ruvmsDMn)`Zl{AlqYfM^sD zf;jpE2m(S7$8iA3)a8{PACWy`j$#trU?ZmA>|y3C>P^7$6>kKzL$C<$vFiTOZ<)}aO z{GW_sY#w+SEU>UCshiJw-ke8!pl+B1qD?h~DGAOu=V9W(-c?D8%c|;kd5`3|Cm>?r zCj6;wd7l=*adu%CgUc;_|JWCoi>j`v!sYtvO`NH0uYOi0EQ6lp+$0k{p8An~j;5cZ z>E~$rIhr1Ukn}p>Y5}BYf}wj3a-FbvLgSwe2TrL@10y< ziGWgJclM>DSUj{QqQTA)h-L+d;Ya$Z>+k9njcp3dLO=D;%jbTy@GEi$>!9#+p9);rzQ3!@P z2n2NmYS17?97BwtELe;v06kRjdI8;t!9nW70>MoJ2IGyvLmdkUN`)ZXECEul7a;0L ze3F#JrwpVsVwp018=#pYP zdcUN2U9s*|#$)u36JqEpinmb^>iz-g=|Z4*=>zc62oD7$8dQj|=wkpi#6WZ;fJCX= zHGB4SOd6#i_x9p@sFw6~_Y~h(oYn4}<0R1W-3PR&;|n-V*jD^TF+FQr@1688EaMm; z6e5fYg`frvf&fyq9#HCQVEYC8kN48S~}6 zHVUrzzV2@+oM5QP1_5fBAPcBLgHV4VR=oy^iW_@9-pi=Sq*hTBP{4l(>)#7t-KD0;rcJ3Kc2X^%@(?=ETO54YBL_+)OL=Q8inc+6T^(SO?zH z?(l4Qcl9Oxyzg-xQId%bJ>_fE=2k0dZlHioVaP%JkE%&(k{Z|C0N18u6#5axusOPl zNmF=z!-AQV4T!MiCH?Lqg=o#|#RGpM&qb&_Iq48*5QU$h>J*W6is)Ke3IT0u4gDKh zn0Qbm6$z#!67375dW~cv=w1DG_ER}8+*!y;7KTWMj-4z6kpI{-t6~PKR^3BJS`gd zJ*7}iu5`~7CJ>Pk~)|X$|dp}wEPAw zzd_5#(+$dxH*TvoX6978UX#^pvU*L1Cmt%&I%XVUu6Ji9*D4_v5T-4_#PTO>+b!XY$GH?8P;el%eOl>3{@DT6^Rkp;N?>AW^)}R(ncbSjFO!j0EbJ zMd8RA1F<9iV(^eYTz?=OF< zoaQu1t%12=T+kSzfqJU^ukbsI%BfwmxrY zXSYBtmtZY7#D>)fWffRlxUPU?w6MD3_6kd)cq5Het*^MHLNBAyZ!pKIxPcoKxdQ>0 zm#}XS7w*=Bq8K+vNLmr%=@e0QO7`|i|B^xg1%+#a=GvgS%4j^|3Y7(^UL)0OBo525 z_4D?Mrz#{IyjJx=l@14<`oL`elZAuZE8eN_rmP2#52|q0t62mlHZ&f>;xWUdqfA?r z4|GwY3{m+5K3xmBi-G{j@ZqV7H(5&@Com~|>`{Ki3Eg3fF)tzT~N8E5oxD8!&mRs?CTE|9~|)H09>9Y_K6z%M7rWG z0F3jzUUN-9m-qnKN6bqKb(jL69&AD<+K}*Od>Npb82%=M9v)v=yOLxsIiY-s1a4< z2m0FsEcxCZ?8rj%+X2pCgDvV*986vf-VfNjy&t@iKtmB)eb7>UlsG`p0WiiS7Rx^$ zCGN=*2Y1w!!Lxzv%Yl8F)?o0v(iZ92K%3&(;MIUVIC@ehag@QPSWTe#GQi7)QzQ$} z6l!Mz>PCekg=F8HK%s6 z5E$=| zhg_M;-j0gYQ&|f}S4yDJ8cdW}K;&XUGd0LMirVh|-9s_{?(J04cGOPwx{@QD)|G&A zl@IP$`H%WQ!C8t&{R0FX@E;-#01uG{=lt;^WATRV-urY9^}cth4_A6f?NIb6V9{wL zY7+%=jkE}Kq6XnaH{DIJW@wN(-zK1$8bqrAi(=?}4*{y}Y=CfZ!Vs21JTwXc8E&E> zS`)FrjdgaZf0fVS`J?_@yhcD2nJ`+|;z59b#`LE?+6kd|_}ipdxI@Nwgd$=L5n|aQtqmZ>LDqN4abNtgoyLe7=hJhF7_#-D<<9jA3TtNk-%$_!A;loLIa14;z^nv`?}Q!s+kMZ#)AeaEaMaD7u*}eBSr>e6>4MLW2>d(|Qg}tIO{h zcukJ~<;0kMPB!rLa&0y6-f4VZj__;a`n(sxQ}oF?L<>u|&p-o*kNzdD&*281qK{_5 zzlj+m^^NhBM_f3`WA5+hGQabmMQc;f`j;#1D;o@My6kNRjyisMqGl()@~VNS$cE1q zjM7Nyd`1~K@NbLrIWmH$*kWE6d{##I56Agj6~R;Z+}{PC7Y!Wc+8pQeVHbRIxEzRI zdbtK0IO=B2M8R;xK0h$<6#MA>_}|1|OdXm!`c)DG-d`oRCeg<~p4^m!yG=Pea&WgJ z=fhmweVDtV8!B!^w{6{Ux2@X~+=(9lB|i9`zBL?g^6_x>t*ncaxIB_v&GUw|ZIC`9v(aBK} zJQ*LIoNnOhdp6Fp4B6w6wfS+yP>9u{Wf%nSsZ;JD|HxnP&;e{^v z{5|3`KhDRWkikA0#J>sqsT1Xl!`OG+qjZHw-9Ni}s{Chn52w(3+zUCO!5aeXFNJP~ z8i$E6_K#>f%%1-Y0(gvZxR?xiZxyAS3YqKivOGnqDCR& z#D%}auj{lr7|2$h;b0e1jc49(rtkEdma5^06@$)2fb5^F8hAzwD-N@`^uhZKU-(*z z?>uARl{x;3(ECxhe>L!Q-x(>7$PbQy#~U~!SDep615f9(stZ0h8903B%!%3*(o=5D z#OJ>5(KJ8>fhsIlefl}7d!8!qqxv194x8YpLk?4uW@4i5sF}0XDL+%4XF2L;o$9P# zyXtE9I#=C-#SBON+P&J9`L){M-QvkC8M}+Itbw(Ozq#rY_fuCLaKCnCxj|jzT>_fN znZ3*1ZFFB3fn6YFbz zndhlZ{sX?+>ObPEo78Pe=4@Dk_SgFA8-InujO-=__#I%k+keBy1nV1LtpM0j@A~_E z#iKEA;XXA|p_hhRrc!2@-j%IJk z#@hy-%$9czsNI!sRN_4YUsbAa@%8}y<^c6+wI5?Gf`5;4gS?+IgaWeCXT=gstRI2Sb?x|LT z#AIKadu^_|G55|~bq~&{N;dS>G&km|J9F>J#Ys#{tqGsVeKuFUgoCI!D*3lu^%>4f zI_hsYRf+SDE4!(Sx?R>yt?qVBH?>3Sx4K%6NjmD9Zfm>YaN@1q)Fzx(!M4Tmjt(*EEoE^k-q;CJyO%4p=#NXyBbJA}!l5!9a*24)qwHZhHYipRiU{f$tJ$G_COIIhJr zS+Dg_@8B>N&tI+Vseak>lAfrjcYDiWDz&1|>OPRv^OrqwQ0khVYAsGnsn2?U(Oc>a z*6F#nCk{s4*%JqvAMB|f?)itF>XY7|^_HVbJYggbaC06H5g9kYcA2~@^3+{mt2R?B>Vj9-;Q$NHjXa*Op2XP_1eg3L^A!(( z$ng(-phJqnLmGe2SD)h;20Q@gDda!|k3vY%UpJ*@S$U?xn8HziDtNg-y;HEa0NdxU zEK=7N-Cu-lx8Th3eA6HMsvn;d6!9c>hMB+JoKqP`q$Yp?bgYpM~lx?53Ap@{amv;a7#& zB7aelT3rNX?kKv4${gq;JKC|(RJ58J-ccm`)zyK6)r4afU0bB?FM7BLJD@-7g^lRv zm#AN0?$9pacDY~HSN*#0Z~DsCY}q$1yS!70_d^s5&i&6O=i#DlMQUf! zb46-b(Thd09~*Da)3P9NThY!U^<2@eBK0CRU&pEU7hPDaxS5$-omUmB)snoh_>yAT z&kWtR_EwMd-iC&9d-0}X*;CAI#LpG0UD!*kw)TFcx9kZ<6fE9Vj9tHfD^{OiXE0(* z@h8Qy_gCFgdwVVS@UHKrZpKDl*`VvF^}TNHg)O*Sd#OjTHy208dp*)iJ>Kh?Ug`yG z!A0S30nszPUg#woZe?Gr?26^y*A)9@FdZ5m_ROmDOMX#;U84V8s@^Ppr&Rs5^gyZl zhWi8e^j%#gds|ZxA1zT&U@Izt2SVzrOVoyvTT0Z%l1(MpsT$%OpoG<|z^h9(l&D)u zHkPPO*pZ5b(vl}i)J|-NRWFphQKI&6yD2x2!naEHl&HNWAC;*6*g}d8o-3+kdnY=c zlKmy>>yoQV)ruHdJDhMCom%>i*KLrRuTL zsCd{mxu@?Z$o|Nd6z`lZeX~@(Q~K9ZwX1q}HTD5sSfehfxvoa7!d)~02Eh@?_EXpSXWJ{W(ZmGGeMz)%9OWCCr*w}GRg<8w49;>-QY**hs2xYP@ zY`|3m)Qtmf9e{mSs|U&k8wcB&{#2%3F56wE-oRF+G(=|KS*Es>JzS=?Vc(Nb6S$>J zJzTb}4BMMBQzImmyicLI@da?Y~a_r0bxLkdT9XlxL zHgsG!VQZPX2Rmx;Zou+S%dvw7=GfL}5AH*wV6<1Ux}sx~M(eXCS*5xCJ6L z`-KYiGB04$PNgz=|T28#PqnZ=8_t9UCp{0^;pfC0qUv&Hx5v@4%jn5 zwhuTe%HCD8y9N*QSN2y|^uMOR$r0eYH6PZfPihX-sDDM-!t(W+cWcy#HJ{X|1K1FN zI_ZB!e|1g&8~dwU`E;L;@Evty|6BXZ!+JbT59RWCx7t2nC-Qgof4RRrW({-Pot zY460dXqtyxi}rA@W{6#CX2v@6w@bhpG*DBcHrw56^6dZWxM}?A6f3R=Yck()=WU1Ro)LC6|;NtFC;X0f(xIcev_q_kPz2I>VW8q58p0C$MYG+&vWFZPIpa*52Fa4EElx z&+|UnQfn8p+D0Z1l?t43dUGCz24(=~C%UsG?uHFqk3gVD%s_JkjunU@`P~}cw z7n8VbC2LG^P&d`H7Uc@plYCY2A5IXhFJ-AbOkkm>T`&^8ik!eM6k$<76_hxEUGQPP zDcU=Eda{i5Tvf8tbAkvEsFD)Z?3@{rGtuxFX9U+9 z_zGjl3Io5d3qDJb?>a@!-;RoFsneO{{#@`&oXu%^ULbM`oxCGc`CKjdrOt-NRQTP1 zruF>c~J1f+c5Do@mb=D z8f4?ZZo$uUb{YO>!tVU-13VAyqVzPm4gah%_$L_S4Kz7_Gx)Y0S%L!wf4lJm8?XK) zaEldPd%=DzS2~}5fLBFmVQM__QLd(m8f5GJD8T!LSW)6_%Jr}T!srv`)X!_{7*)3v z-$cRph#EVZ7H0U`$GnW6b+O-8sT*Uz-69`l$@~G9y3v~NL7#!q-)E^CrL$8%@)AlI z%TKn*hg|ALI#9U1qjSLmd}U>Ra~nR$BOmYTSkgETUk__;2kQK#jSCkqo!hjyaX!AL z)!x{I5Doi#1L; zx>4sIedI|_<59;Pf5fC?8jt_ckLst)Xq++Wh-0RJq7k2lna58twedqXEv*xeI%et- zlN(3ZjTx)I;*#=xpvI{)j>Si=tchkEJDCQW!HUxF{a;o8xruZwI7AK+)t3$GG;Zoc((cX+t7SB`sj4$>-v$duS3%mY{_J);za!AuyX-7oy#o+m_Y^OKrMo?%cNK zGwdC{mDFt0u%Jy6j9QKEm(E*^m=H2-Yh9@C!317?d;P@7Wf^HZOV9Bne=Yb=5=+0+C}^)Q z6DjsfxBt(Ohke4K{_D(ZeC^ea`fp?OTMfOvo@QjH>u62!prVAlmC`F?!l8o&H9^XcNmn91oar0sTAvORxX6$c)oh89IAC zAx@06$qui#r_nDn2=;36EgO4pubb28SC|ZYooETo)zaDPLuvHeO@_TL9G1$!(pl)X zG%>))AZ(9Jb3jUZkA^J(;U-eRv;8g}-Q#?E;?jeg^i8q;2n zHto^UTmP~5dy(jY{}#>8`!;Z5jbA#xy|>qY0H-JZTY5Xs-1u+O{|mJAT}yB0bDItQ zC#J!({qbkzT03q6j3}1g&hIuHPi)6IP5wrIEWN!ZKpdwJ2~N;7R*vOh>Fj+s5EEwQ z+wp#$p-;z8ufKdFf23t-u9kjy8l1ZFXRqakUPGh56O8<25lB*&&Vuow;*ghx@p`wR zpL#|ddN3~M`{_JM@z19;-5a*z44ZV^$z9O@`2|hzq**63IOF@9tb)AYCC(+$1V&tAV}#o~ACaTcPlmd--Y?t=d2(=_HxHm=w&ou2n$ z?1|wWz_-Hrv;Eirr2FE(^+((PW&>t@7s>Fqxl_~6P0LJ|&&9+v6yZ^@Tw`9GMv!*v F{2yVs3xogw literal 0 HcmV?d00001 diff --git a/firmware/spikes/components/awdl/test/host/test_awdl_frame.c b/firmware/spikes/components/awdl/test/host/test_awdl_frame.c new file mode 100644 index 0000000..c5043c6 --- /dev/null +++ b/firmware/spikes/components/awdl/test/host/test_awdl_frame.c @@ -0,0 +1,139 @@ +/* + * EspDrop - native host unit test for the platform-independent AWDL frame code. + * + * Builds PSF / MIF / data frames and parses them back, checking that every + * field round-trips and that the TLV length fields are internally consistent. + * Also dumps a MIF as hex so it can be pasted into a Wireshark "import hex dump" + * and checked against a real capture. + * + * Build & run: make -C firmware/spikes/components/awdl/test/host + * + * SPDX-License-Identifier: GPL-3.0-or-later + * Copyright (C) 2026 EspDrop contributors + */ +#include "awdl_frame.h" + +#include +#include + +static int g_fail; + +#define CHECK(cond, ...) do { \ + if (!(cond)) { printf("FAIL %s:%d: ", __func__, __LINE__); printf(__VA_ARGS__); printf("\n"); g_fail++; } \ +} while (0) + +/* Walk the TLVs of an action frame and confirm they tile the buffer exactly. */ +static void check_tlv_consistency(const uint8_t *buf, int len) { + int off = IEEE80211_HDR_LEN + 16; /* mac + action header */ + int ntlv = 0; + while (off + 3 <= len) { + uint16_t l = (uint16_t)(buf[off + 1] | ((uint16_t)buf[off + 2] << 8)); + off += 3 + l; + ntlv++; + CHECK(off <= len, "TLV %d overruns buffer (off=%d len=%d)", ntlv, off, len); + } + CHECK(off == len, "TLVs do not tile buffer exactly: off=%d len=%d", off, len); + CHECK(ntlv >= 6, "expected >=6 TLVs, got %d", ntlv); +} + +static void hexdump(const char *label, const uint8_t *buf, int len) { + printf("%s (%d bytes):\n", label, len); + for (int i = 0; i < len; i++) { + printf("%02x", buf[i]); + if ((i & 15) == 15) printf("\n"); + else printf(" "); + } + if (len & 15) printf("\n"); +} + +static void test_mif_roundtrip(void) { + uint8_t self_mac[6] = {0x02, 0xaa, 0xbb, 0xcc, 0xdd, 0xee}; + awdl_self_t self; + awdl_self_init(&self, self_mac, "espdrop-c3", 6); + + uint8_t buf[1500]; + int n = awdl_build_action(buf, sizeof(buf), &self, AWDL_SUBTYPE_MIF, 123456789ull); + CHECK(n > 0, "build MIF failed: %d", n); + + /* 802.11 action frame control */ + CHECK(buf[0] == 0xd0 && buf[1] == 0x00, "frame control not action: %02x%02x", buf[1], buf[0]); + /* addr3 == AWDL BSSID */ + CHECK(memcmp(buf + 16, AWDL_BSSID, 6) == 0, "addr3 not AWDL BSSID"); + /* addr1 == broadcast */ + CHECK(memcmp(buf + 4, AWDL_BROADCAST, 6) == 0, "addr1 not broadcast"); + + check_tlv_consistency(buf, n); + + awdl_peer_view_t pv; + int r = awdl_parse_action(buf, (size_t)n, &pv); + CHECK(r == AWDL_OK, "parse MIF returned %d", r); + CHECK(memcmp(pv.src_addr, self_mac, 6) == 0, "src addr mismatch"); + CHECK(pv.subtype == AWDL_SUBTYPE_MIF, "subtype %u", pv.subtype); + CHECK(pv.has_name && strcmp(pv.name, "espdrop-c3") == 0, "name='%s' has=%d", pv.name, pv.has_name); + CHECK(pv.has_version && pv.version == AWDL_VERSION_OWL, "version %02x has=%d", pv.version, pv.has_version); + CHECK(pv.has_chanseq, "no chanseq"); + for (int i = 0; i < 16 && pv.has_chanseq; i++) + CHECK(pv.chanseq[i] == 6, "chanseq[%d]=%u != 6", i, pv.chanseq[i]); + CHECK(pv.has_election && memcmp(pv.master_addr, self_mac, 6) == 0, "election master mismatch"); + + hexdump("MIF", buf, n); +} + +static void test_psf_roundtrip(void) { + uint8_t self_mac[6] = {0x02, 0x11, 0x22, 0x33, 0x44, 0x55}; + awdl_self_t self; + awdl_self_init(&self, self_mac, "x", 149); + + uint8_t buf[1500]; + int n = awdl_build_action(buf, sizeof(buf), &self, AWDL_SUBTYPE_PSF, 0); + CHECK(n > 0, "build PSF failed: %d", n); + check_tlv_consistency(buf, n); + + awdl_peer_view_t pv; + CHECK(awdl_parse_action(buf, (size_t)n, &pv) == AWDL_OK, "parse PSF"); + CHECK(pv.subtype == AWDL_SUBTYPE_PSF, "subtype %u", pv.subtype); + /* PSF carries no ARPA/HT per OWL; name should be absent */ + CHECK(!pv.has_name, "PSF unexpectedly has name"); + CHECK(pv.has_chanseq && pv.chanseq[0] == 149, "chanseq[0]=%u", pv.chanseq[0]); +} + +static void test_data_roundtrip(void) { + uint8_t self_mac[6] = {0x02, 0, 0, 0, 0, 0x01}; + uint8_t dst_mac[6] = {0x02, 0, 0, 0, 0, 0x02}; + awdl_self_t self; + awdl_self_init(&self, self_mac, "x", 6); + + const uint8_t payload[] = {0x60, 0, 0, 0, 0, 8, 58, 255, /* truncated IPv6-ish */ 1, 2, 3, 4}; + uint8_t buf[1500]; + int n = awdl_build_data(buf, sizeof(buf), &self, dst_mac, payload, sizeof(payload)); + CHECK(n > 0, "build data failed: %d", n); + CHECK(buf[0] == 0x08 && buf[1] == 0x00, "data frame control %02x%02x", buf[1], buf[0]); + CHECK(memcmp(buf + 4, dst_mac, 6) == 0, "data addr1 != dst"); + + size_t poff; uint16_t et; + CHECK(awdl_parse_data(buf, (size_t)n, &poff, &et), "parse_data failed"); + CHECK(et == ETHERTYPE_IPV6, "ethertype %04x", et); + CHECK((int)(poff + sizeof(payload)) == n, "payload length mismatch poff=%zu n=%d", poff, n); + CHECK(memcmp(buf + poff, payload, sizeof(payload)) == 0, "payload mismatch"); +} + +static void test_reject_garbage(void) { + uint8_t junk[64]; + memset(junk, 0xA5, sizeof(junk)); + awdl_peer_view_t pv; + int r = awdl_parse_action(junk, sizeof(junk), &pv); + CHECK(r == AWDL_ERR_NOT_AWDL, "garbage not rejected as NOT_AWDL: %d", r); + + CHECK(awdl_parse_action(junk, 8, &pv) == AWDL_ERR_SHORT, "short frame not SHORT"); +} + +int main(void) { + test_mif_roundtrip(); + test_psf_roundtrip(); + test_data_roundtrip(); + test_reject_garbage(); + + if (g_fail == 0) printf("\nALL TESTS PASSED\n"); + else printf("\n%d CHECK(S) FAILED\n", g_fail); + return g_fail ? 1 : 0; +} From 92da2c99cf2459d023c0e118605acdac8e2a5c86 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 14 Oct 2026 12:20:15 +0000 Subject: [PATCH 04/99] chore: untrack native host test binary, ignore it Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01XzgaN4Bv1m8Ka9iqo7QVUN --- .gitignore | 3 +++ .../components/awdl/test/host/test_awdl_frame | Bin 99424 -> 0 bytes 2 files changed, 3 insertions(+) delete mode 100755 firmware/spikes/components/awdl/test/host/test_awdl_frame diff --git a/.gitignore b/.gitignore index cd73161..2f326a5 100644 --- a/.gitignore +++ b/.gitignore @@ -33,3 +33,6 @@ venv/ captures/ logs/ *.log + +# Native host test binary +test_awdl_frame diff --git a/firmware/spikes/components/awdl/test/host/test_awdl_frame b/firmware/spikes/components/awdl/test/host/test_awdl_frame deleted file mode 100755 index f45d6ab38a8f26fb1e50fb3bc2325a98861019f9..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 99424 zcmeFad301&7XN#1Ri#oHNQFse3>qdGB+LPU6eLiQfFPivGD8GqGJ}GGAOTDnqo8Qp ziYWD1JBhM;$hCXOwe#*@yA9#pz4la_i2Azi zdF;I#dAhv&v34_EyJPRUj+y1%RfeuHpOj4he^TBk*Dl=!JodiEfw#Q7SKess)Gq() z<#qF!Zh3#y50l;Y_wH`^zGfHch9cH^SX97F37J8 zNMmZ)$Z-}hTb1TEqvc!b$R3{Z8oBw>f5d9pqtD()7H1xOV)gpWsduhyxaf#<e z0Udh{BjI@ZN;_WqPHTT-YL ztGJ|S_Kdlt(XG>@SyVD<&Xie7nYLicq{3Nqr_7#p*$ivWj5*WhFSMkkzV2-eE65)_ zWK#F8J-YUc^I5fQE!Vwkued^wp4O!Nu_Go;pHVd9f?35SGm6HJ7&3d_+!F4u6lc| ztPNQ8!*RK$?G>OEhEL433|>v!uE)sjj*(j*BcE`T92q-qO!Uexwo=OUXhooH2bgGV zdFE#BSzDu(voq)C$3CvQxUFSXebJgfm$tF2g4l;o<`~7V3d8dfzVA-`I_7514|iOi zxs-I>^-!eyI__21H?^#?!$(R+ZCQ*M+mW^PDb+Jt*|;gT(aK&ePDSf{Y8iVQ>D4S1 zZd9sW)uHx*FP_!Mp{UMU>}()o3C{)tXS<9cLfZC&w7_{Oc#iZ!Oy zrbjz9GR;`pCVJ)5TXE&Q36b-6VBW2c`FzX|K2?>^jm}(-meZeEU*^a5PK?GrjFx@; z^{BCVSzEJqM=J-6Mz5yvG!8btFYM~+lO7`_TA8p+x9r0ujTwqV#WNI>Y7T@?Tq@ih z?j8N{M!(mYx^+AfjeQ;6c4$y^+hKpy-WlC{q@)o5ja`6H%>ieedG;EwO9v?1D(y5b zS~lR@0@!HmlakbE#eg3Xs>c6bQ&Tk^Uw0;aiEK~6HeSB$t1l(f^(QhmKei`7_WZbL z?1wSY3STt#>(Yf$dqgv8Jj%{5+vbaAzY$*c1BL7iB!=@V(xa7w?Pz6@FF$65H|9s9 z**}Dry+L+)K@&TkeY&!v*)N8d=|OmBZWG%&PTuWqo)~aU z(g^r#P}rI4w{=HF*{4@F3guT0&(DiJmtVGHRy6zdqCV6!YVXTivAd*E)+@0$%C_6# zji+Vhl%LiuH~W>MLs4oo!VcwUKVQ-mAR%B@{DRW+vOXrgv?@1tzzmhz{vD+}^ulGu zG#mWE<%PBVpIb3DGZZa5Vux4i6tX-_8%7>iRd(hk+Ll@rf1@#9v@%Llz7$^eoT*Zx zvi@5oh)QuM>E>naH|lDE+}Pf29|X!%6R|0uooMIA_C?ElKJ{y)A{4a`$3l_trf8rr z77auzlB1Q8AazPrso5_T{VP(DjyAbBmYpYpx&h?~{9W;i+X2O}k=nItzp(mEwg%8vL;l9{GImIUN?cw-{B z9PY#H9d+7M<1;cg5_=(QD?u^#YqV@vC>nc<5T731HS92rVR4^m?q#1Hj&}UHY}?#z z)&B3I_g4gC{_v)u)Nn|Eu({%uk_)4;H>0uFtIqzVrslChGOZj=3-5`Bhi#8-FFPCx zFMC?4730XcJS{4~*qC5!d$gjYF>FbF6`*0#@x`(a510KCDr&+#Yj^CWvTdP)irij= z;Xt44y+wy2OMeS9LW9=ko^*aByzw>M7}{)W*@3+~heZ8lTbJgRPn#9q_-r)xY;@a! zP&8?ebNip9SRgYR%gqcC=>wVJ`a|-e@ho!b?pV#SHUvsL5CWH%sNpx3S~=y#!5_E# z!po;AVN*%tBZXAwNcC96ic%|99HffBk@Q=2gruY5U*zn_S&?yy46o4#Wnc7-rDoPy zm~t~asCjF`YqpPA@o{*~*3kCyv6+eC6|GZM^G&&#vq!{sj);9g_Z6^sWfW8<{DU=Z z%O16L?1$*C+{_GE+V1mFRgeExQ&Ui}D6>Osmt#u=C~R93E{ss&+4+^j?8rIM?499d z_i;RuUwKAg@XkR0C2EZf&5yFXNLHxIs?i)>wi&UYa)FPa!MGVq;n}~3m-P%Qj5H*6 zjq(@77ReOd>(C8v>`Bw2H@LiKe+&agKj7#KH~|*|uFW&yWfuxR_RUbV%R4vRR^2o= z_U+*Eg8u$(pQqzpXmI)1{!Dk{9ntTqG|k?g{`|_blk#IPM`Hze6#H>=I@M=U*>)(o z?9)LVzb1gx4-bAeS{Z0)`Z*HY4VbT>vSo_#^rmFX8c{jSuet}^YT?YwB=3|z+vQ|r zryrJ*Bbya}7=s0w#6|SNcu}6((9C3-s@AML49d3S?`U6vTJ{aoDUra=KuUUn%#4Oyd6b;T=9&WW zv~X~&QA4bP#_CSS&tn!l2xC$hYvd=A5u!1BfvoVR#+S5HiFFKIfMl|D4O`%G$z zt5-RS&_dm>=9n!~k($X$WMEBfF35C(b8L^ss?jS#56pnGK6vGiq`arNUTh!@Sr|<9Vdbk%HI_LG1j@ z^s3auY`!X}LDr826(rDHEE2C5HP8AnTDi~xFO2m}$&LLOjqQ!berdBa_G|$Lq1ej> zl|38gc062G<&TuJvOXOtR|naQQonIvG7c0}Mi@US3C4p0bzMJN$r+kkS(F&1!MdZ= z45Stq$`P7_dxY*VO-#jGVQg9vOtlu-^p;?K*tiYl4QTWyrIeri_+t*A^kXtO2;ORDYz+*T$Q?*sg-g z!HjjlpX0bSYpa=fg)!zYV49Gzt+73Y46F2fd!O58e$!?wsDo*H+KdLtwJFG>Sp-607d~U7_jGjGf}bwmX%l0GBaA4 z>SqwM6`>-zv1WPUP3JRRa%1D2ah-zRANF45D3oZi-*`qNzJ+!%Moe@u6N<`hVg&6X zpeeC(6oq(0>+fXbRL<$qoK;htaO+7`_yf*I@5CA+wzgY)ga$1!*uBC-_)6JgADtcJ!84j2- zZYDIs{Kn(%k-D0g*U!Pox{tAs*BBf6?sf+ z*V3PZCE>iRSC;@Kf4SZ^-k(B|bX&O^rLFGAWnp9Jng6wz0%icGs3lbD! zDrXXEw3M9h?g1JwPqzr%t4NDXIeOiQ%6YbFpz%Bo=U3+0^7@?G7E+)YQMuSB2pW(bq#A~$AiplEM5(Af3pnW84t8b1g=TX!ac zOrH`p674)K-b0Odt!RSW@_+OW_xx-D~#-#{xR)KU+^-HQTK1WwOcAoE_sV=eS5UU&iTxb7| zGes5oobK$dZbW&jRKJz9i&sC%Ly_qKrn}GN*FP%1rg{i~HE$KTC7T+}>SB6uRH1f_(Xc05X$ zwb0DU-BSQV>Y5nt!mShWhs>O7_s$)RWH_1U$g~1XKBzt_59NYEz-2 zX0!o*u!!t(Dy@*|Mie}Iz!(oT5s}_E`voROCJq}VYBIRIQIAOM$AZe!G!KgA^W&y+|i$Xj59$ z3V9aFlYb-g*{X5vnsS!loX%?X3DFnkEFrpLe|XKd&~xDx zGc~?eYz;4;1T$jA))BF%3t}&lIby|bb_2`zhF9c~6W%m@HixwNvA5W4M|TA>8Lign zE$q0gsu^G4osQ-rv1h`YhRpU36=FkXN7;UlHzx|`s2uaG*W|vaj#zX&H%f_Y#yRaI zk;gb4e`>_<41`&>SPLo#v+dm(F8hFj5LiyWG;lg2jXb8}0cCLrWHMbFH8H3)%G=@( zkOwB|sfoGxbs_e_yvnFe%J2&f%G+qN4u^ANJAet5FKWWGlTMb!g^5;JHsnGU*)I>qqOs@Pb7jQp zS2Se~erm?aK^{T3AAB7J{n>tyHGC;foA)P?vpH{fj-1SSQ}n3AroL3zJ#5--4x7R& zE@7XS&(T0Wp@fr?*!NxtRNH)ZIDZ%2MN4=5Ie%B=e6eNQraNbZ9JI6#ugt+dQjwD! ziP<~|=A?1f6bVF@E@-->G^sFh<$`8blaj7m@SZW0o7tC^37EGD;T5u^br{b_ZFq7l ztXq6r`aHLmq0*<>%jUF>lp#>#HYM74p{N@$km1NcqM2lx3GZ$RWQ?DN#F8o`3qm+ueT4 zFZ;#5#mzuM7eKSs+w@Hj@8l1 z#lb?>hLiFuFCL))&{Qi$2dyql)|ctyL@Nt2 zdylBh9bm2avgAaq-Jz_loab&;1$(CFl=rNkoBegsms*QLawoj}6PgCjUy_)$HE;R; z(gbaadzaHcJMF0b6*HyuHY!7rVa_j4qQ1jz&5EYDlBwmYL-n1E2ik`=39q~fKD_Z_ z`zdp%oPC4?<)5OJS5V={97ZcOYuy;o-bhOga% zZd6NqP2@>uu6D^~t`=0b{fs@}!4%n54)ta4Tf88D+8dFwT{hj&*caY7wy~}0wuf1n zS6LGD=VpJm`1LJqfa6$>uX{FDyWmRN$M0> zmXOZtRXcm%qH>T+PmYw;BwR6{_!O!Wo$$KsNQaW~j_X@aR$VF^FXzL&pti?G{-~q_Tt?c$aD)yn2fkULdm^$k#aA_8`;HqeiY7 zkWZ6Yb@+g((Pec2KOw0sVXy$JPh*V4_iN*}+~}aU!&AK_ui_cD&ib%Ca~}!B{mSz* z=Wos(Cr=*D{9HD%_oI~$WPS}>$jkIY+;=TEIllTQ^{Bex6Rk^cvz%t_e)1h2e)anL z2#Zy02V0M8A0(K@-c#D=40*i^p7x!j0(L;;ueb>~9*cukctsafohRfKd!5wbR!Jf$ zBW^4iy=4kXG|u-_milkv#e&y3-@LCNGB$Gr>B)5(?Jd$tI*$r_?Y)zvvV@a)K~Ge2 zs$6GL8ixbC=2pv`H(|$?2~LU2oD%cul(@t}o=cL4Jljb+MEPFQcvINMuIh_sHO%zM z4q%o8_&qNZyef2cth?6%Y~du`RtGR-0B0D$_Xty4^!koAIY14m==wT<@4n}>35TS; z_SQ*%hIEpK)j@rjq_TvtL0v4=@ebolwKGZO&PGXS*`F$PVCmG29bWzP~ z==Q3vB-D91$vhwTm_EY3g{C&6@#bSkV|jcurf=2Tf z@eoJA*>9Jev!%oVAK-vz)B!(NAbNC&y``|y>oO0mD#wA}UZ^xdf8~-h(PGNgM0`L(IkUfvT+;2R-g7UqFdfQ1lzfLK;NGeOnbV^~Em_Zlcla8U) znEo1yOn-O3v}LYS=J`&UgX)yI5VY{dYckiN;T9U+xW)_vyt@m{s9zax=~g89N&QW;~HvUH=IC6=!H>=P~5IL9Xtrpi<=c_aCpC6nzWt z5vumTZCYeFZg?&7tdn$l9dG_kQdvR+c~h-|w#;$h<^TuMD3H7+z8Y0C`5Sn5Fj zRHu}w2J)$ng538TlRt}uRgtsJTxaAT#613x@_$%|__WsMcMqu&-a7PEv~uln*P;6! zf1xUc;JAgS3a0Dl1c}Jz#xI_iXl8rWd=|w>>}f4WAF)6&Rli1WPk6y4-f-@w7DrbG|EW}hkX$~y30kW`kCFZf(N zroNv)?Te~QIX%#lYu;N{y~S|_kMUY{%T-lp5zpp>5X;}D;kFEQYWNC$Ne#!dZvT>_d2-s4eneQ zH__sgVVh?Z&!1j2Z+@3(HnAwYJW>8fox=2r5zrfX-QYFh%WtZ%g3Lq{EKM|egECEX zRy|B6`aSEEcsogD3BR>-Y7%vJA^AN^&f1dil<^=fkB#0Gdu3eYmJwC0@=Y0K@iIm_ zW$f`1*bVkKW!$VX^sF&lar-uw2_E=)gdITv2ScorZeGdsk(DK#M?{tm43?a+r2sED zQFn&}j%*-iRaL)1|AsfF<;FfY#h>hzR`oJDXpQy~DE>brl_fM&@%d%XK)5xPPI4DM&R_LG_Tqv4)pW1gf}{;I7uJXNg8QNyT2`^xD;F!WyeAvUq=gilx`u%lOvVSkh z9vt4tTfW#Kukeq_;N8zpdp3kip9g$`2cEUn7(VS7mhOs_eH;$2XsDP^eYN?mvlGp;wWYt)6c5mrz@LhbUQ^5hTI+#=i|dZo14S1!*W*alP*mWbr8Ljs9we0| ztjeTmDiYpgUV6c2MeJ8C2MN1K(`pb zKj^h8ik*R|nu7-X-86?+v2&dyJra0H!$>MixQj<8XUtcJwdCYEnCF5C#JfyouSJ`n z&uDhd$vmvGYzMfk4)CE@1w359)jH|j($~QZI+(FKm^)C-jeS^)St!hZkP5!K({uI~ zgZa}*!rc8c>)0$3%sHE$XZ3KngF^ni{%nE&KeT|&1Jh|#WFH6qA>M!eFErED)%!9} zKU#;c;w_JJSxvsxXJYwCY`Qfxl3$R~wzyy0>HRXS84Kpmm{u}ldd9#3y)(uZoK@U8 zMA#n=k-92XXq8k|}i|9k~egjA`@cmK4pKozb>i_XQ%$ zktGfHO8C~{os)^h<03un)pGCgC?tl~LSN~X=UyqyXm z*>PY*{!vvDe2u#YNLH zq+MH}YCfQS+v4_WhXJTuGNY(?*1Wl*1 z2b|)qw#6A8+D^}yy0BzMamRkiR`JYvMI{-9rU~WpnCKa2js z>R9f&8aX%1zU)lPUXX6LNKFi_0#oTNq4F1NYT8LBH!)qeOsFT6^E^H&AuWAqTGP|R zDVK(pT7#NrpVA}qBqhsUuefHSXAG6drSuYLGU++oy{j|KHBgAU_o94ja9a8`zF}!i zSNe0)GRpX%gq@t$G?JD+I4zXV6NxbY12v_MT1cNuX?vhd+N-S+xVk|fLph{fD!rk? z#pRX$h;T=yb@Mj@IwRtt=cSB7ttqu^m%Zd$jM z!AM%)vc%l9`M&eg7R*SS?_ZykoYpsz)-96O0ZlBZ1r85Qv2uW!4Q4qf&&}uyVOIL` z(wbf)KUM~?C`%ZYmgAd{7A;K6@t=fG9mv5g2P49#BQbjjjOq0EVfCN!3yivcQF`33 zrMB-*92)Xp4&|k&AM}}$ro4PDgZD0#(Fp%mk$x5V>WBZTdux{qZ)5&1H zR#j88Om(LFR{BPyH7)ZGOIw=eZ|-(!BrSxtW%M;}doo_XQvHnna7X{4dj1NZGX&6& zO1}WwivIVE>s@TG9bZTOQF{zaTbJfv8#u1FdBW=nrBiNTw_A5JR_FnDtPD##VEe}( zHCT+_nEiSE*#dvIz@IJfXAAs)qy@a+-SK{BM@yZqkeC%UsRfyZ5_3+Dqy3@_#r|qc>O9Lt5v!I4CjKvz(sj(sHJ2pPTP(>|b}1 zEv;9&_*Rm+8oPJz=9*dSCOIiuesxVYf1Q1pSPpc|#fPBHrGq|k9IXl9CjIbvs+P~W4(N1_q8kk&CTb4$mDzVaN+-7yLrF+b}V2w z?f1BH-1{i^KH0rHx&Qm$&}2t<*pMOpGCGW#S~|C+G$X4AzkAiKOYc%6cE6%~&#v8i zcFpSOW|)nT(_v0?=J0_BU4CzrIb!_pX>dL(Yq&SOMs;h zmI;<^3hSFom$h<7xplmf(?baxFt2|ie*$kbiEv8~C3eB#>_7s)42G1jUWw^}CrC`U zL(kHS>Au8sBn1+?o5bLAB-YotVnVwn6Qu4-s8-SjWlfPOy2So5i1jNw7tu zyWmgm&1g#$`tX5hq>aWTkoJ`dP1`2(v~~>t#I&p7Q%zf@r?o@7LE70EHcnegO`2LK zFCyEZoHJP{|DXUX?J_j%v{T6PTPLgk5~BQ>S}u*&npBy)v0jon6TK@k=aaCQhZ$}+ zqj5q6Cc$bY?Y3j!XDQWWZ}PHzR;zoIeJL;(Dz)w3DEJ~NcAdLm0IA9S;1`OslK;iu z%SP}gnEW^;Ualq@n%o#i7Kt5}d?lVP78~u)5s#I4Mal%U^OJ8!dx_F#CBKKt($`=w zOnwXYO0l!8rpgqu>vU6fhir{J$jLAEnj-Jw4e6`mItJo;-|%wMs2W z4w8CX6H-Sc4+7_QDNIOiP3k%oJduIr+fJIPS$i1cbJ(iCAM?aJ{L4{lcN^;AB>$8g z(zx2`X9Cib{1bDGXtm=L>{k6G|2eLF+NZQwC?{2epbW?pYyb3;MWad*yC`wMz+CFH z>6uUKTC^Jw%(?gQmy;Gcbm-D{7`&I}h$v+lA})Wdqp(rb#y?Ag;SdxM=M zsHRQcz=M134mmPCqhlWqz4O~5iuB9-^yw-2E8Y=#_Fvp7?fB=cB&w3vVSEjJliU^Q&q`Mq%?!Vmslls16AZIqRN_mBys_M4K{}Z7$w+HK1HnDI7Dk)YlkL<5QdZUC`u2@bdXGY@aHL{CzR4&DJ}T(9FjJa34;33g|e)l zf$~jl*des)f;V-xIPD8#9y_#9_j~EVKi!(N}@Kc(3&dd=x#k*aC$8o_zwvJZhUY=Xzu7PsLbd)~ic|_&4Zg@FuDkyE;+*$T zafV&*8A1Mz)|KclkxjQkd}%H>FMz2aDF$`B7@;kxLyBPht2Zt&H(PhATwS98dqjKeeXiBh5+G>r&^3BFxdhfjbY3+jy!X}HB{y^ zQUaP_0gWG96Y5n=Uxv8aY^R=W+?nOG&O-kzyRrQ~snuwFqC#Fp2nGRy%+*F?zS=di zteY^p(Qay{j=pZ%0;(PE9gwuutDQPMkg_0C)$igkchsq>!5V;0e_6(-P84YTlV*bLdzZmwUrSC2xdsJQoF-jSfp9O&!f&Fyg>^o6$X9uK+z{nG^?=<{I4OAvG+%ybEY zo)0q@a-eUDLwg*Nz+AI!pN~0iyuJ+m1p*NCN|@ym1ic#OIthZl73Qyy)p2td2c+e2 z?v1qR&DBpp*adbydnu?{msnl3yymSBnAE;nF|)KW7^Q8&DZC!DMmURq_Ye#28Fny= zM)z;A8RabYJx-Zrjj_`&BDu#YGp4eINf9!a*=VnVcOMQiegdJhvvpb5-l$mSr3tLP z_5z-!%+{r|-CF!VsDM5V)OgyI`BxA4MGHsWuObKec^I*=FpthR}b0K6k&>(3Nhx0h^_;9ct z(jkdG0WCFzK2>i9Obq5K2^ugrz^s;#jc^CdZ4xwKHo-gsnE}%#K45l`pox5E`C#>!OsF4mkGIkJWlAHiH$~}5DZ<VWomY~pcDohs%3O(5{eIO@R{N}>>tzp15vO|H!f}cml!=2v;X&suw=zEKBxd^J)Z3Bmf0J=5 z8aG1ry$FxNJSyQfgim1Jm#`YaW&-{yVFf}fgeH(rdlnBl!HR&~K52}QH6;3=(E~C= zq5vi;K|^8^%y|+tB#L0>LRNy-%ENI#&jZbF-^Hv%E6Bf62K6zx1?DCR2?+myxmUu^ z7;lC759E55;k|LQE(7a|(3hTPZ=&pIYug!&^J=dw}hAsab29cH2gIkyz%G6`~SEzC`j(`x6tXg=!^(3XZ`LQ|_fips;X zk#AdJo|GWpo`ZQ-f_!@o<`u|6D|XQW)(+6F487^09YEzB*$C|im~SKqErd!yYC>xP z(*$yAS}9I~^%7{yLTx!jFg5K=#wn1gX&;zg5>(R&%peJ>>2R2Q$U(bb99O`4545sS zv4?gx8Dk)WHW%hX2|~LPX0ZgJt$|quIcPh?aRsbHpe+w=^U&@kV?AWhUV?d6g3vyP z`B;L`f;tcpnLd4wWL*L42xu!pVcwRRnx2G8OF^qoBQX6X2yFt)*%E}d5JvTJ(0+@f zHRNGvWoUwjb^|Kc$wp`!VD6G2w8vpKNf6pgFwa2_T61w+ybA*Ds?d7S0_xJJG60nF&^Pi)2Wrw*;ZR0P`&5pj{eAyA-sF(0&i?eN^_#Mrhx|d@Vs}iKzIcCbZ@- zCqR}@`_5P#O{c@xg!ttE+Kr4ZsC1N#_8Ws?21?L=V;szA3E2oUVWvyae&ceOOChu0 zxXDGft?NPdwwl+FeYN!UZ1NYF8zpGJu^#4j$gSqTTyvjw8|Iav?Zz?mwxjc;j8x4x zVP2J>YJLv$sRUK?cbK0cr_r8|S923+*M`ztqo(1J4s5ERh1M3PjRc|fh3P3lXv1NK zLC%oXpw#8J^pf?u&_tnW$WBG&T-nq|*aY*4gam}=V0KE-(0v!?Ey(Jmfa{~1bOqv^ z+}5=!wAn*Gg!&h<7jiPGA>|0U5ljOKLT(My5_0_P=_1?K*Z6sTD0m`j^0Naf?PMdg z9xz!Fgf;-CzXYKTgUN#|UmsS(X)c=2`W3Vrd}o3dIA3e#DE^E_?+h8{BAf>^9!iyF za3(1;kfuSn=XrH$#CcRGHjfIIm`8=hdQ{-yf-CWYfh<4qwh0FG1}XH($rQ1coXY{5 z1KFz(9*6mdgyjgo!h9)V0YZPA>;k3AKDdFDc}R1h7LOp9J%d+BqBR7iTT)-mz*u{R zMW`)+%${L2Or-?v86JeWPlEOg+hCrMpgqHDFnb^qcW;X~*@OUlhAvkLSsm~h8Xw6- z9q=2>VF~JhFe)ihQwN*`(;Bi8v;*<{Ot$n!+HRj@MsN@Evmi5q2f~~tArJj1%uop$ z!DqsZg78dOTjQRs2J6;Pn>OfaLQF*GT*$x`!_1Q)xRo%=Bna-WFgHR@J>PcG{MP-T ztqsi+n(Fx=DjOhE&lh2KNl-njVLpgW&k4*rm&7-qPF_dcyOq6-)~WqXFmu z(@uhX>kiXRf_ys-rXS>>CA+i9Z@rCge+#9Ygqms^MI~1@LYn|HUV_l(!_1Z-v@)0_ zkP|Ok#?kcZ=l0ND28}A*gv#}@QM|k#<{k-(mruexCLtSPFU*S)6fZx9IRKe>sbH(i zwtfKFi{9 zYawT-ER6e^0@|ISTZE>eayKe>$VOA*F_?`KG*n)M*)2gsgAbZZFn3AN%y|grK?#~UkHKt)@Jt~m ztQC*Twr&9H{?HoLSrhSTbhgV#a4*BWC_!*j|*LJN?}*TEdLCNK>p2<;S@c94TMD2}!{fu$tWO?@xl2BDG-8MKKo=SYxmm%)@s z5ZX;Js~~5koEt}bGlBN-Eds4}U_ON21CSY*J7AuIQl)7I<{L<_K&+HL7w7+OrQ9-r z-gI?Yok2R0;oFHK4w3T}V4pzthX^O)N<&rd4TQ5{Mo4%LVFk?PP^#>MzmW1zqk$?n4ORrzFuJoR^K2S)zkz|0L}3I0FC{S8NU3tUh5YL8or$n+Cpad4ugqE(D0oG zGXXNg_vTirIf8X2$nCpqk(P$<uShOg&m$eInB zH+(mcf2RyILUzGylb}BR2eCvSpCO#lPWfTivw*bXiOMgAO^fsl#(Pr1mpb!(8q+*AD1$&N~2k$)V_7zv8}dOtA% zavgimHBYeqj`^lg9}ZTHV@uIlBqKR?Bg|?Ea!kJsahC)+_BhNY33BWunCBql*k^Ia z9uL+!_CDF~N?(qB4x^KGq4 zgjNo-OoGtvg87>Sp>2hE404w5hvR7a#F70}o#p#&l-`h)mhVF_UqGo+Gg0y|(vOg? z#JL05bGuLhYZf{gc4&83jQ>JT5Em`ES%J_Armch}2*Y3^P^wIV>q(h}Gyw`eh%g6c z7Gy%6ho5L&kMc9AhdTqJ6?-WfS3oA@Rlvj~DCFG&bF&1k*!RHPEkPmgUoiiKOvwA% z_1tIO19JN=4+vQy?{PG?$V4G;C(P3l6!Kn$*(>2^n)F?m{g4y#JU^4I=RxyA-e=^0 zA_IlIZ(+WcppaJs^ScCvyd;WFgz!u&b{Y>QMzB6f#Dmb#F6e1hY>ZBQxhc3)VA@F# z+z^;S5(IY^%$bmb*49PyTQ!Nq-Ow5j?IKiW$VO;a!z`B|w12|fD?w<_!#o2yXnL^I z<+qxJI35Xo=%Iav$_KI$T98yrIYR3M(^i7ehQUN2rw_)*YuXjG-Jxz8JnDlfs7#Pe zeS}M3iXV%d zZWwEFe1zHukeM8ZV7`!`$?-ePuM#vll27GX1~QYQ5ljQf3_%Y$(Rv!>_DQ>?r6JfF zrlkaRb4Qr=64cGTVR}kXH|M|%ge-IF-qo$Y&w33s@6`Qt@}n|PACH3>BSC#U6=t#o z_3><&3n4tyvCVK7%Wr)L)+?cDsSW6 zNzCuizdW>-Fij;0trtv|1fh+DIbDL#roc>qoK`E3*YhOMUJKPoO|4b}Ghc#QEe5kf zf?DlPn7>I-tNj~B9yn;X#eLInQN11-p+O_GXHj`tHbQ$JX1@fX9ftW{g3waXPlTMB z>Zw4N&wSS7jnJ(^Q@drrG?$>74uZ*+pqfsHnJ7UuT?%s<s4 zO|sJDxEp3YlqxkdIsS?C4@g(yq|=y>?mV;2Cq~-Zq14_OuOVj#YMUUt9N`z3LlPDt z4C&588D!5#xC~}4l&Vjh1lN)B2-4k9@P349m`@~ZL^uNT8)T->=kXbS7nWbAj_m=W zrcVk?q6EFP%7AGpLDOddOtu6~pR-`jl%Ta`7R(IDOdk(9!P)_G`=pSx)ODA^ERdkC zTLDuhL0xw}%qj`$y0tL3K+g0DWvH>#b^Ac`rq2fQ?~;M~?FpDI64Y;Rz`PG1^-btb>1h}ofjKNea3}ZV)DJRnQJBFJ1UDUKBIML_Xoj%l*$K(S z&d}EaP(2r+a+z#Y&vKZR5>(F{VQ!G1dfpCm8|0u($Pku%YX{nQp@C8p+Wj#1ND$g~ zm?tF&Z9mML5`=aL=5xqFE6EU+(E5Y+edrpg39Sa^cL_pE?nMZM3|bSI#u9|q2IfS_ zX}6Ub!V;Q(G4F@a+X7I#bwZ`1Y}9VOV0uVUHw=c!k)U=ffH9xjG7mL>&oE%1od?>F zp<<~`KFDArE0G%pwWd2!DmSQG%Ww9)Q^ZnP-QooVMt)t@$8(4>eDa z{kZh??C>niP6>Lbc?D(<mqKH*pneFp;drpAFdN6dZE-qR*DjXVRE2UshKEIfOI;fD^Z_5`7FcmR>0DK zcxz{e#`VRxFFEIEYW

qzPxGE87-4s(J81%^{$I!RDqD1eDdP+*t=Gev>|!vdHR$V|T=$B4Ro zmi{AM`z~#yrS^@%TqQy6y9VZZ32NUvU~ZS7_PrnGUdY*WdVVHbhd}c-otwyiL@AS9>N)II(4q=T>cd1Na%6tX}YA)(1~hn!L^2IEKcG=4r@`rWPCm>9Vc! zv3M`_aWc$k+6(it1dXP5VBVIX(R2`|T7pK?VVEB!Xf*lyfdrY+G{8mnS(kv^zRNsm zX*8vxkqnv9)C8un1dXOln3E)EGg1a2%QVD`v1G7qk;O>E04>@CVUL5Ty z&<=#omYUH11G7rIz2JKb**8mH!S^nhJ0&RiJ_Pe1 z)N+Gs=zh#EuzKB+pxoU+36I6y{T$E_Lt|zAE*aZko{+E?;cb{VBs_!g9n4n}wjcx< zvp&c|eO11gEpeg37crjl)#U$mZg#Atl zdIoe#*ne_u3Hwj2En)v@wI%Ey8~6Sl1KUvF7kf8e!h3EBD^9zHKq<_5LkH%sD?ZW13Fx=z7>J7C6drzIga8I4Va8I4Va8K=E z*bs-k*M+U1kL|xWu=-G*1N)acZTOcuZTOcu?I6A09|k^oCmNTc<;M3vw#rL?!9#UJssNinm5MH|vHREN59~nJ3VNvLPYUMofym`)P3V)TLO1zA3UWxHUu)gEsO{8K%EA*c+JjR16ZbfyFWTnckB zWVP(*8oLqPe5gFXKpocI3C^6GG_=1%JP0-XHNc8%zLIkiL!+k|8jYBO3L3qBrT|@MJ2z|1VACc2 zOqX;}>~z=MwiaXV zi};TD(%*Y%zYQrA0h1*``=0(V*%Gwx z83Gf59JIA@pLE9ACv=;KHXN0F*$8bc%$X8|c0SCx5`;D#W-8>MZH}Yq8KQ6Kpocaa zl?!Dfv`b)0BnWK@%wh>bD~DMLIY%6C$I^G8+!x!*U6wh2KT}IT|xrF6EIsO zXdAl+<^_m{kXL2hsh+LRB>T;Np_BPWma~p`>SO|!_62sQJ{qzO)A-ZbghneVml12X zb)w+*I6zzTjR(KkaI`hwba24hX4itXH7jC0eYf0hJ7x!%*6@Gv$8M1%!PdyBx7{Rk zFhNpZPP1F&>o-2Eq#pyQG%-KKVCy@Jev{jzjloH33@G1a)i%g~mU2}s-_-5~N57@u zn>tWcF<$wmnnMN?kbToSKBROsrs-gUlJ*1Q8|z@*9LE})Rf{#Yc5(KN&3AD5N}X@) zIrMmh-k=Ck9?$T|ok>mZD*gD}!XNEAZULC^$t5Td8n`Vj43Tc7B#S|6h0 z6nCtokMP7d&_U1(PX}RO=lE*n8)&`^=(NbdJkw8FmVARw)$duTMFyENtfbe4)ziV! zZrj1?*{K$*rx_>?R?l36bqOYC9>5hV=@I(Q*V(bx`I=+jxsHA3I`*C8_MOkvqG%;G zkbS0Oug7r5KC_N}W*z&?xP7K;e~wzSmD?6^`&J!m{cmOZ-toWHV9)cQ--ldo{mx}iIj!AR-VdQ!f`NMB0mV@G$G`EdO zAMJsbx{=oVZA^RZx2FT7pkR+{UZLCA=6}&`w7s6&C(!#6ALLIchtSEr`D02M-+U)G z-)bxP3ip2Gb%)F&u~&-Us>GRTp=J1QV#x$F#zQ8S%!ipRL9wI^W{CvFlABTqz?3;B_!-C8(N@!2DB!s<{(pJLFVz zi0h2s`jDgxLg&%VrkZb}^15vFeElKJ0SQ{1e}MT;LN-DT%&{U3Ik;!2ZzJtOOS)( zV8%$0gOgw;NRWfGU@m}6KV-%oOtfqJp_uH8rLTUt0%nl}d380+N{FkER-ARNeOr@+ z(po(VH`h-IFh}(B0`^#|AcqMk8ljnZ5yVM;qfWTMIh%esBQux6Teaeku7P3auD@5G zg0k~tS@ljt=Et(wijib~EUT^aV_7JySEBP{SxL^1WhFU3mX+lESXPp4ek`jVM{O2A zmNj1L&W~l)(@%x)V_B0)syBi_zz<}ph;|`t^s(&*cPfXI8BWR?mU3(Gpc3xGjNis8 z7w`Ta)*ATs>fnSxE`JlJ2K75Q9a;wwnwp=gYC4nXeMQsxBrLX)ALFK7d5X$Quo_6a z`55?FN^Sl(3cpBPi@6K*lT<-HR4f!{1@$m-*#;#B9|!VsximCrev3-%u%P)ZDzVXy z?r%|TMms-vJK9T>J}YQ`i|Q%Z3xjV_nJdN4wvv@8WY?=?RbRG79^~Y|O@A1>8lEHFSrcD)sDMsk*^nUFsR?9S)=?!Q;8c@R4BL!^S>xHYU*>dbn{Jp z{wnr#(~`GJVR(>XXsuOhK`@B%ZF5K+5gf$d+odoe*qYRJDtIDa5H&wTWh%9pOyh3Y zs<$6=eunC8lv3BzKMhmWV@du-leuw)SCbg(X;L4o5Q8bvN>kOtpVG-mImUjqlxqJ-(@MLE#7^NIg8HX{ z+9s`I+><*A&r83v9*PrVLH!s|Lapn@D`{C>{)WT$G6S_WT`9aVpdz~Lwfc7}iTa~2 zFq$MQnTfyB_ZC)N#kP8qT>t7-OIkf=m~~gQehucq!ECLeM19=JGY(iSu(-^(j5_*0 zCgW)|wnM&s2(QDuD&Yl$PhmcWti*4S`qlSJ?+Ua#q~TT~YN%01w=Se`84MaKosRs)#J_?kZjmLf~Ms&;XeY|G=k@f1s3;z8;W`Bp*Z92atW95VeYciIhZ z!U@LruCRxDx;Ljz2q6q}anhI))`|M5nFZfa*s1rabrvRzZ#MrD>+*&QG0lpsML|W% zMu*9+kA3aja25{zK&H2lD2z(S$PHNu;ZInM;q>|4o%Z<3@7(RR|HQG>f08#NS%KZZ zdS7vBkg^M1@x9_yox)vUBBg)zfudCX8p(y}vh%P0xVWL7K`%;v%7|YV*PqJ=edXvA zwACX}9fy}9>Pof2zc?uimg0&U@=JObH&q1puYRM*%$k`!q(?hg9_P)PdA`gfx09=Z z`wa45z3@cMHXLKTYI!ws*dpb2ey8hWm*WXj}SsvHWZJ%P_a~|Xr;3f$& z^Z1bQsbuQk$5rC@9bC?z!8J1+1sBjPSs$+kSsYmrjl zV9tJ$n)Y(K$+kSsYm2$Qk@eiRNXlcBLw<99g2B;a_>bFSt~)zC zJD=4ujZc{QZuF4bfO?{k&vpGc8u{lO$2+%4Jtcna$5r?0Ki5~~L1qKjN09o=kFKL3 zkE#FWc>O)Dm1AWU`Sc@zW-N>b?hHY`eq6{uu78p1xM%0HJkE2x$ajZV$}CE_K&4b2 zx0Lorj|ET3XL(#Lr2bp7ar{c)mI(6K<3b+O7Ky!`s@s;wd1GOYPruhcr|DuF{s+9i6E#;#e*Hd;r#js|=d8$6kn4woBdg@V-C6I^h#`Cubfw)$wi_rRcKPc42>Ib}nPw zt;*-lR%NWaRr$Ke>`l(DyEi$<>`kg^!1p1uHyHp-l7Agbysn09xz0CRF8}JK&Sqp? zHz&)sJkA?dQ+)qo4{G|Q@lb3b|5L8^|6y2pWoexVO>skvXXmrNAQDXR9rloQveZe) zZyp!<^&H1LcMy3>KFj00K{UlToE@ieR>xRF1o`=KA!`Sb8J7*TQRCu8r^|WupWyqK z2dOu0=Ls^))%joRKf!g}v*V}YT?EhZ2|oRPjd8q`5{gwy?r}@`dya9GKgMxSi7({- z7v$LGFf;{`UkjJ#eG*`*CJkD#2$-XB%$h(1CFUV!bg*+<695oguyN-KyKFj00 zwwUbGTT0UwPf)_+D&@xGmh$+~ZQ&{TERU;&oaRBk4&1AP+;CjTW7^`;cw2a!*A^3f zyFJJ+fjcP3SC0$%$8Et21apkw+4-#Bm=_a$=|o#oe;qyhMF)?I{Ls<$_mq5=$9eUi z=$qz2>YTfUAdegu@|gN3y8+g=JkG2Cc;9XhvKMeY1liHm{$J+>2dQQddv-p{6TFV8n5N0mi^UW>GOuMKICl3W>%I0e2J^f*LU{U{HhWBXQ%oJkdySNz|Z5T%y14 z_f%DP-^-of%jf_9-49GxpE_HeI(2I4s&2#BqI!-^S&Pi}IxXX7Fr7!nCuU?EStL2Z zgc5c)w}U*^-F$FRcjG0ma*`(*imO5KilI2Tzi~&Rw{b^0$pr@eZv(MeHIB|6G|6KX z{BeDuFCu!levsM;tw8TH*AzwfKAbDF3y|KYz$sv_((ADV9uu5)h;Wow!ZPKq{T)P0 zk2BizIM3)FXN>7_cIh7H5YyxA;%xYP(&JnP8~z;sV?EBESdTN=^f-Hdmfz+aDHLx3 z`$hb(YQ$h@cPS?HEza~@jEkMpqp^B`|I$3}!ah&_T!-&cKE^*Bn9@8Z0ON4{_y1@x zx^z76Jof8e@)*O?5_rz}!tx@^awgc-ljYkPS$-7`Crvwd3)Vgigfq|sSA z&#@`1^#pH?m2v|lTu&({Wt6hqn8UENNH|t)&?A<7XOtM@0kFG|EV*r*zf{E|V~CdL z86?-SDQk-#debm{6_(F~-Lqu5E+fm+CyE?+09Hv-Z1F>*xTW(Po3e`k(7VA(*$WAO zp_JcdlyZ5hEi97f*i^)lZ- z4gdXWgOh6B()dyl*Va%fRjuvRt2$Wm@_F5SHJjZ26D&=Jqz_ zzZL9mCd;=ovh2G2a7ih@r9+HB`H%KCTh5Py>%-*yZAQ+|L}Aij^WM*l87!{n*pxNH z(VpMO==?IczDUmb|4XbC%MLV1u47Zy3{$-p%kuAF_aRwM$;h&6Gk`|SVCg*Pc$i_T zx7l(IrhzN}&&|mB_0(oykvzwyZ26~pKJ=E5&>QS3$g(pd%QQFWeVBGM*Rd&E{$stl zmgP9G8$*`k4R)6y!FH3!ni{hN_>{IGwHhDmJz&`%3)a)g{`idSdzckN!_p$bmItv+ zRv9BSgWWu`7 zO&`9oBzrnm*{#eChNJ5|0i0pUZ>o&UF~Ayy|Nea$WP0a93DJ3LNY8JK4e`gb%+z^0 zXs43?xlHuufPOT`EH8)j`#|pwEfL~*AZ#M>-!c$q`;p!Cz^BJ}u*1)FY|6SvU7qm! zIe0B6ziNY-YQPvjY0ZNUxBP6%dh_Al_xhOv{u=z&kmpebJC$eGtr_TII!os{U!drR zd*@oto51xBay~sH=cF0T8b`KBo?}zC@P~VUvMhIi-Q#3=PDYlI0dzDA?K#k-5O3u- z&HG-BDgWzW_uphWIipRxE$gH1A?7~?!IGG-NR2_NMr_nBNy$2X?rk`q>g z_x#Mst2O1&Pr%Evu*Zwsie-O5Z8i71RP*Wb_qcPXn){n4a{fD}tOcJvUMV?P$_I46FSX|ND{0;iUW&cOT+^KF=pq$b`EuI`%UBAInd%xRnB% ziu)hpzf9Y=f@ikBeX^ESwWB-gc@S7EcKUSN2H=@MY{!2Ir1u$>T;~noRr}A?np*rX zU_6ii{xb$6?u4+NZYeSwtv23aF|ifH&{S$2z;_fh5bxLro_opnuNnB_0r(M`gzWWv z|6GWgB~Kq@qHf7N$EK_cR_lrScVPZ63ivXkfXMx{OOG_`^{!)6wnf%>`>fI>$Dz9M z-#^}9$BmJ;704Q1OjBb_W9cwrG8{^cX==P54K}730SUt?Wno4sn`5S-QDP5oEdgHq zVo0q~YCOKCN-TdASWhAQvoo@f3?of_l|gbHo3eK4?|o-kHiF$8vb-rH%aquiVi!bD zkz(mQ$EIwg>hH~hAB*B=LP95{Y|SX;%!zTAG3z-N)pKmhTBN@xOs@dL%gFTEj7%eI zI5~HKKO2=Sy%jqJY_v7ph3mEEd0)(W!Zvia-{Q}8TGnZ6W+MoAGYDrW zDei#=?9HgsdkV+dEwb3G?2Ib(N*?6C9Iv-Ks_-Ot>k+}sl3CU6?A#ycx+n;C`}xVk zau3h_e(qGxY%wvHe!|)&j+(WGfPf&ywY>wkJ)vu-$Qe)Et^_cC8Bb= zI{;Q^K~^<{9SWYgV=2Abwc@+wx-+3`cPM4ZsZ@_axUB?wPm3Nm=SIZu1@=#=_GKbq^Jx6?I1j^lD&mVn7atgU^P z*A=g=_Q#5)zgGtdanm1~?+)lunCthg=u2=Ogn!?7VG`Rw;Z3Lte18de?uKi6nDur?g_vov9$rPjT|9mjsYf1y9MfzrcsIn z-6W^P*L`Dsvkqj;X`f4#t$lrWuB@p(4F)Rze59Gzr}eQhj1wMc-6as;XT! zH+X=KCDr2L(6qW%x7mInpga~6YGS5KYn}b0#yGPi-(_PQ5H-eux-r(85QuI=cS}@a zknL9nr#IChIs^@6(;OBPjW(d`Y%IipK*D=|NUve2{zev>>YpxDyhtaua^B8`L5NqJ+N?JPLz@uW3alXa5S7%y-16u6%mQz4N!RmR1=1QJ@eZj$lT)+{uw)^f~N!JK+q>KC8)!FpTsJ;y2l0IPx$?QuTiAP9cTqbAO$=- zY6WM~VZZNZ*M(+1a~6e6vyzY(!xnJzU9Dd(GhBZLo0qL5lKoz14|NNJ>_mPLj0m!3 zqj@e6m-OM9I6D@KJBbP;{JLqt=oEg(rStQK6p$?rvZKAeqM46!jl{Ab&tFb!o?IB@ zC-T`ME-+ceLHIbRR_RxhqLG4Zo1d+t=BE-Yi~KuD z#fV7nJ9YK6I&Hx(O!h^ZsqG(fQT}ez!HpdY-OzCR+@N2Wif%ih+(e%48VveB4f-z+ zM$eiabo(&{6ly)KBEQh6Vdg&k#xupWUr#z$8w z`IH2~F(|OVJmv}iW5WKeVc4gNEL6h(FB8@8JC9;={pX!okWk?NR!4c;-_TG{g@YW# zxpxf18$yPF*8;p6w8*D+S&2gJH4}{2K!57)uhm2`uL)77(=w$-$m<6Gri1Y5@p;zc zQQ>jle9chZ7^=py(kt?VtdoPh2Hc)(G_n#lq)SLeKgOWHVOX#40{x6s?r$3Ob(zr- z*I?70sICC*D|Am=SCMI{ziAoMSqs`x(j1FJ$hVBZt5S*{jylsD{Cfud%2f2O!pZvv z{nx4JU4@em4EnD!qo=gx4-M`X;@W`=i!Hs23w_+N?HDv^XSjH z{tMEupw#B`riFn{4+L#o-DkL&8i~}^{z#AE)PcUjAw|+h72+;g5F~YIl=fvT&K@l2 z2y$oXkt5&i4x9?JVs<2t6w1jUB zYY8#`FI$2QMse4oJ!jhZG zq1y!*dSoACy0x#w^Gp?xs2|7QK|S4W5;Z*7?f$9VPE-D;b~d=(H)7zf-7c!2s1axG zkm;3ZFS=hrI$v7bN*Uzo)&><5{%7Z+ad9-9^e>gX31YJSx}qZkz-i9kSeTnKapdX^U|j z?n;9`-$jE4a<2dJx#P{G@qCY=LHs~kx0W6^bC4+SqG=n6sa7_jC<_zcm!PffUZIkh zPh8>O^ZneZTtrFh30?sg#3u9vdeHfq@#G8XD0)==|Fvt;3_mvvwZFmmc5=?Pl$+?G z$0ldNZx}93Phng3*C};pTc4LW3zXf1ECyObVZ)5X)o{pYDYDMe?e#cQ$`!_XUuyoT zN<~BBR1v6dG5LXiTd9?X;){bQx+?f8L-EgpD703zQ_L|h5XbymSPJMD42D0RIgQ@>C*du&XeOCtUjD-hvO#h4b z=K5#M3iAE~lyqU}7!K7pnlitdQf3qKXQQT5r(U-i^jA{RyNVjO8}ye`(Pb3<5d9}U zioVGpzn+SW=vBZ8M=QI-Shy2f8adlPjfTYsnnmPVsJUL+3vExyvT-#s&o9>l zRLE)NZGqf!#9_x~tEHlKg@6imTNmWkL7Ro>sr}E8o9|zGF3EJQvNHU2x>{dGwd(2b z>8Rc#^thu3coZ%Syyl?rC&2wHM@FqNE;ir zaOAzySm?gEkza**%s^tcnMT37@qxrkhHO)uObe6=ecJNCpAEV!4}<|h^vi_)qCvkS zmAmZ)Q=*gAI1rTqDClB-@vQcX;cV>6hGMaL>RyiG#2w0jN$;GSGtsI^n{b?0W7qH{Q#2V_i^C-IaC;XqV^T_rGPJ;=4G9CjA%*(=(*jkSkt5%OU>sVhK zcQLC#QYq6`uLG+NcijRo>t@%hZQo&fS~Pi1ub>8I98wP5sU%kJ2UUxu`q=c(X4Xfl zAh*nnX6(E)He=hbdBXFAr}1K~R*<0CIN3B$CG13$hT*dy%trC7BYid+jX`1&?|vh| zGz3%b3Nt@vG>+oh{{1V>jPYiZr+c9U;49%r*{;d96ArVs z5+sVo?=-9AyN$qTIqKb4)@gyS8>ZU-^>FJiF?TNvdWye~;5E(9D`m~jpC?aol2yFh zpXm=uANcS+KGwiPqObA9uDgGCI7E<@!NPddh!$GICB0buPmLGT&9(g9#qG~ z@Eqz3rYWlt`cWPALVu_i;C2Ep<^~)FSc5-i4bDc?b$`Ci*znO*^w8i~7D9_j_}dNM zo5ZsdBO8YOIV?7!5Y6^V<~S9`Zrf`yDDBiOZoOgtj8-D7NK{#T$rOu4h#gqx&h~Tc zxNwWqo^cltD$j3YpN~O0x&%5~N;R^ZqlpskD=TM>PxOG=MZPY3Vn1SV!nYn6wTu1x z7p$YAuKx><(K+z{bb?(5_wURoTR>l~OT#O(4^2B#BuNk??OY0`x=-K$s-q|H?r15I?b+55=&f z4>5cZ0xAoZ?Y;s?t2?a&Y|4WCg(Ft3TX)w=v6`I<>Zx~vqm2_1{uSPSJg}ncerlgr z6(Qhx3jzi|g@ua2y#795WK?8y0UCcHI$AysS;asA$$0X04sFxT|FuUS`T-weP16st zej#_sKziNsoaAroo+Mw@EMNb`}8rIhsTzTeX z8vzWqWVNZg%M8N)6lU6s(c9sH1Y%E+cQguM*Z(gyB&L8yUE}zREG}#_B;j8rxViqm z(EqN*y2uzYA+~J%^&C7Hih`zD*X24dhOc=+0iP_{Xpv^D)Y2P;Cv%xOMOkaCzhx*N zA8fE-b}40zDcCo}HO6Ay#vY}kW;5-skS!MB4hu8jyEp?>iG06MMIC+~KowbOIWnB#4!Uo5>)~6l;NjJQUUY z3bgQBsbGM8J^rgQg5a(IJhu&cPDh{IUPy&H&^eq9gwqo5CdOmFsPGV&m0#q)L?L=& zSd_qKm;c2m91NED`%!In)6nJTm`R8XVUZ(CmK+V_;t4$*C2RaFOo2G}>yEp8ZVs3o z41#`rkl$?XpqpsT>vS1OCOT$O%_2UxEb_OBEkak%WD=?OX&4-!YHf30m}Wehko`t4 z|8em=u_c&?=0t5FCs2OtYV_RoQURjCS8)Y0u{HBdbcTr6J2m!Dcrt=d!rw}>*qB?G zFzXa{9}@&rNagy!I#=8(+6l202S=f^H-_#En?4BkY<=9{LAzY0-I=V55}2|7FV)A0 zAfo)8=pbzsZaq)R@6{L~4?#$_|D-NMpX5UBf|(T*QW2kddP~PpyLY27u?=NRVhc?N zYplQ7VE1g?V(6Gi9yJ;m`hFUv8~UpxSH&Nea$%E4!aQ8O7WCL^<_6^n|J|6wtvyF? zoRQ6H@`p~DNcel9S}_cfz&cftbidFBOKDgSpsf=A^Xv(%udn@Ijm56F#Q!oT`oa`n zM^4bkzv!Hmw9kHP&3u+qG>oP=Nm|TJvOCn)-v-j5gxzS#{Kw8&M}Us_TktKi60Bfev8i%CmtSPJckWoc&K;?siuLXT;^bl79cce$6I8g*gB~f z8&-&JquAThCW`zUy2N%2v zi21OouTm=pqPs(W-0UZlNAST(yV%U!7cq0Hr0R257_4cOyvewjpv!oY{BeyfnyQU|_-@{tAj!6AXNiQ`_VSto`O7R-PQ>+{-EDszR6;oyI-f z_6B1UXQ41yS-3%0N8%Mxc%|I=cNIzQU2^9o=;bh`l%hRTDX_PXH70qeD{W3+pgDf$ zEO8P7VXZi-RD7&ZI6#82ofImG0^!C@3>MCq_8wFzWobEl#~Ah8 zVx;2K3?srzq{3}keCz{5Gt2%x){B!PA*df+rFMq#9qi7WIXm#MFEZI%oQFQs%U{0o zZt)=8;K{7Y5a;8EDGRnka$J=Sdt?h7dwJ&31WsX(}s`o ztg>*KF7sgcG7;BN6!c`Ql3GY4**W8KLVIhWk{4EsFVGx+Uc6#7FwVy6 z2OSV$A+JMA=Nh-`pO#6SY&ehS$^1g>q&K6-^J4t*l?3qry)hyeeqFyYlYqXuJW-xy z0wZ>rhXVA~1z<2S?GC`RwBZ(H5+s}QT|R7+O~R(V3pB@Hw9?wrFJ;UiL^k>AlE!J% zoZK&^A^|L!@J5s(=_Zm#Npa_fT3AkS;7VQGC@)srF7r^bN=ng<$zK>Ujx{Ij9~G&w z%&W z{URCbQ3p$2rX@#tG09!#LGm&!SvMKoadpqc|7V)N!{VX!xyxbo1)Ew|On zZp)1H&JZ!oQG&=-Ug4KBEy;n98T3VHp#@}+M|6tp2!_)F`DVUGkqMcgumy^Hhm}&m za+O(CVja+#qDq)!%Znz|MGh=+rnW>8Cel1K!bN`9A7)Jt{kbIS`t|G+a{S@rb-gAy z>&6Ks$&}(^{KjP#gWZ80mA@8NgAQi3PP3AeL!3cCKXB8m0)MuU4Ivo^(lfL%ht1*< z4Cp(P<6cbp#6UW{W8Fr#x=22sA41!VB7~AdXl(VyO6>xax1XCtfI|iZ{FL6{a^7%o zQ*IUm>pTcEeTG1D{EC$pCGY}k9ro zOvjjzRSrD&pl7269qieIwU{W+vVxc{^H5WRwHUetw@YN}^+O!{k%g#)?mvzRB?{Ms zKNig$jo5t}(`$T_jap&iZL}he>yN9G3YzreBadmEaZE$wq?1mVe9~XEcWB z%bMDnmb5#Im$ojfl0T5BwWGMk*3Kn!o7)_SY@EBiqq*H_U3z9?XS*XjTIXxbWzEfP zjc2qpp9xLdTUzI#G@Ve3f}754oVRp|v#6;ZB@p$UrOW0mZfXaMvl{0wJ+swW($wAo zdQq>vdGP{M>@lOs4fi_dsN-fYSfszF?A!WhA-*pY;B&fu(`Fltz{l=o7(0r znsC;khYUYtY}N3ERl|=TT{XPDWB!DBpc%fPseO6tyhUwGTbFjW5ASR}v!!+Z@Q$Xr zi<{e#+1}AK@2A6;wJq&{3758k42WQ1GN|n$n+*#iW8Q+;y)&<)ZL!ndj!z^v&2Q>x ziYju-F;gbbn0owijVDYxY0|OvtRqwfuyJYSA6pyLt}$K{ovrOH3tOA#SFtu|JX>Ev zVlCOeXlYwVRSNF1rsa#5HqCG3>yN}oj-NTTONAO6Mmz1Db32wVYj#?io0}WqT_UZu zX-TuwzI0wo(_-xz^OttQSdnAZAAS5uGaMrUe&0dxy9*eN6k2)OJg2ONSGpE^Z!mNXWmbZCR7?jzh-6I|Pe11jBiq zaG^6pRBJDz3tNRX_M(nPeCW4nZp-49juv>dZb~N|Kk3NHlj>*aTWNw*j+@+AKlRuX zj+uhbK`vdo*a|3P1jY$;V8puSb!^MbUzO+PI*pWpPxY^|PU)@vcS9P4j_DgLkyFHbT<8 zWhT%_Qx3xhBhitQW=sO%jH4UtXH1$g#W{oSsN>D3L*k~Da*0|btDkaG!_>)BtVruL zQiT+_WSKJ$#ay_wZMm*n1`e_oUw~h2Qp3M-YNf>9uGEB^EUf9y= z%v;*p-npcCeq@D)DJRvVnP~vy-f_pzXr#_*m3hpRqb5zB9oI$t(gYyNj`^W(cV{%W zwWG~zxc23^{;cN3&0|JK6>6enGxTHT{G}bEMm6$l)Yc;`$N9}?=r}0GJ$`yroTFzn zPM&naq$8#tGj+z)DfLcU^O7dCiq?f84`U{0SsQ$90Yi#deo^zhpGtJ6iCPdk`AYpRlKPAtofbrnGuVY zE@>Xo+1}hXVoLk6BioiP8?m6JZONHUZOtRvm$m$~xqSqBk!4F;n_D~DN6?o>Pz8`7 zhSb%Kz&~-Y1?bC~>*i^Y$v}U!xO0B91NLnr%*_aN6pe?B9lp4wwezgu3tKx!%x&pt z*C>ly=8jl6Z{EQuqsCbB6kggj*r4kcu_jPYjSCQ$TNbo5&lk}A`OOQEpc}Vq0BUVG zyY^Px#uT^ofR2}DJiMj#2imYPta*?!zh&uY!)od1;q#m4Eymq-c#jsJP{e;~#kgz6 zxii!?Zm^pZ68b9*U}>Eza^{MW>wiN*HLdHtE_Y2 zmENI6Mg4QW`>wpC7ytI^Taz~cg8eLrxNTfw!x%-C&8>HIn1j#e8lXGV$?k5jj}mNi z>rGv6bAWAt!0uUSuzw=h=GL3G-sS+?0D+zUQ!9o)J=~qCo*urw&Yh*!*F8}O%g$V{ zo~XNO1OQi!xOc1u+&lK6u|Rxi?2fU3?-+YP!_ydo9~jNBj8JD!)Lk$FfD6FLJw@G1 z@);oily{$ME;H5}E}-5t996k#_!nc{+3E|Oed2ml41en4y7hIi8hGA2mRGW&73nuR z^Q=;2zE$U)RSI`f|0PLGafLkkquxU4{N$kZDBrFT5V(89jiYe6anwDd{WaH|r|ud3 z{21InKjyVDa{t;GXn&$s`$We_6-LY;n(Vxhj#HTpJpL@UhX!Hr=$>tH(zFW(>SZ%4VEuhAEqR75a6N2R^Tketopx z=B-z+6YXTMxL_l_wG+%J7n${nb=U94HA1x1||n05Z!i+d~+nQk@^U(q%{Ecy`vr)rPloJ zy!Gm#QE!jZ+T4#+qSHCUsniWu-Jg!c=5a8T%}~8P>R+Q0KX(wC*NcWGE&d^yS{j?4 zq)2nen4M#2Xqy~ol5{)*?~C#!sq^m>$3#;N^CkJ%7{4tL`#v@N@7yv`!8Vg1(fgyed`?qg>@3xHU(^aV;w=#U!ITxG3^}} zCW)#MP`2o&_6&M=C}P#SLpKc5ox_G$yZJM3cu zK88AiVOWCW>cNOks|UX}7*X@J!IFKFcITMborBTW?HqhyC_1YHTI?*N`6*Tk0HXOR zmQJpa@Z@0hVNa5{!4l6l#0{1h03mK*!~1wBaO+7P#%J}H!TYG;zQJ3Dpi6mS*q?^s zvUk{fyu3H;gJJA;zCh{3cU`rA@TEii>^o6|CcS22m}YHiC~C%Mpty9%je;!pwUwb{`>$BFxo_ScXu8 zK*|q6Gc{-?+s1}rn?#sN##tK>0_D$~e(3)^H2J57yfZ}QILYeZniqnU=O zQ*26e!FUfCCVqW96p;i&R@jYD6A1-6QzGZvniuL{Qm4Q&u*2KHG`@hq`4Qr$_DN*1 z2KZB}lb2>gtxK~vS0ZR_u6(glhu0S?KdJ)YqpAZ{8gQWM?tuW@J#foF4cId9^+7sx z7*;W!FJ+_jFOg@1<%wtuo|S-qMAi+KHQ>U!!LkNGSaVNU`VZ7VWfmt*;6`Jhs4SoRZ?!BT9Fjn-r zhL!~2nyS~T0C-KC7Jvg)*H;5@eYKbtfGqq?oDuomPHFfcX2yF?K`>SjO^j~E^!m7TZa&u*No3rNX^R9F;AiB2i zEq&QoKC1et%0%?_K=hO6Nfc+=J&{pdQWDQ4Rgdrl=p)(BW;5cxSh=dV4uJ+3i%XIj zyJujdhm!hfa92-*2|AY-E8nWb-CJPg&LMk^g1i~XGrohohDbC#l9J(v@cs8IKdqz= zCK;D#K#6u|nM6hxk@4B=x3j%N*Q-^%ujq{{UV&+VP=zq)fN-FJ#-ZQNhT}>ede0EO zUV~r}SG}G61>T3renpZjhe<(elLDfrzR12Thn{EC#MDrh&!p-d<*LhaHs!EpOcIDT zh3KKPQGOq_tv2PnmIJp~(;Kbf)EH=v22mM{)u2Jt%7W@Oh`bOPUd#EEyreCWmj%ty zAo8-H1`Q%F3#!*3@M0gD_EVqcm{zkbPo*}Ji~4AQX(Xm;mgv;+^K*l9ox=vKSLf$mkc)}58L@5exSr(hc!o;#z9-GAi*)&UJ(=3urvrINk zTbndPS7xQ|>DldEXX1d-{X3ko1NyB;$FZencPJJEQQJZT5|xubEEinrok~sZGorE_ zsi-VXDuiW9g|K9)5SA|$!cwL{XnWiBJ8+-Pm56^*fxNQ*cxCm;J!+3vRvxa>70_1l z3)xrzuVA^QCmeH2&&`$A?by58wDhktq<^&_Y4cd5L6>Gh(%NRAkG*@K1xZkuA-${x zNr0Il{j3E^jF_Pt(hTWvZ4QfI(t1RDZSK}weBMVrT(zT0KJyb6L)}|-AA48zJJ_7p zZwIcb<@LhaOXYfX?OI-qAFFF~U(5BzuGgG!y`=j3YTU=O6C2jxrPf-QO%u(eUAE%2*bOSGSV#qhGJqM%RL6NOfh-@x2?G^ z<Co!HQ$r_NiIirxu4|*?VJdq?!Ku7EnP3oNT;?8{M|rF#gV|D4GkA& ziELagj%PQC_{yV<(EdQ1H6$k923Sl2(iB7X!a#Fteo0hAigd!pumdkbuB81l4O0M{wZwTKCVX z--WeTk}v`_Xb^{M7OP%^NT@#Ven}4*%Il<%3Xtw;U_?aCT+-u4qN*!vuddZ31_-f) z39SV+Xb_oLP`w5jeQ)gXpm395D62P6k3euE%oajG4H{$$A)tB70%8(~&A0dZ6YkkL&*9A5zhZuG&Yd;FsZ)@3nv*w>ruvmsDMn)`Zl{AlqYfM^sD zf;jpE2m(S7$8iA3)a8{PACWy`j$#trU?ZmA>|y3C>P^7$6>kKzL$C<$vFiTOZ<)}aO z{GW_sY#w+SEU>UCshiJw-ke8!pl+B1qD?h~DGAOu=V9W(-c?D8%c|;kd5`3|Cm>?r zCj6;wd7l=*adu%CgUc;_|JWCoi>j`v!sYtvO`NH0uYOi0EQ6lp+$0k{p8An~j;5cZ z>E~$rIhr1Ukn}p>Y5}BYf}wj3a-FbvLgSwe2TrL@10y< ziGWgJclM>DSUj{QqQTA)h-L+d;Ya$Z>+k9njcp3dLO=D;%jbTy@GEi$>!9#+p9);rzQ3!@P z2n2NmYS17?97BwtELe;v06kRjdI8;t!9nW70>MoJ2IGyvLmdkUN`)ZXECEul7a;0L ze3F#JrwpVsVwp018=#pYP zdcUN2U9s*|#$)u36JqEpinmb^>iz-g=|Z4*=>zc62oD7$8dQj|=wkpi#6WZ;fJCX= zHGB4SOd6#i_x9p@sFw6~_Y~h(oYn4}<0R1W-3PR&;|n-V*jD^TF+FQr@1688EaMm; z6e5fYg`frvf&fyq9#HCQVEYC8kN48S~}6 zHVUrzzV2@+oM5QP1_5fBAPcBLgHV4VR=oy^iW_@9-pi=Sq*hTBP{4l(>)#7t-KD0;rcJ3Kc2X^%@(?=ETO54YBL_+)OL=Q8inc+6T^(SO?zH z?(l4Qcl9Oxyzg-xQId%bJ>_fE=2k0dZlHioVaP%JkE%&(k{Z|C0N18u6#5axusOPl zNmF=z!-AQV4T!MiCH?Lqg=o#|#RGpM&qb&_Iq48*5QU$h>J*W6is)Ke3IT0u4gDKh zn0Qbm6$z#!67375dW~cv=w1DG_ER}8+*!y;7KTWMj-4z6kpI{-t6~PKR^3BJS`gd zJ*7}iu5`~7CJ>Pk~)|X$|dp}wEPAw zzd_5#(+$dxH*TvoX6978UX#^pvU*L1Cmt%&I%XVUu6Ji9*D4_v5T-4_#PTO>+b!XY$GH?8P;el%eOl>3{@DT6^Rkp;N?>AW^)}R(ncbSjFO!j0EbJ zMd8RA1F<9iV(^eYTz?=OF< zoaQu1t%12=T+kSzfqJU^ukbsI%BfwmxrY zXSYBtmtZY7#D>)fWffRlxUPU?w6MD3_6kd)cq5Het*^MHLNBAyZ!pKIxPcoKxdQ>0 zm#}XS7w*=Bq8K+vNLmr%=@e0QO7`|i|B^xg1%+#a=GvgS%4j^|3Y7(^UL)0OBo525 z_4D?Mrz#{IyjJx=l@14<`oL`elZAuZE8eN_rmP2#52|q0t62mlHZ&f>;xWUdqfA?r z4|GwY3{m+5K3xmBi-G{j@ZqV7H(5&@Com~|>`{Ki3Eg3fF)tzT~N8E5oxD8!&mRs?CTE|9~|)H09>9Y_K6z%M7rWG z0F3jzUUN-9m-qnKN6bqKb(jL69&AD<+K}*Od>Npb82%=M9v)v=yOLxsIiY-s1a4< z2m0FsEcxCZ?8rj%+X2pCgDvV*986vf-VfNjy&t@iKtmB)eb7>UlsG`p0WiiS7Rx^$ zCGN=*2Y1w!!Lxzv%Yl8F)?o0v(iZ92K%3&(;MIUVIC@ehag@QPSWTe#GQi7)QzQ$} z6l!Mz>PCekg=F8HK%s6 z5E$=| zhg_M;-j0gYQ&|f}S4yDJ8cdW}K;&XUGd0LMirVh|-9s_{?(J04cGOPwx{@QD)|G&A zl@IP$`H%WQ!C8t&{R0FX@E;-#01uG{=lt;^WATRV-urY9^}cth4_A6f?NIb6V9{wL zY7+%=jkE}Kq6XnaH{DIJW@wN(-zK1$8bqrAi(=?}4*{y}Y=CfZ!Vs21JTwXc8E&E> zS`)FrjdgaZf0fVS`J?_@yhcD2nJ`+|;z59b#`LE?+6kd|_}ipdxI@Nwgd$=L5n|aQtqmZ>LDqN4abNtgoyLe7=hJhF7_#-D<<9jA3TtNk-%$_!A;loLIa14;z^nv`?}Q!s+kMZ#)AeaEaMaD7u*}eBSr>e6>4MLW2>d(|Qg}tIO{h zcukJ~<;0kMPB!rLa&0y6-f4VZj__;a`n(sxQ}oF?L<>u|&p-o*kNzdD&*281qK{_5 zzlj+m^^NhBM_f3`WA5+hGQabmMQc;f`j;#1D;o@My6kNRjyisMqGl()@~VNS$cE1q zjM7Nyd`1~K@NbLrIWmH$*kWE6d{##I56Agj6~R;Z+}{PC7Y!Wc+8pQeVHbRIxEzRI zdbtK0IO=B2M8R;xK0h$<6#MA>_}|1|OdXm!`c)DG-d`oRCeg<~p4^m!yG=Pea&WgJ z=fhmweVDtV8!B!^w{6{Ux2@X~+=(9lB|i9`zBL?g^6_x>t*ncaxIB_v&GUw|ZIC`9v(aBK} zJQ*LIoNnOhdp6Fp4B6w6wfS+yP>9u{Wf%nSsZ;JD|HxnP&;e{^v z{5|3`KhDRWkikA0#J>sqsT1Xl!`OG+qjZHw-9Ni}s{Chn52w(3+zUCO!5aeXFNJP~ z8i$E6_K#>f%%1-Y0(gvZxR?xiZxyAS3YqKivOGnqDCR& z#D%}auj{lr7|2$h;b0e1jc49(rtkEdma5^06@$)2fb5^F8hAzwD-N@`^uhZKU-(*z z?>uARl{x;3(ECxhe>L!Q-x(>7$PbQy#~U~!SDep615f9(stZ0h8903B%!%3*(o=5D z#OJ>5(KJ8>fhsIlefl}7d!8!qqxv194x8YpLk?4uW@4i5sF}0XDL+%4XF2L;o$9P# zyXtE9I#=C-#SBON+P&J9`L){M-QvkC8M}+Itbw(Ozq#rY_fuCLaKCnCxj|jzT>_fN znZ3*1ZFFB3fn6YFbz zndhlZ{sX?+>ObPEo78Pe=4@Dk_SgFA8-InujO-=__#I%k+keBy1nV1LtpM0j@A~_E z#iKEA;XXA|p_hhRrc!2@-j%IJk z#@hy-%$9czsNI!sRN_4YUsbAa@%8}y<^c6+wI5?Gf`5;4gS?+IgaWeCXT=gstRI2Sb?x|LT z#AIKadu^_|G55|~bq~&{N;dS>G&km|J9F>J#Ys#{tqGsVeKuFUgoCI!D*3lu^%>4f zI_hsYRf+SDE4!(Sx?R>yt?qVBH?>3Sx4K%6NjmD9Zfm>YaN@1q)Fzx(!M4Tmjt(*EEoE^k-q;CJyO%4p=#NXyBbJA}!l5!9a*24)qwHZhHYipRiU{f$tJ$G_COIIhJr zS+Dg_@8B>N&tI+Vseak>lAfrjcYDiWDz&1|>OPRv^OrqwQ0khVYAsGnsn2?U(Oc>a z*6F#nCk{s4*%JqvAMB|f?)itF>XY7|^_HVbJYggbaC06H5g9kYcA2~@^3+{mt2R?B>Vj9-;Q$NHjXa*Op2XP_1eg3L^A!(( z$ng(-phJqnLmGe2SD)h;20Q@gDda!|k3vY%UpJ*@S$U?xn8HziDtNg-y;HEa0NdxU zEK=7N-Cu-lx8Th3eA6HMsvn;d6!9c>hMB+JoKqP`q$Yp?bgYpM~lx?53Ap@{amv;a7#& zB7aelT3rNX?kKv4${gq;JKC|(RJ58J-ccm`)zyK6)r4afU0bB?FM7BLJD@-7g^lRv zm#AN0?$9pacDY~HSN*#0Z~DsCY}q$1yS!70_d^s5&i&6O=i#DlMQUf! zb46-b(Thd09~*Da)3P9NThY!U^<2@eBK0CRU&pEU7hPDaxS5$-omUmB)snoh_>yAT z&kWtR_EwMd-iC&9d-0}X*;CAI#LpG0UD!*kw)TFcx9kZ<6fE9Vj9tHfD^{OiXE0(* z@h8Qy_gCFgdwVVS@UHKrZpKDl*`VvF^}TNHg)O*Sd#OjTHy208dp*)iJ>Kh?Ug`yG z!A0S30nszPUg#woZe?Gr?26^y*A)9@FdZ5m_ROmDOMX#;U84V8s@^Ppr&Rs5^gyZl zhWi8e^j%#gds|ZxA1zT&U@Izt2SVzrOVoyvTT0Z%l1(MpsT$%OpoG<|z^h9(l&D)u zHkPPO*pZ5b(vl}i)J|-NRWFphQKI&6yD2x2!naEHl&HNWAC;*6*g}d8o-3+kdnY=c zlKmy>>yoQV)ruHdJDhMCom%>i*KLrRuTL zsCd{mxu@?Z$o|Nd6z`lZeX~@(Q~K9ZwX1q}HTD5sSfehfxvoa7!d)~02Eh@?_EXpSXWJ{W(ZmGGeMz)%9OWCCr*w}GRg<8w49;>-QY**hs2xYP@ zY`|3m)Qtmf9e{mSs|U&k8wcB&{#2%3F56wE-oRF+G(=|KS*Es>JzS=?Vc(Nb6S$>J zJzTb}4BMMBQzImmyicLI@da?Y~a_r0bxLkdT9XlxL zHgsG!VQZPX2Rmx;Zou+S%dvw7=GfL}5AH*wV6<1Ux}sx~M(eXCS*5xCJ6L z`-KYiGB04$PNgz=|T28#PqnZ=8_t9UCp{0^;pfC0qUv&Hx5v@4%jn5 zwhuTe%HCD8y9N*QSN2y|^uMOR$r0eYH6PZfPihX-sDDM-!t(W+cWcy#HJ{X|1K1FN zI_ZB!e|1g&8~dwU`E;L;@Evty|6BXZ!+JbT59RWCx7t2nC-Qgof4RRrW({-Pot zY460dXqtyxi}rA@W{6#CX2v@6w@bhpG*DBcHrw56^6dZWxM}?A6f3R=Yck()=WU1Ro)LC6|;NtFC;X0f(xIcev_q_kPz2I>VW8q58p0C$MYG+&vWFZPIpa*52Fa4EElx z&+|UnQfn8p+D0Z1l?t43dUGCz24(=~C%UsG?uHFqk3gVD%s_JkjunU@`P~}cw z7n8VbC2LG^P&d`H7Uc@plYCY2A5IXhFJ-AbOkkm>T`&^8ik!eM6k$<76_hxEUGQPP zDcU=Eda{i5Tvf8tbAkvEsFD)Z?3@{rGtuxFX9U+9 z_zGjl3Io5d3qDJb?>a@!-;RoFsneO{{#@`&oXu%^ULbM`oxCGc`CKjdrOt-NRQTP1 zruF>c~J1f+c5Do@mb=D z8f4?ZZo$uUb{YO>!tVU-13VAyqVzPm4gah%_$L_S4Kz7_Gx)Y0S%L!wf4lJm8?XK) zaEldPd%=DzS2~}5fLBFmVQM__QLd(m8f5GJD8T!LSW)6_%Jr}T!srv`)X!_{7*)3v z-$cRph#EVZ7H0U`$GnW6b+O-8sT*Uz-69`l$@~G9y3v~NL7#!q-)E^CrL$8%@)AlI z%TKn*hg|ALI#9U1qjSLmd}U>Ra~nR$BOmYTSkgETUk__;2kQK#jSCkqo!hjyaX!AL z)!x{I5Doi#1L; zx>4sIedI|_<59;Pf5fC?8jt_ckLst)Xq++Wh-0RJq7k2lna58twedqXEv*xeI%et- zlN(3ZjTx)I;*#=xpvI{)j>Si=tchkEJDCQW!HUxF{a;o8xruZwI7AK+)t3$GG;Zoc((cX+t7SB`sj4$>-v$duS3%mY{_J);za!AuyX-7oy#o+m_Y^OKrMo?%cNK zGwdC{mDFt0u%Jy6j9QKEm(E*^m=H2-Yh9@C!317?d;P@7Wf^HZOV9Bne=Yb=5=+0+C}^)Q z6DjsfxBt(Ohke4K{_D(ZeC^ea`fp?OTMfOvo@QjH>u62!prVAlmC`F?!l8o&H9^XcNmn91oar0sTAvORxX6$c)oh89IAC zAx@06$qui#r_nDn2=;36EgO4pubb28SC|ZYooETo)zaDPLuvHeO@_TL9G1$!(pl)X zG%>))AZ(9Jb3jUZkA^J(;U-eRv;8g}-Q#?E;?jeg^i8q;2n zHto^UTmP~5dy(jY{}#>8`!;Z5jbA#xy|>qY0H-JZTY5Xs-1u+O{|mJAT}yB0bDItQ zC#J!({qbkzT03q6j3}1g&hIuHPi)6IP5wrIEWN!ZKpdwJ2~N;7R*vOh>Fj+s5EEwQ z+wp#$p-;z8ufKdFf23t-u9kjy8l1ZFXRqakUPGh56O8<25lB*&&Vuow;*ghx@p`wR zpL#|ddN3~M`{_JM@z19;-5a*z44ZV^$z9O@`2|hzq**63IOF@9tb)AYCC(+$1V&tAV}#o~ACaTcPlmd--Y?t=d2(=_HxHm=w&ou2n$ z?1|wWz_-Hrv;Eirr2FE(^+((PW&>t@7s>Fqxl_~6P0LJ|&&9+v6yZ^@Tw`9GMv!*v F{2yVs3xogw From 37a0a057ca35d231689bd0f438cac76e1d285ef1 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 14 Oct 2026 12:27:05 +0000 Subject: [PATCH 05/99] spikes: ESP32-C3 firmware for the M0 AWDL hardware tests ESP-IDF projekt in firmware/spikes/ mit USB-Serial/JTAG-Konsole: - radio.c: esp_wifi-Glue, Raw-TX (esp_wifi_80211_tx), Promiscuous-RX gefiltert auf die AWDL-BSSID, RX/TX-Zaehler, Peer-Ereignis-Queue, tx-done-Status fuer den ACK-Test. - console.c: Befehle info/mac/chan/rx/tx/txdata/peers/stats/chanlat/usbbench fuer die fuenf Spikes aus docs/SPIKES.md. - Baut sauber fuer esp32c3 (ESP-IDF 6.0.3); Host-Test weiterhin gruen. Dazu docs/SPIKES.md (Testplan + Wireshark-/Monitor-Setup), firmware/README.md und tools/usb_throughput.py. Der awdl-Kern bleibt ESP-IDF-frei und testbar. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01XzgaN4Bv1m8Ka9iqo7QVUN --- docs/SPIKES.md | 113 ++++++++ firmware/README.md | 45 ++++ firmware/spikes/CMakeLists.txt | 4 + .../spikes/components/awdl/CMakeLists.txt | 4 + firmware/spikes/components/awdl/awdl_frame.c | 6 +- firmware/spikes/main/CMakeLists.txt | 5 + firmware/spikes/main/app_main.c | 47 ++++ firmware/spikes/main/console.c | 247 ++++++++++++++++++ firmware/spikes/main/radio.c | 197 ++++++++++++++ firmware/spikes/main/radio.h | 89 +++++++ firmware/spikes/sdkconfig.defaults | 20 ++ tools/usb_throughput.py | 52 ++++ 12 files changed, 826 insertions(+), 3 deletions(-) create mode 100644 docs/SPIKES.md create mode 100644 firmware/README.md create mode 100644 firmware/spikes/CMakeLists.txt create mode 100644 firmware/spikes/components/awdl/CMakeLists.txt create mode 100644 firmware/spikes/main/CMakeLists.txt create mode 100644 firmware/spikes/main/app_main.c create mode 100644 firmware/spikes/main/console.c create mode 100644 firmware/spikes/main/radio.c create mode 100644 firmware/spikes/main/radio.h create mode 100644 firmware/spikes/sdkconfig.defaults create mode 100755 tools/usb_throughput.py diff --git a/docs/SPIKES.md b/docs/SPIKES.md new file mode 100644 index 0000000..2210e63 --- /dev/null +++ b/docs/SPIKES.md @@ -0,0 +1,113 @@ +# M0 – Hardware-Spikes auf dem ESP32-C3 + +Ziel von M0: herausfinden, ob der ESP32-C3 das kann, was AWDL braucht, **bevor** +der ganze Stack portiert wird. Der C3 funkt nur auf 2,4 GHz, also läuft hier +alles auf **Kanal 6** (dem 2,4-GHz-Sozialkanal von AWDL). Für den Alltag wird +später vermutlich ein C5 (5 GHz, Kanäle 44/149) nötig – siehe `UEBERBLICK.md`. + +Die Firmware dazu liegt in `firmware/spikes/`. Sie bringt WLAN hoch und bietet +eine Konsole über den eingebauten USB-Serial/JTAG-Port. + +## Bauen und Flashen + +```sh +# ESP-IDF >= 5.5 (getestet mit 6.0.3), einmalig: . $IDF_PATH/export.sh +cd firmware/spikes +idf.py set-target esp32c3 +idf.py build +idf.py -p /dev/ttyACM0 flash monitor # C3: USB-Serial/JTAG meldet sich als ttyACM* +``` + +In `monitor` (oder jedem seriellen Terminal) `help` eingeben. Beenden mit `Ctrl-]`. + +## Konsolenbefehle + +| Befehl | Zweck | +|---|---| +| `info` | eigene MAC, Kanal, Promiscuous-Status | +| `mac ` | Stations-MAC setzen (für den ACK-Test) | +| `chan <1..13>` | Kanal wechseln | +| `rx on` / `rx off` | Promiscuous-Empfang (nur AWDL-BSSID) an/aus | +| `tx psf [n]` / `tx mif [n]` | n Action-Frames senden | +| `txdata ` | einen AWDL-Datenframe an die AWDL-Broadcast-MAC senden | +| `peers` | gesammelte Peer-Ereignisse ausgeben (Name, RSSI, Kanalsequenz, Zeitstempel) | +| `stats` | RX/TX-Zähler | +| `reset` | Zähler nullen | +| `chanlat [n]` | Latenz von `esp_wifi_set_channel` über n Wechsel messen | +| `usbbench [kib]` | Datenstrom für die USB-Durchsatzmessung auf dem Host | + +Zum Mitschneiden brauchst du **zusätzlich** deine Linux-WLAN-Karte im +Monitor-Mode auf Kanal 6: + +```sh +sudo ip link set wlan0 down +sudo iw dev wlan0 set type monitor +sudo ip link set wlan0 up +sudo iw dev wlan0 set channel 6 +sudo tcpdump -i wlan0 -w /tmp/awdl.pcap 'wlan addr3 00:25:00:ff:94:73' +``` + +Der BSSID-Filter `00:25:00:ff:94:73` ist die feste AWDL-BSSID; so siehst du nur +AWDL-Verkehr. **Mitschnitte enthalten MAC-Adressen und Gerätenamen von Leuten in +der Nähe – nicht committen** (die `.gitignore` blockt `*.pcap` und `*.log`). + +## Die fünf Spikes + +### Spike 1 – Sendet der C3 gültige AWDL-Frames? +1. `tx mif 5` auf dem C3. +2. Im `tcpdump`/Wireshark auf dem Laptop nachsehen: Erscheinen die Frames? + Wireshark hat einen AWDL-Dissektor – prüfen, ob er Sync-Params, Kanalsequenz, + ARPA-Name (`espdrop-c3`) und Version sauber dekodiert. +3. Querprobe gegen den Host-Test: `firmware/spikes/components/awdl/test/host` + gibt denselben MIF als Hexdump aus. + +**Erfolg:** Wireshark erkennt die Frames als wohlgeformtes AWDL. + +### Spike 2 – Empfängt und versteht der C3 AWDL von einem iPhone? +1. Auf dem iPhone AirDrop auf **„Jeder für 10 Minuten"**, Teilen-Dialog öffnen + (das weckt AWDL). +2. `rx on`, dann mehrfach `peers`. +3. Erwartung: Das iPhone taucht als Peer auf, mit Name und einer Kanalsequenz, + die auf 2,4 GHz einen Slot auf Kanal 6 enthält. + +**Erfolg:** `peers` zeigt das iPhone mit korrekt dekodierter Kanalsequenz. +**Risiko:** Auf 2,4 GHz bietet das iPhone Kanal 6 evtl. nur in 1 von 16 Slots an +– Empfang kann selten sein. Mit `stats` beobachten, wie viele Action-Frames +ankommen. + +### Spike 3 – ACKt der C3 Unicast an die eigene MAC, und sieht er die Frames? (wichtigstes Risiko) +Hintergrund: Ein Forenbericht sagt, der ESP liefere ge-ACKte Frames an die +eigene MAC nicht an den Sniffer. Dieser Test klärt das. + +- **Variante A (AWDL-MAC = Interface-MAC):** Standard. Ein anderes Gerät (Mac/ + iPhone oder ein zweiter ESP) sendet Unicast an die C3-MAC. + - `stats` → steigt `tx_done_ok`? Kommen die Frames im `rx_*`-Zähler an? + - Auf dem Mitschnitt des Laptops prüfen, ob der C3 ACKs sendet. +- **Variante B (AWDL-MAC ≠ Interface-MAC):** mit `mac` eine andere MAC setzen, + dann als AWDL-Adresse eine dritte verwenden. Erwartung: Frames kommen im + Callback an, aber es gibt keine Hardware-ACKs. + +**Erfolg:** Klarheit, ob Variante A ACKt *und* zustellt. Davon hängt ab, welche +Architektur der Daten-Pfad später bekommt. + +### Spike 4 – Kanalwechsel-Latenz und Zeitbezug +1. `chanlat 200` → Mittel- und Maximalwert von `esp_wifi_set_channel`. +2. Bei einem empfangenen Peer `rx_ts` (Hardware-Empfangszeit) mit `esp_timer` + vergleichen – brauchen wir für die AWDL-Synchronisation (M1). + +**Erfolg:** Latenz klein gegen ein AW (16 TU ≈ 16 ms). MT7921 lag bei ~8,5 ms; +der C3 ist nicht offloaded, hoffentlich schneller. + +### Spike 5 – USB-Durchsatz +1. Auf dem Host mitschreiben: `python3 tools/usb_throughput.py /dev/ttyACM0` + (oder schlicht `time cat /dev/ttyACM0 >/dev/null` währenddessen). +2. Auf dem C3 `usbbench 512`. + +**Erfolg:** Deutlich über dem AWDL-Realdurchsatz (50–250 kB/s), damit USB nicht +zum Flaschenhals wird. + +## Danach + +Ergebnisse (auch Fehlschläge) in `docs/UEBERBLICK.md` unter „Offene Fragen" +festhalten. Fällt Spike 3 Variante A positiv aus, geht es mit M1 weiter: den +OWL-Kern (`src/`) als Komponente portieren und auf einen echten Scheduler legen. diff --git a/firmware/README.md b/firmware/README.md new file mode 100644 index 0000000..d55040c --- /dev/null +++ b/firmware/README.md @@ -0,0 +1,45 @@ +# EspDrop firmware + +ESP-IDF firmware for the ESP32 that acts as the AWDL radio for EspDrop. + +## Layout + +``` +spikes/ M0 hardware spikes (ESP32-C3): prove the radio + components/awdl/ platform-independent AWDL frame build/parse + include/ awdl_frame.h, awdl_wire.h + awdl_frame.c + test/host/ native unit test (no ESP-IDF): `make -C ... run` + main/ ESP-IDF glue: radio.c, console.c, app_main.c + CMakeLists.txt, sdkconfig.defaults +``` + +The `components/awdl` code has **no ESP-IDF dependency** and is unit tested on +the host, so the protocol logic can be developed and checked without hardware. +The `main/` code is the only part that calls `esp_wifi`. + +## Build + +```sh +. $IDF_PATH/export.sh # ESP-IDF >= 5.5, tested with 6.0.3 +cd spikes +idf.py set-target esp32c3 +idf.py build +idf.py -p /dev/ttyACM0 flash monitor +``` + +## Host unit test + +```sh +make -C spikes/components/awdl/test/host run +``` + +See [`../docs/SPIKES.md`](../docs/SPIKES.md) for the M0 test plan and +[`../docs/UEBERBLICK.md`](../docs/UEBERBLICK.md) for the overall design. + +## Provenance / licence + +The AWDL frame layouts, TLV numbers and timing come from the Open Wireless Link +project's reverse engineering and from OWL (https://github.com/seemoo-lab/owl). +This firmware is GPL-3.0-or-later, matching OWL. It is an interoperability +project and is not affiliated with or endorsed by Apple. diff --git a/firmware/spikes/CMakeLists.txt b/firmware/spikes/CMakeLists.txt new file mode 100644 index 0000000..e247392 --- /dev/null +++ b/firmware/spikes/CMakeLists.txt @@ -0,0 +1,4 @@ +# EspDrop AWDL spikes (ESP32-C3). ESP-IDF >= 5.5 / 6.0. +cmake_minimum_required(VERSION 3.16) +include($ENV{IDF_PATH}/tools/cmake/project.cmake) +project(espdrop_spikes) diff --git a/firmware/spikes/components/awdl/CMakeLists.txt b/firmware/spikes/components/awdl/CMakeLists.txt new file mode 100644 index 0000000..1f8f6dc --- /dev/null +++ b/firmware/spikes/components/awdl/CMakeLists.txt @@ -0,0 +1,4 @@ +idf_component_register( + SRCS "awdl_frame.c" + INCLUDE_DIRS "include" +) diff --git a/firmware/spikes/components/awdl/awdl_frame.c b/firmware/spikes/components/awdl/awdl_frame.c index 6812997..3b835dd 100644 --- a/firmware/spikes/components/awdl/awdl_frame.c +++ b/firmware/spikes/components/awdl/awdl_frame.c @@ -355,7 +355,7 @@ int awdl_parse_action(const uint8_t *buf, size_t len, awdl_peer_view_t *out) { /* AWDL action header at offset 24 */ size_t a = IEEE80211_HDR_LEN; - uint8_t category, type; + uint8_t category = 0, type = 0; wire_get_u8(buf, len, a + 0, &category); if (category != AWDL_CATEGORY_VENDOR) return AWDL_ERR_NOT_AWDL; if (memcmp(buf + a + 1, AWDL_OUI, 3) != 0) return AWDL_ERR_NOT_AWDL; @@ -422,14 +422,14 @@ bool awdl_parse_data(const uint8_t *buf, size_t len, if (fc != FC_DATA) return false; size_t llc = IEEE80211_HDR_LEN; - uint8_t dsap, ssap, ctrl; + uint8_t dsap = 0, ssap = 0, ctrl = 0; wire_get_u8(buf, len, llc + 0, &dsap); wire_get_u8(buf, len, llc + 1, &ssap); wire_get_u8(buf, len, llc + 2, &ctrl); if (dsap != 0xaa || ssap != 0xaa || ctrl != 0x03) return false; if (memcmp(buf + llc + 3, AWDL_OUI, 3) != 0) return false; - uint16_t pid, head, et; + uint16_t pid = 0, head = 0, et = 0; wire_get_be16(buf, len, llc + 6, &pid); if (pid != AWDL_LLC_PID) return false; diff --git a/firmware/spikes/main/CMakeLists.txt b/firmware/spikes/main/CMakeLists.txt new file mode 100644 index 0000000..3058485 --- /dev/null +++ b/firmware/spikes/main/CMakeLists.txt @@ -0,0 +1,5 @@ +idf_component_register( + SRCS "app_main.c" "radio.c" "console.c" + INCLUDE_DIRS "." + REQUIRES awdl esp_wifi nvs_flash console esp_timer +) diff --git a/firmware/spikes/main/app_main.c b/firmware/spikes/main/app_main.c new file mode 100644 index 0000000..51e5906 --- /dev/null +++ b/firmware/spikes/main/app_main.c @@ -0,0 +1,47 @@ +/* + * EspDrop - AWDL hardware spikes for the ESP32-C3. + * + * Milestone M0: prove the ESP32-C3 radio can do what AWDL needs. Brings up + * Wi-Fi, starts a USB-serial console, and lets you inject AWDL action/data + * frames, sniff AWDL frames from nearby Apple devices, and measure channel + * switch latency and USB throughput. See console.c for the command list and + * docs/SPIKES.md for the test plan. + * + * SPDX-License-Identifier: GPL-3.0-or-later + * Copyright (C) 2026 EspDrop contributors + */ +#include + +#include "esp_console.h" +#include "esp_log.h" + +#include "radio.h" + +#define DEFAULT_CHANNEL 6 /* AWDL 2.4 GHz social channel, the only one on the C3 */ + +void console_register_commands(void); + +void app_main(void) { + ESP_LOGI("espdrop", "EspDrop AWDL spike (M0) starting"); + + int err = radio_init(DEFAULT_CHANNEL); + if (err != ESP_OK) { + ESP_LOGE("espdrop", "radio_init failed: 0x%x", err); + return; + } + + esp_console_repl_t *repl = NULL; + esp_console_repl_config_t repl_cfg = ESP_CONSOLE_REPL_CONFIG_DEFAULT(); + repl_cfg.prompt = "espdrop>"; + repl_cfg.max_cmdline_length = 256; + + esp_console_dev_usb_serial_jtag_config_t usb_cfg = + ESP_CONSOLE_DEV_USB_SERIAL_JTAG_CONFIG_DEFAULT(); + ESP_ERROR_CHECK(esp_console_new_repl_usb_serial_jtag(&usb_cfg, &repl_cfg, &repl)); + + esp_console_register_help_command(); + console_register_commands(); + + ESP_ERROR_CHECK(esp_console_start_repl(repl)); + ESP_LOGI("espdrop", "console ready; type 'help'"); +} diff --git a/firmware/spikes/main/console.c b/firmware/spikes/main/console.c new file mode 100644 index 0000000..b8dcc33 --- /dev/null +++ b/firmware/spikes/main/console.c @@ -0,0 +1,247 @@ +/* + * EspDrop - serial console for the AWDL M0 hardware spikes. + * + * Commands (type `help` on the device): + * info show self MAC, channel, promiscuous state + * mac override the station MAC (ACK experiment) + * chan <1..13> set the 2.4 GHz channel + * rx on|off enable/disable promiscuous AWDL receive + * tx psf|mif [n] inject n (default 1) action frames + * txdata inject one AWDL data frame to the broadcast AWDL addr + * peers drain and print discovered-peer events + * stats print rx/tx counters + * reset zero the counters + * chanlat [n] measure esp_wifi_set_channel latency over n hops + * usbbench [kib] stream kib*1024 bytes so the host can time USB CDC + * + * SPDX-License-Identifier: GPL-3.0-or-later + * Copyright (C) 2026 EspDrop contributors + */ +#include +#include +#include +#include + +#include "esp_console.h" +#include "esp_timer.h" +#include "esp_wifi.h" + +#include "awdl_frame.h" +#include "radio.h" + +static awdl_self_t s_self; + +static void refresh_self(void) { + uint8_t mac[6]; + radio_get_self_mac(mac); + awdl_self_init(&s_self, mac, "espdrop-c3", radio_get_channel()); +} + +static int parse_mac(const char *s, uint8_t out[6]) { + unsigned v[6]; + if (sscanf(s, "%x:%x:%x:%x:%x:%x", &v[0], &v[1], &v[2], &v[3], &v[4], &v[5]) != 6) + return -1; + for (int i = 0; i < 6; i++) { + if (v[i] > 0xff) return -1; + out[i] = (uint8_t)v[i]; + } + return 0; +} + +static int cmd_info(int argc, char **argv) { + (void)argc; (void)argv; + uint8_t mac[6]; + radio_get_self_mac(mac); + printf("self MAC : %02x:%02x:%02x:%02x:%02x:%02x\n", + mac[0], mac[1], mac[2], mac[3], mac[4], mac[5]); + printf("channel : %u\n", radio_get_channel()); + printf("promisc : %s\n", radio_promisc_enabled() ? "on" : "off"); + printf("awdl bssid: %02x:%02x:%02x:%02x:%02x:%02x\n", + AWDL_BSSID[0], AWDL_BSSID[1], AWDL_BSSID[2], + AWDL_BSSID[3], AWDL_BSSID[4], AWDL_BSSID[5]); + return 0; +} + +static int cmd_mac(int argc, char **argv) { + if (argc != 2) { printf("usage: mac \n"); return 1; } + uint8_t mac[6]; + if (parse_mac(argv[1], mac) != 0) { printf("bad MAC\n"); return 1; } + int err = radio_set_self_mac(mac); + if (err) { printf("set_mac failed: 0x%x\n", err); return 1; } + refresh_self(); + printf("MAC set\n"); + return 0; +} + +static int cmd_chan(int argc, char **argv) { + if (argc != 2) { printf("usage: chan <1..13>\n"); return 1; } + int ch = atoi(argv[1]); + if (ch < 1 || ch > 13) { printf("channel out of range\n"); return 1; } + int err = radio_set_channel((uint8_t)ch); + if (err) { printf("set_channel failed: 0x%x\n", err); return 1; } + refresh_self(); + printf("channel %d\n", ch); + return 0; +} + +static int cmd_rx(int argc, char **argv) { + if (argc != 2) { printf("usage: rx on|off\n"); return 1; } + bool on = strcmp(argv[1], "on") == 0; + int err = radio_promisc(on); + if (err) { printf("promisc failed: 0x%x\n", err); return 1; } + printf("rx %s\n", on ? "on" : "off"); + return 0; +} + +static int cmd_tx(int argc, char **argv) { + if (argc < 2) { printf("usage: tx psf|mif [count]\n"); return 1; } + uint8_t subtype; + if (strcmp(argv[1], "psf") == 0) subtype = AWDL_SUBTYPE_PSF; + else if (strcmp(argv[1], "mif") == 0) subtype = AWDL_SUBTYPE_MIF; + else { printf("unknown frame '%s'\n", argv[1]); return 1; } + + int count = (argc >= 3) ? atoi(argv[2]) : 1; + if (count < 1) count = 1; + + uint8_t buf[1500]; + int sent = 0, failed = 0; + for (int i = 0; i < count; i++) { + int n = awdl_build_action(buf, sizeof(buf), &s_self, subtype, + (uint64_t)esp_timer_get_time()); + if (n <= 0) { printf("build failed: %d\n", n); return 1; } + if (radio_tx(buf, n) == 0) sent++; else failed++; + } + printf("tx %s x%d: %d ok, %d err (len=%d)\n", + argv[1], count, sent, failed, + awdl_build_action(buf, sizeof(buf), &s_self, subtype, 0)); + return 0; +} + +static int hexnib(char c) { + if (c >= '0' && c <= '9') return c - '0'; + if (c >= 'a' && c <= 'f') return c - 'a' + 10; + if (c >= 'A' && c <= 'F') return c - 'A' + 10; + return -1; +} + +static int cmd_txdata(int argc, char **argv) { + if (argc != 2) { printf("usage: txdata \n"); return 1; } + const char *h = argv[1]; + size_t hlen = strlen(h); + if (hlen % 2) { printf("hex length must be even\n"); return 1; } + static uint8_t payload[1400]; + size_t plen = hlen / 2; + if (plen > sizeof(payload)) { printf("payload too long\n"); return 1; } + for (size_t i = 0; i < plen; i++) { + int hi = hexnib(h[2 * i]), lo = hexnib(h[2 * i + 1]); + if (hi < 0 || lo < 0) { printf("bad hex\n"); return 1; } + payload[i] = (uint8_t)((hi << 4) | lo); + } + uint8_t buf[1500]; + int n = awdl_build_data(buf, sizeof(buf), &s_self, AWDL_BROADCAST, payload, plen); + if (n <= 0) { printf("build failed: %d\n", n); return 1; } + int err = radio_tx(buf, n); + printf("txdata len=%d: %s\n", n, err == 0 ? "ok" : "err"); + return 0; +} + +static int cmd_peers(int argc, char **argv) { + (void)argc; (void)argv; + radio_peer_event_t ev; + int n = 0; + while (radio_poll_peer_event(&ev)) { + n++; + printf("peer %02x:%02x:%02x:%02x:%02x:%02x %-4s rssi %d ch %u name '%s'\n", + ev.addr[0], ev.addr[1], ev.addr[2], ev.addr[3], ev.addr[4], ev.addr[5], + ev.subtype == AWDL_SUBTYPE_MIF ? "MIF" : "PSF", + ev.rssi, ev.channel, ev.has_name ? ev.name : "?"); + if (ev.has_chanseq) { + printf(" chanseq:"); + for (int i = 0; i < AWDL_CHANSEQ_LEN; i++) printf(" %u", ev.chanseq[i]); + printf("\n"); + } + printf(" rx_ts=%" PRIu32 " us esp_timer=%lld us\n", + ev.rx_timestamp, (long long)ev.esp_timer_us); + } + if (n == 0) printf("(no peer events queued)\n"); + return 0; +} + +static int cmd_stats(int argc, char **argv) { + (void)argc; (void)argv; + radio_stats_t s; + radio_get_stats(&s); + printf("rx: action=%llu data=%llu other_bssid=%llu parse_err=%llu\n", + s.rx_action, s.rx_data, s.rx_other_bssid, s.rx_parse_err); + printf("tx: ok=%llu err=%llu tx_done: ok=%llu fail=%llu\n", + s.tx_ok, s.tx_err, s.tx_done_ok, s.tx_done_fail); + return 0; +} + +static int cmd_reset(int argc, char **argv) { + (void)argc; (void)argv; + radio_reset_stats(); + printf("stats reset\n"); + return 0; +} + +static int cmd_chanlat(int argc, char **argv) { + int n = (argc >= 2) ? atoi(argv[1]) : 100; + if (n < 1) n = 1; + const uint8_t chans[] = {1, 6, 11}; + int64_t total = 0, worst = 0; + for (int i = 0; i < n; i++) { + uint8_t ch = chans[i % 3]; + int64_t t0 = esp_timer_get_time(); + esp_wifi_set_channel(ch, WIFI_SECOND_CHAN_NONE); + int64_t dt = esp_timer_get_time() - t0; + total += dt; + if (dt > worst) worst = dt; + } + radio_set_channel(radio_get_channel()); + printf("chanlat over %d hops: mean %lld us, worst %lld us\n", + n, (long long)(total / n), (long long)worst); + return 0; +} + +static int cmd_usbbench(int argc, char **argv) { + int kib = (argc >= 2) ? atoi(argv[1]) : 256; + if (kib < 1) kib = 1; + static char line[64]; + memset(line, 'A', sizeof(line) - 1); + line[sizeof(line) - 1] = '\n'; + int64_t t0 = esp_timer_get_time(); + long total = (long)kib * 1024; + long written = 0; + while (written < total) { + fwrite(line, 1, sizeof(line), stdout); + written += sizeof(line); + } + fflush(stdout); + int64_t dt = esp_timer_get_time() - t0; + /* device-side timing is a rough lower bound (TinyUSB buffers); time it on + * the host too, e.g. `time cat /dev/ttyACM0 > /dev/null` against this run. */ + printf("\nusbbench: %ld bytes in %lld us (device-side) = %.1f kB/s\n", + written, (long long)dt, dt ? written * 1000.0 / (double)dt : 0.0); + return 0; +} + +static void reg(const char *cmd, const char *help, esp_console_cmd_func_t fn) { + esp_console_cmd_t c = { .command = cmd, .help = help, .hint = NULL, .func = fn }; + esp_console_cmd_register(&c); +} + +void console_register_commands(void) { + refresh_self(); + reg("info", "show self MAC, channel, promiscuous state", cmd_info); + reg("mac", "mac - override station MAC", cmd_mac); + reg("chan", "chan <1..13> - set 2.4 GHz channel", cmd_chan); + reg("rx", "rx on|off - promiscuous AWDL receive", cmd_rx); + reg("tx", "tx psf|mif [count] - inject action frame(s)", cmd_tx); + reg("txdata", "txdata - inject one AWDL data frame", cmd_txdata); + reg("peers", "drain and print discovered-peer events", cmd_peers); + reg("stats", "print rx/tx counters", cmd_stats); + reg("reset", "zero the counters", cmd_reset); + reg("chanlat", "chanlat [n] - measure channel-switch latency", cmd_chanlat); + reg("usbbench", "usbbench [kib] - stream data for host-side USB timing", cmd_usbbench); +} diff --git a/firmware/spikes/main/radio.c b/firmware/spikes/main/radio.c new file mode 100644 index 0000000..98bde75 --- /dev/null +++ b/firmware/spikes/main/radio.c @@ -0,0 +1,197 @@ +/* + * EspDrop - ESP32 Wi-Fi radio glue for the AWDL spikes. + * + * SPDX-License-Identifier: GPL-3.0-or-later + * Copyright (C) 2026 EspDrop contributors + */ +#include "radio.h" + +#include + +#include "esp_log.h" +#include "esp_mac.h" +#include "esp_timer.h" +#include "esp_wifi.h" +#include "nvs_flash.h" +#include "freertos/FreeRTOS.h" +#include "freertos/queue.h" + +static const char *TAG = "radio"; + +static radio_stats_t s_stats; +static portMUX_TYPE s_stats_mux = portMUX_INITIALIZER_UNLOCKED; +static QueueHandle_t s_peer_q; /* radio_peer_event_t */ +static volatile bool s_promisc; +static uint8_t s_channel = 6; + +#define PEER_Q_LEN 16 + +static inline void stat_inc(uint64_t *field) { + portENTER_CRITICAL(&s_stats_mux); + (*field)++; + portEXIT_CRITICAL(&s_stats_mux); +} + +/* ---- 80211 tx-done callback: records the driver's reported send status ---- */ +static void IRAM_ATTR tx_done_cb(const esp_80211_tx_info_t *info) { + if (!info) return; + if (info->tx_status == WIFI_SEND_SUCCESS) + stat_inc(&s_stats.tx_done_ok); + else + stat_inc(&s_stats.tx_done_fail); +} + +/* ---- promiscuous rx callback: filter AWDL BSSID, parse, enqueue ---- */ +static void rx_cb(void *buf, wifi_promiscuous_pkt_type_t type) { + if (type != WIFI_PKT_MGMT && type != WIFI_PKT_DATA) return; + + const wifi_promiscuous_pkt_t *pkt = (const wifi_promiscuous_pkt_t *)buf; + int len = pkt->rx_ctrl.sig_len; + if (len < (int)(IEEE80211_HDR_LEN + 4)) return; + len -= 4; /* strip trailing FCS */ + const uint8_t *frame = pkt->payload; + + /* addr3 (BSSID) at offset 16 must be the AWDL BSSID */ + if (memcmp(frame + 16, AWDL_BSSID, 6) != 0) { + stat_inc(&s_stats.rx_other_bssid); + return; + } + + if (type == WIFI_PKT_MGMT) { + radio_peer_event_t ev; + awdl_peer_view_t pv; + int r = awdl_parse_action(frame, (size_t)len, &pv); + if (r != AWDL_OK) { stat_inc(&s_stats.rx_parse_err); return; } + stat_inc(&s_stats.rx_action); + + memset(&ev, 0, sizeof(ev)); + memcpy(ev.addr, pv.src_addr, 6); + ev.rssi = pkt->rx_ctrl.rssi; + ev.channel = pkt->rx_ctrl.channel; + ev.subtype = pv.subtype; + ev.has_name = pv.has_name; + if (pv.has_name) memcpy(ev.name, pv.name, sizeof(ev.name)); + ev.has_chanseq = pv.has_chanseq; + if (pv.has_chanseq) memcpy(ev.chanseq, pv.chanseq, sizeof(ev.chanseq)); + ev.rx_timestamp = pkt->rx_ctrl.timestamp; + ev.esp_timer_us = esp_timer_get_time(); + + if (s_peer_q) { + BaseType_t hp = pdFALSE; + /* drop oldest on overflow so the newest always gets through */ + if (xQueueSendFromISR(s_peer_q, &ev, &hp) != pdTRUE) { + radio_peer_event_t drop; + xQueueReceiveFromISR(s_peer_q, &drop, &hp); + xQueueSendFromISR(s_peer_q, &ev, &hp); + } + if (hp) portYIELD_FROM_ISR(); + } + } else { /* WIFI_PKT_DATA */ + size_t poff; uint16_t et; + if (awdl_parse_data(frame, (size_t)len, &poff, &et)) + stat_inc(&s_stats.rx_data); + } +} + +/* ------------------------------------------------------------------------ */ + +int radio_init(uint8_t channel) { + esp_err_t err = nvs_flash_init(); + if (err == ESP_ERR_NVS_NO_FREE_PAGES || err == ESP_ERR_NVS_NEW_VERSION_FOUND) { + nvs_flash_erase(); + err = nvs_flash_init(); + } + if (err != ESP_OK) return err; + + s_peer_q = xQueueCreate(PEER_Q_LEN, sizeof(radio_peer_event_t)); + if (!s_peer_q) return ESP_ERR_NO_MEM; + + wifi_init_config_t cfg = WIFI_INIT_CONFIG_DEFAULT(); + if ((err = esp_wifi_init(&cfg)) != ESP_OK) return err; + if ((err = esp_wifi_set_storage(WIFI_STORAGE_RAM)) != ESP_OK) return err; + if ((err = esp_wifi_set_mode(WIFI_MODE_STA)) != ESP_OK) return err; + if ((err = esp_wifi_start()) != ESP_OK) return err; + + /* no power save: we must stay on-channel to hear/inject AWDL */ + esp_wifi_set_ps(WIFI_PS_NONE); + esp_wifi_register_80211_tx_cb(tx_done_cb); + + s_channel = channel; + esp_wifi_set_channel(channel, WIFI_SECOND_CHAN_NONE); + + uint8_t mac[6]; + esp_wifi_get_mac(WIFI_IF_STA, mac); + ESP_LOGI(TAG, "ready on ch %u, STA MAC %02x:%02x:%02x:%02x:%02x:%02x", + channel, mac[0], mac[1], mac[2], mac[3], mac[4], mac[5]); + return ESP_OK; +} + +void radio_get_self_mac(uint8_t out[6]) { + esp_wifi_get_mac(WIFI_IF_STA, out); +} + +int radio_set_self_mac(const uint8_t mac[6]) { + if (mac[0] & 0x01) return ESP_ERR_INVALID_ARG; /* must be unicast */ + esp_err_t err; + bool was_promisc = s_promisc; + if (was_promisc) radio_promisc(false); + if ((err = esp_wifi_stop()) != ESP_OK) return err; + if ((err = esp_wifi_set_mac(WIFI_IF_STA, mac)) != ESP_OK) { esp_wifi_start(); return err; } + if ((err = esp_wifi_start()) != ESP_OK) return err; + esp_wifi_set_ps(WIFI_PS_NONE); + esp_wifi_set_channel(s_channel, WIFI_SECOND_CHAN_NONE); + if (was_promisc) radio_promisc(true); + return ESP_OK; +} + +int radio_set_channel(uint8_t channel) { + esp_err_t err = esp_wifi_set_channel(channel, WIFI_SECOND_CHAN_NONE); + if (err == ESP_OK) s_channel = channel; + return err; +} + +uint8_t radio_get_channel(void) { return s_channel; } + +int radio_tx(const uint8_t *buf, int len) { + esp_err_t err = esp_wifi_80211_tx(WIFI_IF_STA, buf, len, false /* keep our seq */); + if (err == ESP_OK) stat_inc(&s_stats.tx_ok); + else stat_inc(&s_stats.tx_err); + return err; +} + +int radio_promisc(bool on) { + esp_err_t err; + if (on) { + wifi_promiscuous_filter_t f = { + .filter_mask = WIFI_PROMIS_FILTER_MASK_MGMT | WIFI_PROMIS_FILTER_MASK_DATA, + }; + esp_wifi_set_promiscuous_filter(&f); + esp_wifi_set_promiscuous_rx_cb(rx_cb); + if ((err = esp_wifi_set_promiscuous(true)) != ESP_OK) return err; + /* promiscuous resets the channel; reapply */ + esp_wifi_set_channel(s_channel, WIFI_SECOND_CHAN_NONE); + } else { + if ((err = esp_wifi_set_promiscuous(false)) != ESP_OK) return err; + } + s_promisc = on; + return ESP_OK; +} + +bool radio_promisc_enabled(void) { return s_promisc; } + +void radio_get_stats(radio_stats_t *out) { + portENTER_CRITICAL(&s_stats_mux); + *out = s_stats; + portEXIT_CRITICAL(&s_stats_mux); +} + +void radio_reset_stats(void) { + portENTER_CRITICAL(&s_stats_mux); + memset(&s_stats, 0, sizeof(s_stats)); + portEXIT_CRITICAL(&s_stats_mux); +} + +bool radio_poll_peer_event(radio_peer_event_t *ev) { + if (!s_peer_q) return false; + return xQueueReceive(s_peer_q, ev, 0) == pdTRUE; +} diff --git a/firmware/spikes/main/radio.h b/firmware/spikes/main/radio.h new file mode 100644 index 0000000..db84f9a --- /dev/null +++ b/firmware/spikes/main/radio.h @@ -0,0 +1,89 @@ +/* + * EspDrop - ESP32 Wi-Fi radio glue for the AWDL spikes. + * + * Thin layer over ESP-IDF esp_wifi: raw 802.11 transmit, promiscuous receive + * filtered to the AWDL BSSID, and the counters/peer events the console prints. + * This is the ONLY place that talks to ESP-IDF; awdl_frame.* stays portable. + * + * SPDX-License-Identifier: GPL-3.0-or-later + * Copyright (C) 2026 EspDrop contributors + */ +#ifndef ESPDROP_RADIO_H +#define ESPDROP_RADIO_H + +#include +#include + +#include "awdl_frame.h" + +#ifdef __cplusplus +extern "C" { +#endif + +typedef struct { + uint64_t rx_action; /* AWDL action frames received (BSSID match) */ + uint64_t rx_data; /* AWDL data frames received */ + uint64_t rx_other_bssid;/* frames seen in promiscuous with a different BSSID */ + uint64_t rx_parse_err; /* AWDL-looking frames that failed to parse */ + uint64_t tx_ok; /* esp_wifi_80211_tx calls that returned ESP_OK */ + uint64_t tx_err; /* esp_wifi_80211_tx calls that returned an error */ + uint64_t tx_done_ok; /* 80211 tx-done callbacks with status SUCCESS */ + uint64_t tx_done_fail; /* 80211 tx-done callbacks with status FAIL */ +} radio_stats_t; + +/* A discovered peer, snapshotted from a received action frame. */ +typedef struct { + uint8_t addr[6]; + int8_t rssi; + uint8_t channel; /* channel the frame was received on */ + uint8_t subtype; /* PSF / MIF */ + bool has_name; + char name[AWDL_NAME_MAX + 1]; + bool has_chanseq; + uint8_t chanseq[AWDL_CHANSEQ_LEN]; + uint32_t rx_timestamp; /* rx_ctrl.timestamp (us), hardware receive time */ + int64_t esp_timer_us; /* esp_timer_get_time() at callback entry */ +} radio_peer_event_t; + +/* + * Bring up Wi-Fi in station mode without connecting, on the given 2.4 GHz + * channel. Safe to call once at startup. Returns an esp_err_t (0 == ESP_OK). + */ +int radio_init(uint8_t channel); + +/* Our AWDL self address = the station interface MAC. Copies 6 bytes out. */ +void radio_get_self_mac(uint8_t out[6]); + +/* + * Override the station MAC (for the ACK experiment: AWDL-addr == iface MAC vs + * not). Stops and restarts Wi-Fi internally. bit0 of mac[0] must be 0. + * Returns esp_err_t. + */ +int radio_set_self_mac(const uint8_t mac[6]); + +/* Set the 2.4 GHz channel (1..13). Returns esp_err_t. */ +int radio_set_channel(uint8_t channel); +uint8_t radio_get_channel(void); + +/* Inject a raw 802.11 frame (MAC header .. no FCS). Returns esp_err_t. */ +int radio_tx(const uint8_t *buf, int len); + +/* Enable/disable promiscuous receive with the AWDL action+data callback. */ +int radio_promisc(bool on); +bool radio_promisc_enabled(void); + +/* Copy current counters out. */ +void radio_get_stats(radio_stats_t *out); +void radio_reset_stats(void); + +/* + * Pop the next discovered-peer event, if any, without blocking. + * Returns true and fills *ev if one was queued, false otherwise. + */ +bool radio_poll_peer_event(radio_peer_event_t *ev); + +#ifdef __cplusplus +} +#endif + +#endif /* ESPDROP_RADIO_H */ diff --git a/firmware/spikes/sdkconfig.defaults b/firmware/spikes/sdkconfig.defaults new file mode 100644 index 0000000..98f8a7d --- /dev/null +++ b/firmware/spikes/sdkconfig.defaults @@ -0,0 +1,20 @@ +# EspDrop AWDL spikes - build-time defaults (checked in; sdkconfig is generated) + +# Console over the built-in USB-Serial/JTAG (no external USB-UART bridge needed) +CONFIG_ESP_CONSOLE_USB_SERIAL_JTAG=y +CONFIG_ESP_CONSOLE_SECONDARY_NONE=y + +# 1 kHz tick: AWDL availability windows are 16 TU (~16 ms); a 10 ms tick is too +# coarse to schedule against them. Needed from M1 on; harmless now. +CONFIG_FREERTOS_HZ=1000 + +# Give the Wi-Fi driver static RX/TX buffers (promiscuous RX is bursty). +CONFIG_ESP_WIFI_STATIC_RX_BUFFER_NUM=16 +CONFIG_ESP_WIFI_DYNAMIC_RX_BUFFER_NUM=32 +CONFIG_ESP_WIFI_DYNAMIC_TX_BUFFER_NUM=32 + +# A little more log headroom while bringing the spikes up. +CONFIG_LOG_DEFAULT_LEVEL_INFO=y + +# Main task stack: console command handlers build 1.5 KB frames on-stack. +CONFIG_ESP_MAIN_TASK_STACK_SIZE=6144 diff --git a/tools/usb_throughput.py b/tools/usb_throughput.py new file mode 100755 index 0000000..cdc3c5d --- /dev/null +++ b/tools/usb_throughput.py @@ -0,0 +1,52 @@ +#!/usr/bin/env python3 +# EspDrop - measure USB-Serial/JTAG throughput from the host side. +# +# Run this, then trigger `usbbench ` on the device console. It counts the +# bytes that arrive and reports the sustained rate. Rough but honest: it is the +# host actually reading the CDC endpoint, not the device guessing. +# +# Usage: tools/usb_throughput.py /dev/ttyACM0 [seconds] +# +# SPDX-License-Identifier: GPL-3.0-or-later +# Copyright (C) 2026 EspDrop contributors + +import os +import sys +import time + + +def main() -> int: + if len(sys.argv) < 2: + print("usage: usb_throughput.py [seconds]", file=sys.stderr) + return 2 + port = sys.argv[1] + window = float(sys.argv[2]) if len(sys.argv) > 2 else 5.0 + + try: + fd = os.open(port, os.O_RDONLY | os.O_NOCTTY) + except OSError as e: + print(f"cannot open {port}: {e}", file=sys.stderr) + return 1 + + print(f"reading {port} for {window:.0f}s; trigger `usbbench` on the device now ...") + total = 0 + t0 = time.monotonic() + try: + while time.monotonic() - t0 < window: + try: + chunk = os.read(fd, 65536) + except BlockingIOError: + continue + if chunk: + total += len(chunk) + finally: + os.close(fd) + + dt = time.monotonic() - t0 + rate = total / dt / 1000.0 if dt > 0 else 0.0 + print(f"{total} bytes in {dt:.2f}s = {rate:.1f} kB/s") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) From 86b756c62b241d1f647bfa49f948f5b4eeeeeb07 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 14 Oct 2026 12:44:52 +0000 Subject: [PATCH 06/99] spikes: use internal esp_wifi_set_tx_done_cb for IDF < 5.5 compatibility Die oeffentliche esp_wifi_register_80211_tx_cb / esp_80211_tx_info_t API kam erst in ESP-IDF 5.5; auf aelteren Installationen schlug der Build fehl (unknown type esp_80211_tx_info_t). Die interne esp_wifi_set_tx_done_cb aus esp_private/wifi.h gibt es mit identischer Signatur in 5.1..6.x und liefert den txStatus, den der ACK-Test braucht. Keine Versionsabhaengigkeit mehr. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01XzgaN4Bv1m8Ka9iqo7QVUN --- firmware/spikes/main/radio.c | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/firmware/spikes/main/radio.c b/firmware/spikes/main/radio.c index 98bde75..fac8956 100644 --- a/firmware/spikes/main/radio.c +++ b/firmware/spikes/main/radio.c @@ -12,6 +12,7 @@ #include "esp_mac.h" #include "esp_timer.h" #include "esp_wifi.h" +#include "esp_private/wifi.h" /* esp_wifi_set_tx_done_cb: stable across IDF 5.1..6.x */ #include "nvs_flash.h" #include "freertos/FreeRTOS.h" #include "freertos/queue.h" @@ -32,10 +33,16 @@ static inline void stat_inc(uint64_t *field) { portEXIT_CRITICAL(&s_stats_mux); } -/* ---- 80211 tx-done callback: records the driver's reported send status ---- */ -static void IRAM_ATTR tx_done_cb(const esp_80211_tx_info_t *info) { - if (!info) return; - if (info->tx_status == WIFI_SEND_SUCCESS) +/* ---- tx-done callback: records the driver's reported send status ---- + * txStatus is true when the frame left the MAC successfully. For a unicast + * frame to a real peer that is the signal the ACK experiment cares about + * (did the peer acknowledge); for broadcast it is always success. Uses the + * internal esp_wifi_set_tx_done_cb API, which is present and identical on + * ESP-IDF 5.1 through 6.x (the public esp_wifi_register_80211_tx_cb only + * appeared in 5.5). */ +static void tx_done_cb(uint8_t ifidx, uint8_t *data, uint16_t *data_len, bool txStatus) { + (void)ifidx; (void)data; (void)data_len; + if (txStatus) stat_inc(&s_stats.tx_done_ok); else stat_inc(&s_stats.tx_done_fail); @@ -114,7 +121,7 @@ int radio_init(uint8_t channel) { /* no power save: we must stay on-channel to hear/inject AWDL */ esp_wifi_set_ps(WIFI_PS_NONE); - esp_wifi_register_80211_tx_cb(tx_done_cb); + esp_wifi_set_tx_done_cb(tx_done_cb); s_channel = channel; esp_wifi_set_channel(channel, WIFI_SECOND_CHAN_NONE); From faf88aa5fb507bdaac8aa2c0cfb62b89c73201d2 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 14 Oct 2026 15:05:40 +0000 Subject: [PATCH 07/99] spikes: create default event loop before Wi-Fi init Ohne Default-Event-Loop scheitert das Posten der WLAN-Events (E wifi:failed to post WiFi event ... ret=259 = ESP_ERR_INVALID_STATE). esp_netif_init() + esp_event_loop_create_default() in radio_init ergaenzt; esp_event/esp_netif zu REQUIRES. Behebt die Fehlermeldungen beim Start. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01XzgaN4Bv1m8Ka9iqo7QVUN --- firmware/spikes/main/CMakeLists.txt | 2 +- firmware/spikes/main/radio.c | 10 ++++++++++ 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/firmware/spikes/main/CMakeLists.txt b/firmware/spikes/main/CMakeLists.txt index 3058485..2e8359f 100644 --- a/firmware/spikes/main/CMakeLists.txt +++ b/firmware/spikes/main/CMakeLists.txt @@ -1,5 +1,5 @@ idf_component_register( SRCS "app_main.c" "radio.c" "console.c" INCLUDE_DIRS "." - REQUIRES awdl esp_wifi nvs_flash console esp_timer + REQUIRES awdl esp_wifi esp_event esp_netif nvs_flash console esp_timer ) diff --git a/firmware/spikes/main/radio.c b/firmware/spikes/main/radio.c index fac8956..f2c5a2e 100644 --- a/firmware/spikes/main/radio.c +++ b/firmware/spikes/main/radio.c @@ -8,8 +8,10 @@ #include +#include "esp_event.h" #include "esp_log.h" #include "esp_mac.h" +#include "esp_netif.h" #include "esp_timer.h" #include "esp_wifi.h" #include "esp_private/wifi.h" /* esp_wifi_set_tx_done_cb: stable across IDF 5.1..6.x */ @@ -110,6 +112,14 @@ int radio_init(uint8_t channel) { } if (err != ESP_OK) return err; + /* The Wi-Fi driver posts its events (STA_START etc.) to the default event + * loop. Without it they fail with ESP_ERR_INVALID_STATE ("failed to post + * WiFi event"). esp_netif is not strictly needed for raw/promiscuous use + * but is cheap and required once we add an IP stack later. */ + if ((err = esp_netif_init()) != ESP_OK) return err; + err = esp_event_loop_create_default(); + if (err != ESP_OK && err != ESP_ERR_INVALID_STATE) return err; /* already created: fine */ + s_peer_q = xQueueCreate(PEER_Q_LEN, sizeof(radio_peer_event_t)); if (!s_peer_q) return ESP_ERR_NO_MEM; From 27831a596ee627d9bca77d8255698ed0f70e9904 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 14 Oct 2026 17:02:43 +0000 Subject: [PATCH 08/99] tools: Spike 3 AWDL unicast injector (scapy) Sendet von der Monitor-Karte des Laptops Unicast-AWDL-Datenframes an eine Ziel-MAC (den C3), byte-genau so aufgebaut wie awdl_parse_data der Firmware sie erwartet. So klaert Spike 3: ACKt der C3 Frames an seine eigene MAC (parallele Aufzeichnung) und liefert er sie an den Promiscuous-Callback (C3-Zaehler rx_data). Variante A/B und die Pruefschritte stehen im Modul-Docstring. --dry-run baut/hexdumpt ohne scapy/root. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01XzgaN4Bv1m8Ka9iqo7QVUN --- tools/awdl_inject.py | 166 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 166 insertions(+) create mode 100755 tools/awdl_inject.py diff --git a/tools/awdl_inject.py b/tools/awdl_inject.py new file mode 100755 index 0000000..e1f3de2 --- /dev/null +++ b/tools/awdl_inject.py @@ -0,0 +1,166 @@ +#!/usr/bin/env python3 +# EspDrop - Spike 3 injector: send unicast AWDL data frames to a target MAC. +# +# The question Spike 3 answers (see docs/SPIKES.md): when a unicast 802.11 frame +# is addressed to the ESP32-C3's own station MAC, does the C3 +# (a) ACK it in hardware (SIFS later), and +# (b) still deliver it to its promiscuous receive callback? +# A forum report claims some ESP-IDF versions ACK such frames but swallow them +# before the sniffer sees them. This tool lets you measure both on real hardware. +# +# It runs on the LAPTOP's monitor-mode Wi-Fi card (the same one you capture with) +# and injects frames over the air to the C3. The frame is built byte-for-byte as +# the C3 firmware's awdl_parse_data() expects (non-QoS data, AWDL LLC/SNAP, AWDL +# data header, IPv6 ethertype), so a delivered frame increments the C3's `rx_data` +# counter - that is the delivery signal. The ACK is read from a parallel capture. +# +# Usage (as root; monitor iface already up on channel 6): +# sudo tools/awdl_inject.py wlan0mon --target 8c:d0:b2:bb:ca:57 --count 50 +# sudo tools/awdl_inject.py wlan0mon --target 8c:d0:b2:bb:ca:57 --dry-run # just hexdump, no scapy/root +# +# Experiment design: +# Variant A (AWDL addr == C3 iface MAC, the default case): +# - On the C3: `info` to read the STA MAC; leave it as is; `reset`; `rx on`. +# - Run this with --target . +# - C3 `stats`: did `rx_data` rise? (delivery) +# - Parallel capture on ch6: is there an ACK with RA == C3 MAC after each +# injected frame? (hardware ACK) +# Variant B (AWDL addr != C3 iface MAC): +# - On the C3: `mac aa:bb:cc:dd:ee:01` (some other addr), `reset`, `rx on`. +# - Run this with --target . +# - Expect: frames delivered to the callback, but NO hardware ACK. +# +# The authoritative ACK check is a parallel capture, because a card rarely sees +# the ACKs to its own injected frames. In another terminal: +# sudo tcpdump -i wlan0mon -ne 'wlan[0] == 0xd4' # ACK control frames +# and watch for RA == the C3 MAC. --listen here is a best-effort convenience. +# +# SPDX-License-Identifier: GPL-3.0-or-later +# Copyright (C) 2026 EspDrop contributors + +import argparse +import struct +import sys +import time + +AWDL_BSSID = bytes.fromhex("002500ff9473") +AWDL_OUI = bytes.fromhex("0017f2") +AWDL_LLC_PID = 0x0800 +AWDL_DATA_HEAD = 0x0403 +ETHERTYPE_IPV6 = 0x86DD + + +def parse_mac(s: str) -> bytes: + parts = s.replace("-", ":").split(":") + if len(parts) != 6: + raise argparse.ArgumentTypeError(f"bad MAC: {s}") + try: + return bytes(int(p, 16) for p in parts) + except ValueError: + raise argparse.ArgumentTypeError(f"bad MAC: {s}") + + +def build_awdl_data_frame(target: bytes, src: bytes, seq: int, payload: bytes) -> bytes: + """A non-QoS AWDL data frame (MAC header .. payload), no radiotap, no FCS. + + Mirrors the firmware's awdl_build_data()/awdl_parse_data() exactly so that a + frame delivered to the C3 increments its `rx_data` counter. + """ + f = bytearray() + # 802.11 non-QoS data header, ToDS=0 FromDS=0 + f += struct.pack("H", AWDL_LLC_PID) + # AWDL data header + f += struct.pack("H", ETHERTYPE_IPV6) + f += payload + return bytes(f) + + +def hexdump(b: bytes) -> str: + out = [] + for i in range(0, len(b), 16): + row = b[i:i + 16] + out.append(" ".join(f"{x:02x}" for x in row)) + return "\n".join(out) + + +def main() -> int: + ap = argparse.ArgumentParser(description="EspDrop Spike 3 AWDL unicast injector") + ap.add_argument("iface", help="monitor-mode interface, e.g. wlan0mon") + ap.add_argument("--target", type=parse_mac, help="destination MAC (the C3)") + ap.add_argument("--src", type=parse_mac, default=parse_mac("02:45:53:50:44:52"), + help="source MAC (default locally-administered 02:ESPDR)") + ap.add_argument("--count", type=int, default=20, help="frames to send") + ap.add_argument("--interval", type=float, default=0.2, help="seconds between frames") + ap.add_argument("--payload-len", type=int, default=16, help="dummy payload bytes") + ap.add_argument("--listen", action="store_true", + help="best-effort: also sniff for ACKs with RA == target") + ap.add_argument("--dry-run", action="store_true", + help="build and hexdump one frame, do not send (no scapy/root needed)") + args = ap.parse_args() + + if not args.target and not args.dry_run: + ap.error("--target is required unless --dry-run") + + target = args.target or parse_mac("8c:d0:b2:bb:ca:57") + payload = bytes([0x11]) * args.payload_len + frame0 = build_awdl_data_frame(target, args.src, 0, payload) + + if args.dry_run: + print(f"frame length {len(frame0)} bytes (MAC header .. payload, no FCS):") + print(hexdump(frame0)) + print("\ndry-run only; re-run without --dry-run (as root) to transmit.") + return 0 + + # scapy is only needed for actual transmit/sniff. + try: + from scapy.all import RadioTap, Raw, sendp, AsyncSniffer, Dot11 + except ImportError: + print("scapy not installed. Try: pip install --user scapy", file=sys.stderr) + return 1 + + tgt_str = ":".join(f"{x:02x}" for x in target) + + sniffer = None + acks = {"n": 0} + if args.listen: + def is_ack_to_target(pkt): + return (pkt.haslayer(Dot11) and pkt.type == 1 and pkt.subtype == 13 + and pkt.addr1 and pkt.addr1.lower() == tgt_str) + def on_pkt(pkt): + if is_ack_to_target(pkt): + acks["n"] += 1 + sniffer = AsyncSniffer(iface=args.iface, prn=on_pkt, store=False) + sniffer.start() + time.sleep(0.2) + + print(f"injecting {args.count} AWDL data frames to {tgt_str} on {args.iface} " + f"({len(frame0)} bytes each)") + for i in range(args.count): + frame = build_awdl_data_frame(target, args.src, i, payload) + sendp(RadioTap() / Raw(load=frame), iface=args.iface, verbose=False) + if args.interval: + time.sleep(args.interval) + + if sniffer: + time.sleep(0.3) + sniffer.stop() + print(f"saw {acks['n']} ACK(s) addressed to {tgt_str} on this card " + f"(best-effort; trust a parallel capture for the real count)") + + print("done. Now read `stats` on the C3: did rx_data rise? " + "And check the capture for ACKs with RA == the target.") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) From 170ef6848c7032ff4027e2425633c3fe3265ab80 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 14 Oct 2026 17:35:00 +0000 Subject: [PATCH 09/99] tools: fix ACK-check guidance in injector (RA is the sender, not the C3) Ein 802.11-ACK traegt nur eine Adresse (RA), und das ist der Absender des bestaetigten Frames, also unsere --src, nicht die MAC des C3. Die Doku und der --listen-Filter pruefen jetzt RA == --src. Spike 3 damit eindeutig: Zustellung ja (rx_data) und HW-ACK ja (ACK mit RA == --src). Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01XzgaN4Bv1m8Ka9iqo7QVUN --- tools/awdl_inject.py | 32 +++++++++++++++++++++----------- 1 file changed, 21 insertions(+), 11 deletions(-) diff --git a/tools/awdl_inject.py b/tools/awdl_inject.py index e1f3de2..49659cf 100755 --- a/tools/awdl_inject.py +++ b/tools/awdl_inject.py @@ -23,17 +23,23 @@ # - On the C3: `info` to read the STA MAC; leave it as is; `reset`; `rx on`. # - Run this with --target . # - C3 `stats`: did `rx_data` rise? (delivery) -# - Parallel capture on ch6: is there an ACK with RA == C3 MAC after each -# injected frame? (hardware ACK) +# - Parallel capture on ch6: is there an ACK after each injected frame? +# NOTE: an 802.11 ACK carries only one address (RA), and it is the +# TRANSMITTER of the acknowledged frame, i.e. OUR --src, not the C3's MAC. +# So the C3's ACK comes back addressed to --src. (hardware ACK) # Variant B (AWDL addr != C3 iface MAC): # - On the C3: `mac aa:bb:cc:dd:ee:01` (some other addr), `reset`, `rx on`. # - Run this with --target . # - Expect: frames delivered to the callback, but NO hardware ACK. # # The authoritative ACK check is a parallel capture, because a card rarely sees -# the ACKs to its own injected frames. In another terminal: -# sudo tcpdump -i wlan0mon -ne 'wlan[0] == 0xd4' # ACK control frames -# and watch for RA == the C3 MAC. --listen here is a best-effort convenience. +# the ACKs to its own injected frames. In another terminal, filter on the ACK's +# RA == our --src MAC (an ACK names the sender it acknowledges, not the C3): +# sudo tcpdump -i wlan0mon -ne 'wlan[0] == 0xd4 and wlan addr1 02:45:53:50:44:52' +# A Wireshark display filter does the same: +# wlan.fc.type_subtype == 0x1d && wlan.ra == 02:45:53:50:44:52 +# Control: aim --target at a MAC no device owns; those ACKs must then stop. +# --listen here is a best-effort convenience. # # SPDX-License-Identifier: GPL-3.0-or-later # Copyright (C) 2026 EspDrop contributors @@ -129,15 +135,19 @@ def main() -> int: return 1 tgt_str = ":".join(f"{x:02x}" for x in target) + src_str = ":".join(f"{x:02x}" for x in args.src) sniffer = None acks = {"n": 0} if args.listen: - def is_ack_to_target(pkt): + # The C3's ACK for our frame is addressed to the frame's transmitter + # (our --src), not to the C3 itself: an ACK carries only an RA, set to + # the sender being acknowledged. + def is_ack_for_us(pkt): return (pkt.haslayer(Dot11) and pkt.type == 1 and pkt.subtype == 13 - and pkt.addr1 and pkt.addr1.lower() == tgt_str) + and pkt.addr1 and pkt.addr1.lower() == src_str) def on_pkt(pkt): - if is_ack_to_target(pkt): + if is_ack_for_us(pkt): acks["n"] += 1 sniffer = AsyncSniffer(iface=args.iface, prn=on_pkt, store=False) sniffer.start() @@ -154,11 +164,11 @@ def on_pkt(pkt): if sniffer: time.sleep(0.3) sniffer.stop() - print(f"saw {acks['n']} ACK(s) addressed to {tgt_str} on this card " + print(f"saw {acks['n']} ACK(s) with RA == {src_str} on this card " f"(best-effort; trust a parallel capture for the real count)") - print("done. Now read `stats` on the C3: did rx_data rise? " - "And check the capture for ACKs with RA == the target.") + print(f"done. Now read `stats` on the C3: did rx_data rise? " + f"And check the capture for ACKs with RA == {src_str} (our --src).") return 0 From cfc865a2c0599af2f1738796309e5b2f294d663b Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 14 Oct 2026 17:40:04 +0000 Subject: [PATCH 10/99] tools: self-triggering, gap-timed USB throughput meter usb_throughput.py kann den usbbench-Befehl jetzt selbst an den C3 senden (--trigger KIB) und misst gap-basiert (Uhr startet beim ersten Byte, stoppt nach einer Pause), statt ein festes Fenster zu mitteln. Macht Spike 5 zu einem Host-seitigen Einzelbefehl. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01XzgaN4Bv1m8Ka9iqo7QVUN --- tools/usb_throughput.py | 89 ++++++++++++++++++++++++++++------------- 1 file changed, 62 insertions(+), 27 deletions(-) diff --git a/tools/usb_throughput.py b/tools/usb_throughput.py index cdc3c5d..d6c9ec2 100755 --- a/tools/usb_throughput.py +++ b/tools/usb_throughput.py @@ -1,50 +1,85 @@ #!/usr/bin/env python3 # EspDrop - measure USB-Serial/JTAG throughput from the host side. # -# Run this, then trigger `usbbench ` on the device console. It counts the -# bytes that arrive and reports the sustained rate. Rough but honest: it is the -# host actually reading the CDC endpoint, not the device guessing. +# Two ways to use it: +# 1. Self-triggering (recommended): the tool sends `usbbench ` to the +# device itself, then times the bytes that come back. Close `idf.py monitor` +# first - only one program may read the port at a time. +# sudo tools/usb_throughput.py /dev/ttyACM0 --trigger 2048 +# 2. Passive: just time whatever arrives in a window; trigger `usbbench` by +# hand in a monitor on another machine. +# tools/usb_throughput.py /dev/ttyACM0 --seconds 10 # -# Usage: tools/usb_throughput.py /dev/ttyACM0 [seconds] +# Timing is gap-based: the clock starts at the first byte and stops after a +# 0.5 s lull, so the rate reflects the actual burst, not idle wait time. # # SPDX-License-Identifier: GPL-3.0-or-later # Copyright (C) 2026 EspDrop contributors +import argparse import os import sys +import termios import time +import tty def main() -> int: - if len(sys.argv) < 2: - print("usage: usb_throughput.py [seconds]", file=sys.stderr) - return 2 - port = sys.argv[1] - window = float(sys.argv[2]) if len(sys.argv) > 2 else 5.0 + ap = argparse.ArgumentParser(description="EspDrop USB-Serial/JTAG throughput meter") + ap.add_argument("port", help="serial device, e.g. /dev/ttyACM0") + ap.add_argument("--trigger", type=int, metavar="KIB", + help="send `usbbench ` to the device before timing " + "(close idf.py monitor first)") + ap.add_argument("--seconds", type=float, default=12.0, + help="maximum time to wait/read (default 12)") + ap.add_argument("--gap", type=float, default=0.5, + help="end the measurement after this long with no data (default 0.5)") + args = ap.parse_args() + flags = os.O_RDWR if args.trigger else os.O_RDONLY try: - fd = os.open(port, os.O_RDONLY | os.O_NOCTTY) + fd = os.open(args.port, flags | os.O_NOCTTY | os.O_NONBLOCK) except OSError as e: - print(f"cannot open {port}: {e}", file=sys.stderr) + print(f"cannot open {args.port}: {e}", file=sys.stderr) return 1 - print(f"reading {port} for {window:.0f}s; trigger `usbbench` on the device now ...") - total = 0 - t0 = time.monotonic() + # Raw mode so the command and the binary stream pass through untouched. try: - while time.monotonic() - t0 < window: - try: - chunk = os.read(fd, 65536) - except BlockingIOError: - continue - if chunk: - total += len(chunk) - finally: - os.close(fd) - - dt = time.monotonic() - t0 - rate = total / dt / 1000.0 if dt > 0 else 0.0 - print(f"{total} bytes in {dt:.2f}s = {rate:.1f} kB/s") + tty.setraw(fd) + except termios.error: + pass # not a tty (e.g. a pipe in testing): fine + + if args.trigger: + os.write(fd, f"usbbench {args.trigger}\r\n".encode()) + + print(f"timing {args.port} (up to {args.seconds:.0f}s, " + f"stop after {args.gap:.1f}s idle) ...") + total = 0 + deadline = time.monotonic() + args.seconds + t_first = None + t_last = None + while time.monotonic() < deadline: + try: + chunk = os.read(fd, 65536) + except BlockingIOError: + if t_last is not None and time.monotonic() - t_last > args.gap: + break + time.sleep(0.005) + continue + if chunk: + now = time.monotonic() + if t_first is None: + t_first = now + t_last = now + total += len(chunk) + os.close(fd) + + if t_first is None or t_last is None or t_last == t_first: + print(f"{total} bytes; not enough data to time a rate " + f"(did you trigger `usbbench`? is a monitor still holding the port?)") + return 0 + dt = t_last - t_first + print(f"{total} bytes in {dt:.2f}s = {total / dt / 1000.0:.1f} kB/s") return 0 From 8333651b0c8cd3ec740c89346aafe7f3e0ffc78e Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 14 Oct 2026 17:59:09 +0000 Subject: [PATCH 11/99] awdl: M1 step 1 - sync math, election, peer table, extended parser Plattformunabhaengige Portierung aus OWL (GPL-3.0), host-getestet: - awdl_sync.{h,c}: AWDL-Zeitmathematik (AW/EAW, Phasenfehler, Nachfuehren auf den Master) aus src/sync.c. - awdl_election.{h,c}: Election-Zustand und Vergleiche aus src/election.c. - awdl_peers.{h,c}: feste Peer-Tabelle ohne dynamischen Speicher plus der Election-Lauf ueber das Array. - Parser zieht jetzt auch Sync-Params (tx_down_counter, aw_counter) und die vollen Election-Felder (v1 und v2) heraus. Host-Test erweitert (Sync-Params/Election durchs Parsen, Sync-Mathematik, Election-Lauf inkl. Alterung) - alle gruen; C3-Build sauber (ESP-IDF 6.0.3). Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01XzgaN4Bv1m8Ka9iqo7QVUN --- .../spikes/components/awdl/CMakeLists.txt | 2 +- .../spikes/components/awdl/awdl_election.c | 35 ++++++ firmware/spikes/components/awdl/awdl_frame.c | 35 +++++- firmware/spikes/components/awdl/awdl_peers.c | 118 ++++++++++++++++++ firmware/spikes/components/awdl/awdl_sync.c | 54 ++++++++ .../components/awdl/include/awdl_election.h | 56 +++++++++ .../components/awdl/include/awdl_frame.h | 10 ++ .../components/awdl/include/awdl_peers.h | 92 ++++++++++++++ .../components/awdl/include/awdl_sync.h | 59 +++++++++ .../spikes/components/awdl/test/host/Makefile | 6 +- .../awdl/test/host/test_awdl_frame.c | 94 ++++++++++++++ 11 files changed, 554 insertions(+), 7 deletions(-) create mode 100644 firmware/spikes/components/awdl/awdl_election.c create mode 100644 firmware/spikes/components/awdl/awdl_peers.c create mode 100644 firmware/spikes/components/awdl/awdl_sync.c create mode 100644 firmware/spikes/components/awdl/include/awdl_election.h create mode 100644 firmware/spikes/components/awdl/include/awdl_peers.h create mode 100644 firmware/spikes/components/awdl/include/awdl_sync.h diff --git a/firmware/spikes/components/awdl/CMakeLists.txt b/firmware/spikes/components/awdl/CMakeLists.txt index 1f8f6dc..3d259db 100644 --- a/firmware/spikes/components/awdl/CMakeLists.txt +++ b/firmware/spikes/components/awdl/CMakeLists.txt @@ -1,4 +1,4 @@ idf_component_register( - SRCS "awdl_frame.c" + SRCS "awdl_frame.c" "awdl_sync.c" "awdl_election.c" "awdl_peers.c" INCLUDE_DIRS "include" ) diff --git a/firmware/spikes/components/awdl/awdl_election.c b/firmware/spikes/components/awdl/awdl_election.c new file mode 100644 index 0000000..1ff58c9 --- /dev/null +++ b/firmware/spikes/components/awdl/awdl_election.c @@ -0,0 +1,35 @@ +/* + * EspDrop - AWDL master election state (platform independent). + * + * See awdl_election.h. Ported from OWL src/election.c (GPL-3.0). The run loop + * is in awdl_peers.c. + * + * SPDX-License-Identifier: GPL-3.0-or-later + * Copyright (C) 2026 EspDrop contributors + */ +#include "awdl_election.h" + +#include + +int awdl_addr_cmp(const uint8_t a[6], const uint8_t b[6]) { + return memcmp(a, b, 6); +} + +void awdl_election_reset_self(awdl_election_t *e) { + e->height = 0; + memcpy(e->master_addr, e->self_addr, 6); + memcpy(e->sync_addr, e->self_addr, 6); + e->master_metric = e->self_metric; + e->master_counter = e->self_counter; +} + +void awdl_election_init(awdl_election_t *e, const uint8_t self[6]) { + memcpy(e->self_addr, self, 6); + e->self_counter = AWDL_ELECTION_COUNTER_INIT; + e->self_metric = AWDL_ELECTION_METRIC_INIT; + awdl_election_reset_self(e); +} + +bool awdl_election_is_sync_master(const awdl_election_t *e, const uint8_t addr[6]) { + return awdl_addr_cmp(e->sync_addr, addr) == 0; +} diff --git a/firmware/spikes/components/awdl/awdl_frame.c b/firmware/spikes/components/awdl/awdl_frame.c index 3b835dd..78fc023 100644 --- a/firmware/spikes/components/awdl/awdl_frame.c +++ b/firmware/spikes/components/awdl/awdl_frame.c @@ -394,16 +394,43 @@ int awdl_parse_action(const uint8_t *buf, size_t len, awdl_peer_view_t *out) { wire_get_u8(v, l, 1, &out->devclass)) out->has_version = true; break; + case AWDL_TLV_SYNC_PARAMS: + /* sync-params TLV value: tx_down_counter at offset 1, + * next_aw_seq (the AW counter) at offset 29, both LE16. */ + if (wire_get_le16(v, l, 1, &out->sync_tx_down_tu) && + wire_get_le16(v, l, 29, &out->sync_aw_counter)) + out->has_sync = true; + break; case AWDL_TLV_ELECTION_PARAMS_V2: - if (wire_get_bytes(v, l, 0, out->master_addr, 6)) { + /* v2 layout: master_addr@0, sync_addr@6, master_counter@12, + * distance_to_master@16, master_metric@20, self_metric@24, + * self_counter@36 (all LE32 except the addresses). */ + if (wire_get_bytes(v, l, 0, out->master_addr, 6) && + wire_get_bytes(v, l, 6, out->sync_addr, 6) && + wire_get_le32(v, l, 12, &out->el_master_counter) && + wire_get_le32(v, l, 16, &out->el_height) && + wire_get_le32(v, l, 20, &out->el_master_metric) && + wire_get_le32(v, l, 24, &out->el_self_metric) && + wire_get_le32(v, l, 36, &out->el_self_counter)) { out->has_election = true; election_v2 = true; } break; case AWDL_TLV_ELECTION_PARAMS: - /* v1: top_master_addr at offset 5; ignored if v2 already seen */ - if (!election_v2 && wire_get_bytes(v, l, 5, out->master_addr, 6)) - out->has_election = true; + /* v1 (no counters): distancetop@3, top_master_addr@5, + * top_master_metric@11, self_metric@15. Ignored if v2 seen. */ + if (!election_v2) { + uint8_t dist = 0; + if (wire_get_u8(v, l, 3, &dist) && + wire_get_bytes(v, l, 5, out->master_addr, 6) && + wire_get_le32(v, l, 11, &out->el_master_metric) && + wire_get_le32(v, l, 15, &out->el_self_metric)) { + out->el_height = dist; + out->el_master_counter = 0; + out->el_self_counter = 0; + out->has_election = true; + } + } break; default: break; diff --git a/firmware/spikes/components/awdl/awdl_peers.c b/firmware/spikes/components/awdl/awdl_peers.c new file mode 100644 index 0000000..e0bcd91 --- /dev/null +++ b/firmware/spikes/components/awdl/awdl_peers.c @@ -0,0 +1,118 @@ +/* + * EspDrop - AWDL peer table and election run loop (platform independent). + * + * See awdl_peers.h. Election logic ported from OWL src/election.c + * awdl_election_run() (GPL-3.0), over a fixed array instead of OWL's hashmap. + * + * SPDX-License-Identifier: GPL-3.0-or-later + * Copyright (C) 2026 EspDrop contributors + */ +#include "awdl_peers.h" + +#include + +void awdl_peers_init(awdl_peer_table_t *t) { + memset(t, 0, sizeof(*t)); + t->timeout_us = AWDL_PEER_TIMEOUT_US; + t->clean_interval_us = AWDL_PEER_CLEAN_INTERVAL_US; +} + +awdl_peer_t *awdl_peers_get(awdl_peer_table_t *t, const uint8_t addr[6]) { + for (int i = 0; i < AWDL_MAX_PEERS; i++) + if (t->peers[i].used && memcmp(t->peers[i].addr, addr, 6) == 0) + return &t->peers[i]; + return NULL; +} + +awdl_peer_t *awdl_peers_add(awdl_peer_table_t *t, const uint8_t addr[6], + uint64_t now_us, bool *created) { + if (created) *created = false; + + awdl_peer_t *p = awdl_peers_get(t, addr); + if (p) { + p->last_update_us = now_us; + return p; + } + for (int i = 0; i < AWDL_MAX_PEERS; i++) { + if (!t->peers[i].used) { + p = &t->peers[i]; + memset(p, 0, sizeof(*p)); + p->used = true; + memcpy(p->addr, addr, 6); + p->last_update_us = now_us; + awdl_election_init(&p->election, addr); + p->is_valid = true; + if (created) *created = true; + return p; + } + } + return NULL; /* table full */ +} + +int awdl_peers_count(const awdl_peer_table_t *t) { + int n = 0; + for (int i = 0; i < AWDL_MAX_PEERS; i++) + if (t->peers[i].used) n++; + return n; +} + +int awdl_peers_age(awdl_peer_table_t *t, uint64_t cutoff_us) { + int removed = 0; + for (int i = 0; i < AWDL_MAX_PEERS; i++) { + if (t->peers[i].used && t->peers[i].last_update_us < cutoff_us) { + t->peers[i].used = false; + removed++; + } + } + return removed; +} + +static int cmp_u32(uint32_t a, uint32_t b) { + return (a < b) ? -1 : (a > b); +} + +/* Compare two election states by (master_counter, master_metric). */ +static int election_cmp_master(const awdl_election_t *a, const awdl_election_t *b) { + int r = cmp_u32(a->master_counter, b->master_counter); + if (!r) r = cmp_u32(a->master_metric, b->master_metric); + return r; +} + +void awdl_election_run(awdl_election_t *self, const awdl_peer_table_t *t) { + awdl_election_reset_self(self); + const awdl_election_t *master = self; + + for (int i = 0; i < AWDL_MAX_PEERS; i++) { + if (!t->peers[i].used) continue; + const awdl_peer_t *peer = &t->peers[i]; + const awdl_election_t *ps = &peer->election; + + if (!peer->is_valid) + continue; + if (ps->height + 1 > AWDL_ELECTION_TREE_MAX_HEIGHT) + continue; /* sync tree would get too tall */ + if (awdl_election_is_sync_master(ps, self->self_addr)) + continue; /* would create a cycle */ + + int cmp = election_cmp_master(ps, master); + if (cmp < 0) { + continue; /* weaker top master */ + } else if (cmp == 0) { + if (ps->height > master->height) { + continue; /* would lengthen the sync tree */ + } else if (ps->height == master->height) { + if (awdl_addr_cmp(ps->self_addr, master->self_addr) <= 0) + continue; /* tie-break: peer has the smaller address */ + } + } + master = ps; /* accept */ + } + + if (master != self) { + memcpy(self->master_addr, master->master_addr, 6); + memcpy(self->sync_addr, master->self_addr, 6); + self->master_metric = master->master_metric; + self->master_counter = master->master_counter; + self->height = master->height + 1; + } +} diff --git a/firmware/spikes/components/awdl/awdl_sync.c b/firmware/spikes/components/awdl/awdl_sync.c new file mode 100644 index 0000000..e9bdaed --- /dev/null +++ b/firmware/spikes/components/awdl/awdl_sync.c @@ -0,0 +1,54 @@ +/* + * EspDrop - AWDL synchronization math (platform independent). + * + * See awdl_sync.h. Ported from OWL src/sync.c (GPL-3.0). + * + * SPDX-License-Identifier: GPL-3.0-or-later + * Copyright (C) 2026 EspDrop contributors + */ +#include "awdl_sync.h" + +static inline uint64_t tu_to_us(uint64_t tu) { return tu * AWDL_TU_US; } +static inline uint64_t us_to_tu(uint64_t us) { return us / AWDL_TU_US; } + +void awdl_sync_init(awdl_sync_t *s, uint64_t now_us) { + s->last_update_us = now_us; + s->aw_counter = 0; + s->aw_period_tu = AWDL_AW_PERIOD_TU; + s->presence_mode = AWDL_PRESENCE_MODE; +} + +uint16_t awdl_sync_next_aw_tu(uint64_t now_us, const awdl_sync_t *s) { + uint64_t eaw_period = (uint64_t)s->presence_mode * s->aw_period_tu; + uint64_t since = us_to_tu(now_us - s->last_update_us); + return (uint16_t)(eaw_period - (since % eaw_period)); +} + +uint16_t awdl_sync_current_aw(uint64_t now_us, const awdl_sync_t *s) { + uint64_t eaw_period = (uint64_t)s->presence_mode * s->aw_period_tu; + uint64_t since = us_to_tu(now_us - s->last_update_us); + uint64_t aw = (uint64_t)s->aw_counter + + (since % eaw_period) / s->aw_period_tu + + (uint64_t)s->presence_mode * (since / eaw_period); + return (uint16_t)aw; +} + +uint16_t awdl_sync_current_eaw(uint64_t now_us, const awdl_sync_t *s) { + return (uint16_t)(awdl_sync_current_aw(now_us, s) / s->presence_mode); +} + +int64_t awdl_sync_error_tu(uint64_t now_us, uint16_t time_to_next_aw, + uint16_t aw_counter, const awdl_sync_t *s) { + int64_t eaw_diff = (int64_t)(aw_counter / s->presence_mode) - + (int64_t)awdl_sync_current_eaw(now_us, s); + int64_t aw_term = eaw_diff * (int64_t)s->presence_mode * (int64_t)s->aw_period_tu; + int64_t phase = (int64_t)time_to_next_aw - (int64_t)awdl_sync_next_aw_tu(now_us, s); + return aw_term - phase; +} + +void awdl_sync_update(uint64_t now_us, uint16_t time_to_next_aw, + uint16_t aw_counter, awdl_sync_t *s) { + uint64_t eaw_period = (uint64_t)s->presence_mode * s->aw_period_tu; + s->last_update_us = now_us - tu_to_us(eaw_period - time_to_next_aw); + s->aw_counter = aw_counter & 0xfffc; /* round down to EAW boundary (presence_mode=4) */ +} diff --git a/firmware/spikes/components/awdl/include/awdl_election.h b/firmware/spikes/components/awdl/include/awdl_election.h new file mode 100644 index 0000000..1bc28e4 --- /dev/null +++ b/firmware/spikes/components/awdl/include/awdl_election.h @@ -0,0 +1,56 @@ +/* + * EspDrop - AWDL master election (platform independent). + * + * Ported from OWL src/election.{c,h} (GPL-3.0). Each node advertises an + * election state; a node adopts as its sync master whichever peer has the + * better (master_counter, master_metric), breaking ties by lower sync-tree + * height and then by MAC address. OWL's defaults keep our own metric low so a + * real Apple device always wins and we slave to it. + * + * The run loop that iterates the peer table lives in awdl_peers.c to avoid a + * circular dependency; this file holds the per-node state and comparisons. + * + * SPDX-License-Identifier: GPL-3.0-or-later + * Copyright (C) 2026 EspDrop contributors + */ +#ifndef ESPDROP_AWDL_ELECTION_H +#define ESPDROP_AWDL_ELECTION_H + +#include +#include + +#include "awdl_frame.h" /* AWDL_ELECTION_METRIC_INIT / _COUNTER_INIT (single source) */ + +#ifdef __cplusplus +extern "C" { +#endif + +#define AWDL_ELECTION_TREE_MAX_HEIGHT 10 + +typedef struct { + uint8_t master_addr[6]; + uint8_t sync_addr[6]; + uint8_t self_addr[6]; + uint32_t height; + uint32_t master_metric; + uint32_t self_metric; + uint32_t master_counter; + uint32_t self_counter; +} awdl_election_t; + +/* memcmp on 6 bytes; <0, 0, >0 like memcmp. */ +int awdl_addr_cmp(const uint8_t a[6], const uint8_t b[6]); + +void awdl_election_init(awdl_election_t *e, const uint8_t self[6]); + +/* True if addr is the node this election state currently syncs to. */ +bool awdl_election_is_sync_master(const awdl_election_t *e, const uint8_t addr[6]); + +/* Reset self/master fields to "we are our own master" (used before a run). */ +void awdl_election_reset_self(awdl_election_t *e); + +#ifdef __cplusplus +} +#endif + +#endif /* ESPDROP_AWDL_ELECTION_H */ diff --git a/firmware/spikes/components/awdl/include/awdl_frame.h b/firmware/spikes/components/awdl/include/awdl_frame.h index f1c48a3..0cc6b4f 100644 --- a/firmware/spikes/components/awdl/include/awdl_frame.h +++ b/firmware/spikes/components/awdl/include/awdl_frame.h @@ -158,6 +158,16 @@ typedef struct { bool has_election; uint8_t master_addr[6]; /* currently elected master (v2 if present, else v1) */ + uint8_t sync_addr[6]; /* v2 only: the node the peer syncs to (else zeroed) */ + uint32_t el_height; /* distance to master (sync-tree height) */ + uint32_t el_master_metric; + uint32_t el_master_counter; /* 0 for v1 (no counter field) */ + uint32_t el_self_metric; + uint32_t el_self_counter; /* 0 for v1 */ + + bool has_sync; /* synchronization-parameters TLV present */ + uint16_t sync_tx_down_tu; /* TU until the sender's next AW (tx_down_counter) */ + uint16_t sync_aw_counter; /* the sender's current AW counter (next_aw_seq) */ } awdl_peer_view_t; /* ------------------------------------------------------------------------ */ diff --git a/firmware/spikes/components/awdl/include/awdl_peers.h b/firmware/spikes/components/awdl/include/awdl_peers.h new file mode 100644 index 0000000..3915372 --- /dev/null +++ b/firmware/spikes/components/awdl/include/awdl_peers.h @@ -0,0 +1,92 @@ +/* + * EspDrop - AWDL peer table and master election run loop (platform independent). + * + * A fixed-size table (no dynamic allocation, so it is safe on the ESP32 and + * testable on a host). Trimmed from OWL src/peers.c: we keep the fields the + * sync/election/scheduler need and drop the hashmap and neighbour-table + * plumbing. Election iterates this table, so awdl_election_run lives here. + * + * SPDX-License-Identifier: GPL-3.0-or-later + * Copyright (C) 2026 EspDrop contributors + */ +#ifndef ESPDROP_AWDL_PEERS_H +#define ESPDROP_AWDL_PEERS_H + +#include +#include + +#include "awdl_frame.h" /* AWDL_CHANSEQ_LEN, AWDL_NAME_MAX */ +#include "awdl_election.h" + +#ifdef __cplusplus +extern "C" { +#endif + +#ifndef AWDL_MAX_PEERS +#define AWDL_MAX_PEERS 16 +#endif + +#define AWDL_PEER_TIMEOUT_US 2000000 /* evict a peer unseen this long */ +#define AWDL_PEER_CLEAN_INTERVAL_US 1000000 /* how often to run the cleaner */ + +typedef struct { + bool used; + uint8_t addr[6]; + uint64_t last_update_us; + + bool has_name; + char name[AWDL_NAME_MAX + 1]; + + bool has_version; + uint8_t version; + uint8_t devclass; + + bool has_chanseq; + uint8_t chanseq_enc; + uint8_t chanseq[AWDL_CHANSEQ_LEN]; /* decoded channel numbers, 0 = absent */ + + awdl_election_t election; + bool supports_v2; + + bool has_sync_offset; + int64_t sync_offset_us; /* our AW phase -> this peer's; +ve = peer ahead */ + + bool is_valid; /* eligible to be elected master */ +} awdl_peer_t; + +typedef struct { + awdl_peer_t peers[AWDL_MAX_PEERS]; + uint64_t timeout_us; + uint64_t clean_interval_us; +} awdl_peer_table_t; + +void awdl_peers_init(awdl_peer_table_t *t); + +/* Look up a peer by address, or NULL. */ +awdl_peer_t *awdl_peers_get(awdl_peer_table_t *t, const uint8_t addr[6]); + +/* + * Get the peer for addr, creating it if new (returns NULL only if the table is + * full). Sets last_update_us = now and, on creation, initialises election and + * marks the peer valid. *created is set true if a new entry was made. + */ +awdl_peer_t *awdl_peers_add(awdl_peer_table_t *t, const uint8_t addr[6], + uint64_t now_us, bool *created); + +int awdl_peers_count(const awdl_peer_table_t *t); + +/* Remove peers whose last_update_us is older than cutoff_us. Returns count removed. */ +int awdl_peers_age(awdl_peer_table_t *t, uint64_t cutoff_us); + +/* + * Run the election: pick our sync master among self and the valid peers, by + * (master_counter, master_metric), then lower height, then larger address. + * Updates *self in place (sync_addr/master_addr/height/...). + */ +void awdl_election_run(awdl_election_t *self, const awdl_peer_table_t *t); + +#ifdef __cplusplus +} +#endif + +#endif /* ESPDROP_AWDL_PEERS_H */ diff --git a/firmware/spikes/components/awdl/include/awdl_sync.h b/firmware/spikes/components/awdl/include/awdl_sync.h new file mode 100644 index 0000000..f865be9 --- /dev/null +++ b/firmware/spikes/components/awdl/include/awdl_sync.h @@ -0,0 +1,59 @@ +/* + * EspDrop - AWDL synchronization math (platform independent). + * + * Ported field-for-field from OWL src/sync.{c,h} (The Open Wireless Link + * Project / Milan Stute, GPL-3.0). AWDL time is counted in availability windows + * (AW, 16 TU) grouped into extended availability windows (EAW = presence_mode + * AWs). One TU is 1024 microseconds. + * + * All times are microseconds on a single monotonic clock (on the ESP32 that is + * esp_timer_get_time()); there is no wall-clock dependency, so this is testable + * on a host with made-up timestamps. + * + * SPDX-License-Identifier: GPL-3.0-or-later + * Copyright (C) 2026 EspDrop contributors + */ +#ifndef ESPDROP_AWDL_SYNC_H +#define ESPDROP_AWDL_SYNC_H + +#include + +#include "awdl_frame.h" /* AWDL_TU_US, AWDL_AW_PERIOD_TU, AWDL_PRESENCE_MODE */ + +#ifdef __cplusplus +extern "C" { +#endif + +typedef struct { + uint64_t last_update_us; /* our estimate of when AW counter `aw_counter` began */ + uint16_t aw_counter; /* AW index at last_update_us */ + uint16_t aw_period_tu; /* AW length in TU (16) */ + uint8_t presence_mode; /* AWs per EAW (4) */ +} awdl_sync_t; + +void awdl_sync_init(awdl_sync_t *s, uint64_t now_us); + +/* TU until the next AW boundary. */ +uint16_t awdl_sync_next_aw_tu(uint64_t now_us, const awdl_sync_t *s); + +/* Current AW index and EAW (slot) index. */ +uint16_t awdl_sync_current_aw(uint64_t now_us, const awdl_sync_t *s); +uint16_t awdl_sync_current_eaw(uint64_t now_us, const awdl_sync_t *s); + +/* + * Phase error in TU between a master's announced timing (time_to_next_aw and + * aw_counter, as carried in its synchronization-parameters TLV) and ours. + * Zero means we are in sync; the sign tells which way we are off. + */ +int64_t awdl_sync_error_tu(uint64_t now_us, uint16_t time_to_next_aw, + uint16_t aw_counter, const awdl_sync_t *s); + +/* Adopt a master's announced timing as our own AW clock. */ +void awdl_sync_update(uint64_t now_us, uint16_t time_to_next_aw, + uint16_t aw_counter, awdl_sync_t *s); + +#ifdef __cplusplus +} +#endif + +#endif /* ESPDROP_AWDL_SYNC_H */ diff --git a/firmware/spikes/components/awdl/test/host/Makefile b/firmware/spikes/components/awdl/test/host/Makefile index 42e0983..49a27d3 100644 --- a/firmware/spikes/components/awdl/test/host/Makefile +++ b/firmware/spikes/components/awdl/test/host/Makefile @@ -8,13 +8,15 @@ CFLAGS ?= -std=c11 -Wall -Wextra -Werror -O2 -g COMP = ../.. INCLUDE = -I$(COMP)/include -SRCS = $(COMP)/awdl_frame.c test_awdl_frame.c +SRCS = $(COMP)/awdl_frame.c $(COMP)/awdl_sync.c $(COMP)/awdl_election.c \ + $(COMP)/awdl_peers.c test_awdl_frame.c BIN = test_awdl_frame +HEADERS = $(wildcard $(COMP)/include/*.h) .PHONY: all run clean all: run -$(BIN): $(SRCS) $(COMP)/include/awdl_frame.h $(COMP)/include/awdl_wire.h +$(BIN): $(SRCS) $(HEADERS) $(CC) $(CFLAGS) $(INCLUDE) $(SRCS) -o $(BIN) run: $(BIN) diff --git a/firmware/spikes/components/awdl/test/host/test_awdl_frame.c b/firmware/spikes/components/awdl/test/host/test_awdl_frame.c index c5043c6..f0d56ef 100644 --- a/firmware/spikes/components/awdl/test/host/test_awdl_frame.c +++ b/firmware/spikes/components/awdl/test/host/test_awdl_frame.c @@ -12,8 +12,11 @@ * Copyright (C) 2026 EspDrop contributors */ #include "awdl_frame.h" +#include "awdl_sync.h" +#include "awdl_peers.h" #include +#include #include static int g_fail; @@ -127,11 +130,102 @@ static void test_reject_garbage(void) { CHECK(awdl_parse_action(junk, 8, &pv) == AWDL_ERR_SHORT, "short frame not SHORT"); } +static void test_sync_election_parse(void) { + uint8_t self_mac[6] = {0x02, 0xaa, 0xbb, 0xcc, 0xdd, 0xee}; + awdl_self_t self; + awdl_self_init(&self, self_mac, "espdrop-c3", 6); + + uint8_t buf[1500]; + int n = awdl_build_action(buf, sizeof(buf), &self, AWDL_SUBTYPE_MIF, 0 /* now_us */); + CHECK(n > 0, "build failed: %d", n); + + awdl_peer_view_t pv; + CHECK(awdl_parse_action(buf, (size_t)n, &pv) == AWDL_OK, "parse"); + + /* now_us=0: next AW is a full EAW away (64 TU), AW counter 0 */ + CHECK(pv.has_sync, "no sync params parsed"); + CHECK(pv.sync_tx_down_tu == AWDL_EAW_TU, "tx_down %u != %u", pv.sync_tx_down_tu, AWDL_EAW_TU); + CHECK(pv.sync_aw_counter == 0, "aw_counter %u", pv.sync_aw_counter); + + /* election: we are our own master with OWL's init metric, counter 0 */ + CHECK(pv.has_election, "no election parsed"); + CHECK(memcmp(pv.master_addr, self_mac, 6) == 0, "master_addr mismatch"); + CHECK(pv.el_master_metric == AWDL_ELECTION_METRIC_INIT, "master_metric %u", pv.el_master_metric); + CHECK(pv.el_master_counter == 0, "master_counter %u", pv.el_master_counter); + CHECK(pv.el_height == 0, "height %u", pv.el_height); +} + +static void test_sync_math(void) { + awdl_sync_t s; + awdl_sync_init(&s, 0); + + const uint64_t T = 5 * AWDL_TU_US + 123; /* some arbitrary non-aligned time */ + + /* A perfectly in-phase master announcement yields zero error. */ + uint16_t ttna = awdl_sync_next_aw_tu(T, &s); + uint16_t awc = awdl_sync_current_aw(T, &s); + CHECK(awdl_sync_error_tu(T, ttna, awc, &s) == 0, "in-phase error not 0"); + + /* A master 3 TU ahead gives error -3. */ + CHECK(awdl_sync_error_tu(T, (uint16_t)(ttna + 3), awc, &s) == -3, + "off-phase error wrong: %lld", + (long long)awdl_sync_error_tu(T, (uint16_t)(ttna + 3), awc, &s)); + + /* update() adopts the announced phase exactly (TU<->us is exact). */ + uint16_t target = 20; + awdl_sync_update(T, target, awc, &s); + CHECK(awdl_sync_next_aw_tu(T, &s) == target, + "update did not adopt phase: %u != %u", awdl_sync_next_aw_tu(T, &s), target); +} + +static void set_peer_election(awdl_peer_t *p, uint32_t counter, uint32_t metric) { + p->election.master_counter = counter; + p->election.master_metric = metric; + memcpy(p->election.master_addr, p->addr, 6); /* peer advertises itself as top master */ + p->election.height = 0; +} + +static void test_election_run(void) { + uint8_t me[6] = {0x02, 0x00, 0x00, 0x00, 0x00, 0x01}; + uint8_t strong[6] = {0x02, 0x00, 0x00, 0x00, 0x00, 0x99}; + uint8_t weak[6] = {0x02, 0x00, 0x00, 0x00, 0x00, 0x02}; + + awdl_election_t self; + awdl_election_init(&self, me); /* self_metric = 60, counter 0 */ + + awdl_peer_table_t t; + awdl_peers_init(&t); + bool created; + + /* A weaker peer (metric 10 < our 60) must not win. */ + awdl_peer_t *pw = awdl_peers_add(&t, weak, 1000, &created); + set_peer_election(pw, 0, 10); + awdl_election_run(&self, &t); + CHECK(awdl_election_is_sync_master(&self, me), "weak peer wrongly won"); + CHECK(self.height == 0, "height should stay 0, got %u", self.height); + + /* A stronger peer (metric 1000 > 60) must become our master. */ + awdl_peer_t *ps = awdl_peers_add(&t, strong, 1000, &created); + set_peer_election(ps, 0, 1000); + awdl_election_run(&self, &t); + CHECK(awdl_election_is_sync_master(&self, strong), "strong peer did not win"); + CHECK(self.height == 1, "height should be 1, got %u", self.height); + CHECK(self.master_metric == 1000, "master_metric %u", self.master_metric); + + /* Ageing with a cutoff past both peers empties the table. */ + CHECK(awdl_peers_count(&t) == 2, "count %d", awdl_peers_count(&t)); + awdl_peers_age(&t, 2000); + CHECK(awdl_peers_count(&t) == 0, "ageing did not clear table"); +} + int main(void) { test_mif_roundtrip(); test_psf_roundtrip(); test_data_roundtrip(); test_reject_garbage(); + test_sync_election_parse(); + test_sync_math(); + test_election_run(); if (g_fail == 0) printf("\nALL TESTS PASSED\n"); else printf("\n%d CHECK(S) FAILED\n", g_fail); From 9b57ee14dbe20fa1c0009eff5a79cd1839f19c96 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 14 Oct 2026 18:17:37 +0000 Subject: [PATCH 12/99] awdl: fix two defects found by port verification Aus dem adversarialen Review der OWL-Portierung: - tlv_end schrieb die Laengen-Rueckpatchung ungeprueft und bedingungslos; bei zu kleinem cap ein Out-of-Bounds-Write + size_t-Unterlauf. tlv_end nimmt jetzt cap und ist ein No-op, wenn der TL-Header nicht passte. Damit haelt awdl_build_action den 'schreibt nie ueber cap'-Vertrag ein. - Der Parser schrieb Election-Felder direkt in *out; ein laengen-verkuerztes v2-TLV konnte bereits geparste v1-Daten teilweise ueberschreiben und galt trotzdem als gueltig. Sync-/v1-/v2-Faelle lesen jetzt in lokale Variablen und uebernehmen erst bei vollstaendigem Erfolg (Frames kommen aus der Luft). Regressionstests ergaenzt (cap-Respekt mit Canary, verkuerztes v2 ohne Clobber); Host-Tests inkl. ASan/UBSan gruen, C3-Build sauber. Sync- und Election-Module wurden als korrekt bestaetigt. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01XzgaN4Bv1m8Ka9iqo7QVUN --- firmware/spikes/components/awdl/awdl_frame.c | 103 +++++++++++------- .../awdl/test/host/test_awdl_frame.c | 65 +++++++++++ 2 files changed, 131 insertions(+), 37 deletions(-) diff --git a/firmware/spikes/components/awdl/awdl_frame.c b/firmware/spikes/components/awdl/awdl_frame.c index 78fc023..93bf3fa 100644 --- a/firmware/spikes/components/awdl/awdl_frame.c +++ b/firmware/spikes/components/awdl/awdl_frame.c @@ -39,8 +39,14 @@ static bool tlv_begin(uint8_t *buf, size_t cap, size_t *off, size_t *hdr, uint8_ return wire_put_u8(buf, cap, off, type) && wire_put_le16(buf, cap, off, 0); } -/* Finish a TLV: backpatch length = bytes written after the 3-byte TL header. */ -static void tlv_end(uint8_t *buf, size_t *off, size_t hdr) { +/* Finish a TLV: backpatch length = bytes written after the 3-byte TL header. + * No-op when the 3-byte TL header did not fully fit (hdr+3 > cap): in that case + * tlv_begin's writes already failed and *off never advanced, so there is + * nothing to patch and buf[hdr+1/2] would be out of bounds. This keeps the + * "never writes past cap" contract and avoids a size_t underflow in body. */ +static void tlv_end(uint8_t *buf, size_t cap, size_t *off, size_t hdr) { + if (hdr + 3 > cap || *off < hdr + 3) + return; uint16_t body = (uint16_t)(*off - hdr - 3); buf[hdr + 1] = (uint8_t)(body & 0xff); buf[hdr + 2] = (uint8_t)((body >> 8) & 0xff); @@ -144,7 +150,7 @@ static bool put_tlv_sync_params(uint8_t *buf, size_t cap, size_t *off, ok &= wire_put_le16(buf, cap, off, cur_aw); /* ap_alignment */ ok &= put_chanseq(buf, cap, off, self); ok &= wire_put_le16(buf, cap, off, 0); /* padding */ - tlv_end(buf, off, hdr); + tlv_end(buf, cap, off, hdr); return ok; } @@ -160,7 +166,7 @@ static bool put_tlv_election_params(uint8_t *buf, size_t cap, size_t *off, ok &= wire_put_le32(buf, cap, off, AWDL_ELECTION_METRIC_INIT); /* top_master_metric */ ok &= wire_put_le32(buf, cap, off, AWDL_ELECTION_METRIC_INIT); /* self_metric */ ok &= wire_put_le16(buf, cap, off, 0); /* pad */ - tlv_end(buf, off, hdr); + tlv_end(buf, cap, off, hdr); return ok; } @@ -171,7 +177,7 @@ static bool put_tlv_chanseq(uint8_t *buf, size_t cap, size_t *off, const awdl_se ok &= wire_put_u8(buf, cap, off, 0); ok &= wire_put_u8(buf, cap, off, 0); ok &= wire_put_u8(buf, cap, off, 0); /* padding (3 bytes, as OWL) */ - tlv_end(buf, off, hdr); + tlv_end(buf, cap, off, hdr); return ok; } @@ -188,7 +194,7 @@ static bool put_tlv_election_params_v2(uint8_t *buf, size_t cap, size_t *off, ok &= wire_put_le32(buf, cap, off, 0); /* unknown */ ok &= wire_put_le32(buf, cap, off, 0); /* reserved */ ok &= wire_put_le32(buf, cap, off, 0); /* self_counter */ - tlv_end(buf, off, hdr); + tlv_end(buf, cap, off, hdr); return ok; } @@ -200,7 +206,7 @@ static bool put_tlv_service_params(uint8_t *buf, size_t cap, size_t *off) { ok &= wire_put_u8(buf, cap, off, 0); /* unknown[3] */ ok &= wire_put_le16(buf, cap, off, 0); /* sui */ ok &= wire_put_le32(buf, cap, off, 0); /* bitmask */ - tlv_end(buf, off, hdr); + tlv_end(buf, cap, off, hdr); return ok; } @@ -212,7 +218,7 @@ static bool put_tlv_ht_capabilities(uint8_t *buf, size_t cap, size_t *off) { ok &= wire_put_u8(buf, cap, off, 0x1b); /* ampdu_params */ ok &= wire_put_u8(buf, cap, off, 0xff); /* rx_mcs: one stream, all MCS */ ok &= wire_put_le16(buf, cap, off, 0); /* unknown2 */ - tlv_end(buf, off, hdr); + tlv_end(buf, cap, off, hdr); return ok; } @@ -224,7 +230,7 @@ static bool put_tlv_arpa(uint8_t *buf, size_t cap, size_t *off, const awdl_self_ ok &= wire_put_u8(buf, cap, off, (uint8_t)nlen); /* name_length */ ok &= wire_put_bytes(buf, cap, off, self->name, nlen); ok &= wire_put_be16(buf, cap, off, 0xc00c); /* compressed ".local" */ - tlv_end(buf, off, hdr); + tlv_end(buf, cap, off, hdr); return ok; } @@ -245,7 +251,7 @@ static bool put_tlv_data_path_state(uint8_t *buf, size_t cap, size_t *off, ok &= wire_put_le16(buf, cap, off, social); /* social_channels */ ok &= wire_put_bytes(buf, cap, off, self->self_addr, 6); /* awdl_addr */ ok &= wire_put_le16(buf, cap, off, 0); /* ext_flags */ - tlv_end(buf, off, hdr); + tlv_end(buf, cap, off, hdr); return ok; } @@ -254,7 +260,7 @@ static bool put_tlv_version(uint8_t *buf, size_t cap, size_t *off, const awdl_se bool ok = tlv_begin(buf, cap, off, &hdr, AWDL_TLV_VERSION); ok &= wire_put_u8(buf, cap, off, self->version); ok &= wire_put_u8(buf, cap, off, self->devclass); - tlv_end(buf, off, hdr); + tlv_end(buf, cap, off, hdr); return ok; } @@ -394,44 +400,67 @@ int awdl_parse_action(const uint8_t *buf, size_t len, awdl_peer_view_t *out) { wire_get_u8(v, l, 1, &out->devclass)) out->has_version = true; break; - case AWDL_TLV_SYNC_PARAMS: + case AWDL_TLV_SYNC_PARAMS: { /* sync-params TLV value: tx_down_counter at offset 1, - * next_aw_seq (the AW counter) at offset 29, both LE16. */ - if (wire_get_le16(v, l, 1, &out->sync_tx_down_tu) && - wire_get_le16(v, l, 29, &out->sync_aw_counter)) + * next_aw_seq (the AW counter) at offset 29, both LE16. + * Read into locals and commit only if both reads are in bounds, so + * a truncated TLV never leaves half-written state behind. */ + uint16_t txd, awc; + if (wire_get_le16(v, l, 1, &txd) && wire_get_le16(v, l, 29, &awc)) { + out->sync_tx_down_tu = txd; + out->sync_aw_counter = awc; out->has_sync = true; + } break; - case AWDL_TLV_ELECTION_PARAMS_V2: + } + case AWDL_TLV_ELECTION_PARAMS_V2: { /* v2 layout: master_addr@0, sync_addr@6, master_counter@12, * distance_to_master@16, master_metric@20, self_metric@24, - * self_counter@36 (all LE32 except the addresses). */ - if (wire_get_bytes(v, l, 0, out->master_addr, 6) && - wire_get_bytes(v, l, 6, out->sync_addr, 6) && - wire_get_le32(v, l, 12, &out->el_master_counter) && - wire_get_le32(v, l, 16, &out->el_height) && - wire_get_le32(v, l, 20, &out->el_master_metric) && - wire_get_le32(v, l, 24, &out->el_self_metric) && - wire_get_le32(v, l, 36, &out->el_self_counter)) { + * self_counter@36 (all LE32 except the addresses). Read into locals + * first: a length-truncated v2 TLV must NOT partially overwrite an + * already-parsed (v1 or earlier v2) election record. */ + uint8_t ma[6], sa[6]; + uint32_t mc, ht, mm, sm, sc; + if (wire_get_bytes(v, l, 0, ma, 6) && + wire_get_bytes(v, l, 6, sa, 6) && + wire_get_le32(v, l, 12, &mc) && + wire_get_le32(v, l, 16, &ht) && + wire_get_le32(v, l, 20, &mm) && + wire_get_le32(v, l, 24, &sm) && + wire_get_le32(v, l, 36, &sc)) { + memcpy(out->master_addr, ma, 6); + memcpy(out->sync_addr, sa, 6); + out->el_master_counter = mc; + out->el_height = ht; + out->el_master_metric = mm; + out->el_self_metric = sm; + out->el_self_counter = sc; out->has_election = true; election_v2 = true; } break; - case AWDL_TLV_ELECTION_PARAMS: + } + case AWDL_TLV_ELECTION_PARAMS: { /* v1 (no counters): distancetop@3, top_master_addr@5, - * top_master_metric@11, self_metric@15. Ignored if v2 seen. */ - if (!election_v2) { - uint8_t dist = 0; - if (wire_get_u8(v, l, 3, &dist) && - wire_get_bytes(v, l, 5, out->master_addr, 6) && - wire_get_le32(v, l, 11, &out->el_master_metric) && - wire_get_le32(v, l, 15, &out->el_self_metric)) { - out->el_height = dist; - out->el_master_counter = 0; - out->el_self_counter = 0; - out->has_election = true; - } + * top_master_metric@11, self_metric@15. Ignored if v2 seen. + * Commit only on full success (see v2). */ + uint8_t dist, ma[6]; + uint32_t mm, sm; + if (!election_v2 && + wire_get_u8(v, l, 3, &dist) && + wire_get_bytes(v, l, 5, ma, 6) && + wire_get_le32(v, l, 11, &mm) && + wire_get_le32(v, l, 15, &sm)) { + memcpy(out->master_addr, ma, 6); + out->el_height = dist; + out->el_master_metric = mm; + out->el_self_metric = sm; + out->el_master_counter = 0; + out->el_self_counter = 0; + out->has_election = true; } break; + } default: break; } diff --git a/firmware/spikes/components/awdl/test/host/test_awdl_frame.c b/firmware/spikes/components/awdl/test/host/test_awdl_frame.c index f0d56ef..b1aae77 100644 --- a/firmware/spikes/components/awdl/test/host/test_awdl_frame.c +++ b/firmware/spikes/components/awdl/test/host/test_awdl_frame.c @@ -218,6 +218,69 @@ static void test_election_run(void) { CHECK(awdl_peers_count(&t) == 0, "ageing did not clear table"); } +static void test_builder_respects_cap(void) { + /* A too-small cap must make awdl_build_action fail without writing past it. + * cap=40 lands exactly at a TLV start (24-byte MAC + 16-byte action header), + * which is the case that used to overrun via the length back-patch. */ + uint8_t self_mac[6] = {0x02, 1, 2, 3, 4, 5}; + awdl_self_t self; + awdl_self_init(&self, self_mac, "x", 6); + + uint8_t buf[80]; + memset(buf, 0xAB, sizeof(buf)); + for (size_t cap = 24; cap <= 48; cap++) { + int r = awdl_build_action(buf, cap, &self, AWDL_SUBTYPE_MIF, 0); + CHECK(r == AWDL_ERR_ARG, "cap=%zu should fail, got %d", cap, r); + /* nothing beyond cap may be touched */ + for (size_t i = cap; i < sizeof(buf); i++) + CHECK(buf[i] == 0xAB, "cap=%zu wrote past end at byte %zu (0x%02x)", + cap, i, buf[i]); + memset(buf, 0xAB, sizeof(buf)); /* restore canary */ + } +} + +static void test_truncated_v2_no_clobber(void) { + /* Hand-build an action frame with a well-formed v1 election TLV followed by + * a LENGTH-TRUNCATED v2 TLV (28 <= l < 40). The v2 must be rejected whole; + * the v1 master_addr must survive, and the record stays consistent. */ + uint8_t f[128]; + size_t o = 0; + memset(f, 0, sizeof(f)); + /* 802.11 header: FC=action, addr2 = sender */ + f[0] = 0xd0; f[1] = 0x00; + uint8_t sender[6] = {0x02, 0xAA, 0xAA, 0xAA, 0xAA, 0xAA}; + memcpy(f + 10, sender, 6); + memcpy(f + 16, AWDL_BSSID, 6); + o = 24; + /* AWDL action header */ + f[o++] = 127; f[o++] = AWDL_OUI[0]; f[o++] = AWDL_OUI[1]; f[o++] = AWDL_OUI[2]; + f[o++] = 8; f[o++] = 0x10; f[o++] = AWDL_SUBTYPE_MIF; f[o++] = 0; + for (int i = 0; i < 8; i++) f[o++] = 0; /* phy_tx, target_tx */ + + /* v1 election TLV (type 5), 21 value bytes; master_addr = V1MA at value off 5 */ + uint8_t v1ma[6] = {0x02, 0x11, 0x11, 0x11, 0x11, 0x11}; + f[o++] = AWDL_TLV_ELECTION_PARAMS; f[o++] = 21; f[o++] = 0; + size_t v1 = o; + for (int i = 0; i < 21; i++) f[o++] = 0; + memcpy(f + v1 + 5, v1ma, 6); /* top_master_addr */ + f[v1 + 11] = 0x2a; /* some metric LSB, != 0 */ + + /* v2 election TLV (type 24) but declared length 30 (< 40): reads at off 36 fail. + * Put a different master_addr (V2MA) at value off 0 to detect a clobber. */ + uint8_t v2ma[6] = {0x02, 0x22, 0x22, 0x22, 0x22, 0x22}; + f[o++] = AWDL_TLV_ELECTION_PARAMS_V2; f[o++] = 30; f[o++] = 0; + size_t v2 = o; + for (int i = 0; i < 30; i++) f[o++] = 0; + memcpy(f + v2 + 0, v2ma, 6); + + awdl_peer_view_t pv; + CHECK(awdl_parse_action(f, o, &pv) == AWDL_OK, "parse crafted frame"); + CHECK(pv.has_election, "v1 election should have been parsed"); + CHECK(memcmp(pv.master_addr, v1ma, 6) == 0, + "truncated v2 clobbered the v1 master_addr"); + CHECK(pv.el_self_counter == 0, "self_counter should remain v1's 0"); +} + int main(void) { test_mif_roundtrip(); test_psf_roundtrip(); @@ -226,6 +289,8 @@ int main(void) { test_sync_election_parse(); test_sync_math(); test_election_run(); + test_builder_respects_cap(); + test_truncated_v2_no_clobber(); if (g_fail == 0) printf("\nALL TESTS PASSED\n"); else printf("\n%d CHECK(S) FAILED\n", g_fail); From b6f1368a957292ddfb65a3d38bc141676be063e0 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 14 Oct 2026 19:10:29 +0000 Subject: [PATCH 13/99] awdl: M1 step 2 - node brain + scheduler, sync to a real master - awdl_node.{h,c}: plattformunabhaengiges Gehirn, das Self/Sync/Election/Peers verbindet. on_rx_action aktualisiert den Peer, faehrt die Election und fuehrt bei unserem Master die AW-Uhr nach; tick altert Peers und waehlt neu; build erzeugt PSF/MIF mit der synchronisierten Zeit. Host-Test prueft: iPhone wird Master, Sync-Fehler ~0 nach Adoption, Peer wird gehalten und altert korrekt. - awdl_build_action nimmt jetzt die Sync-Zeitwerte als Parameter (aus unserer Uhr) statt einer freilaufenden Epoche. - radio.c: statt eigener Peer-Queue ein registrierter Handler, der geparste Action-Frames an die App gibt. - main/awdl_app.{h,c}: ein FreeRTOS-Task besitzt den Node, verarbeitet RX aus einer Queue und sendet PSF/MIF + Cleanup zeitgesteuert; alles unter einem Mutex. Frame-Puffer statisch (Stack-Schutz), groessere Task-Stacks. - console: tx/peers ueber die App, neuer sync-Befehl (Master/Hoehe/Sync-Fehler/ Peers); mac/chan bauen den Node neu. Host-Tests inkl. ASan/UBSan gruen; C3-Build sauber (ESP-IDF 6.0.3). Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01XzgaN4Bv1m8Ka9iqo7QVUN --- .../spikes/components/awdl/CMakeLists.txt | 2 +- firmware/spikes/components/awdl/awdl_frame.c | 16 +- firmware/spikes/components/awdl/awdl_node.c | 97 +++++++++++ .../components/awdl/include/awdl_frame.h | 11 +- .../components/awdl/include/awdl_node.h | 72 ++++++++ .../spikes/components/awdl/test/host/Makefile | 2 +- .../awdl/test/host/test_awdl_frame.c | 82 ++++++++- firmware/spikes/main/CMakeLists.txt | 2 +- firmware/spikes/main/app_main.c | 9 +- firmware/spikes/main/awdl_app.c | 162 ++++++++++++++++++ firmware/spikes/main/awdl_app.h | 49 ++++++ firmware/spikes/main/console.c | 59 +++---- firmware/spikes/main/radio.c | 51 ++---- firmware/spikes/main/radio.h | 28 ++- 14 files changed, 537 insertions(+), 105 deletions(-) create mode 100644 firmware/spikes/components/awdl/awdl_node.c create mode 100644 firmware/spikes/components/awdl/include/awdl_node.h create mode 100644 firmware/spikes/main/awdl_app.c create mode 100644 firmware/spikes/main/awdl_app.h diff --git a/firmware/spikes/components/awdl/CMakeLists.txt b/firmware/spikes/components/awdl/CMakeLists.txt index 3d259db..79214b8 100644 --- a/firmware/spikes/components/awdl/CMakeLists.txt +++ b/firmware/spikes/components/awdl/CMakeLists.txt @@ -1,4 +1,4 @@ idf_component_register( - SRCS "awdl_frame.c" "awdl_sync.c" "awdl_election.c" "awdl_peers.c" + SRCS "awdl_frame.c" "awdl_sync.c" "awdl_election.c" "awdl_peers.c" "awdl_node.c" INCLUDE_DIRS "include" ) diff --git a/firmware/spikes/components/awdl/awdl_frame.c b/firmware/spikes/components/awdl/awdl_frame.c index 93bf3fa..9ca79c9 100644 --- a/firmware/spikes/components/awdl/awdl_frame.c +++ b/firmware/spikes/components/awdl/awdl_frame.c @@ -116,13 +116,9 @@ static bool put_dot11_hdr(uint8_t *buf, size_t cap, size_t *off, uint16_t fc, /* ------------------------------------------------------------------------ */ static bool put_tlv_sync_params(uint8_t *buf, size_t cap, size_t *off, - const awdl_self_t *self, uint64_t now_us) { - /* Free-running AW clock with epoch 0 (spike: not yet synced to a master). */ - uint64_t since_tu = now_us / AWDL_TU_US; - uint32_t eaw = AWDL_EAW_TU; /* 64 */ - uint16_t tx_down = (uint16_t)(eaw - (since_tu % eaw)); /* TU to next AW */ - uint16_t cur_aw = (uint16_t)(((since_tu % eaw) / AWDL_AW_PERIOD_TU) + - AWDL_PRESENCE_MODE * (since_tu / eaw)); + const awdl_self_t *self, + uint16_t tx_down, uint16_t cur_aw) { + /* Timing comes from the caller's (synchronised) AW clock. */ uint16_t aw_com = AWDL_AW_PERIOD_TU; uint16_t consumed = (uint16_t)(AWDL_AW_PERIOD_TU * AWDL_PRESENCE_MODE - tx_down); uint16_t remaining = (aw_com < consumed) ? 0 : (uint16_t)(aw_com - consumed); @@ -268,8 +264,8 @@ static bool put_tlv_version(uint8_t *buf, size_t cap, size_t *off, const awdl_se /* Public builders */ /* ------------------------------------------------------------------------ */ -int awdl_build_action(uint8_t *buf, size_t cap, awdl_self_t *self, - uint8_t subtype, uint64_t now_us) { +int awdl_build_action(uint8_t *buf, size_t cap, awdl_self_t *self, uint8_t subtype, + uint64_t now_us, uint16_t sync_tx_down_tu, uint16_t sync_aw_counter) { if (!buf || !self) return AWDL_ERR_ARG; size_t off = 0; @@ -287,7 +283,7 @@ int awdl_build_action(uint8_t *buf, size_t cap, awdl_self_t *self, ok &= wire_put_le32(buf, cap, &off, (uint32_t)now_us); /* phy_tx */ ok &= wire_put_le32(buf, cap, &off, (uint32_t)now_us); /* target_tx */ - ok &= put_tlv_sync_params(buf, cap, &off, self, now_us); + ok &= put_tlv_sync_params(buf, cap, &off, self, sync_tx_down_tu, sync_aw_counter); ok &= put_tlv_election_params(buf, cap, &off, self); ok &= put_tlv_chanseq(buf, cap, &off, self); ok &= put_tlv_election_params_v2(buf, cap, &off, self); diff --git a/firmware/spikes/components/awdl/awdl_node.c b/firmware/spikes/components/awdl/awdl_node.c new file mode 100644 index 0000000..083ef37 --- /dev/null +++ b/firmware/spikes/components/awdl/awdl_node.c @@ -0,0 +1,97 @@ +/* + * EspDrop - AWDL node brain (platform independent). See awdl_node.h. + * + * SPDX-License-Identifier: GPL-3.0-or-later + * Copyright (C) 2026 EspDrop contributors + */ +#include "awdl_node.h" + +#include + +void awdl_node_init(awdl_node_t *n, const uint8_t self_addr[6], + const char *name, uint8_t chan_num, uint64_t now_us) { + memset(n, 0, sizeof(*n)); + awdl_self_init(&n->self, self_addr, name, chan_num); + awdl_sync_init(&n->sync, now_us); + awdl_election_init(&n->election, self_addr); + awdl_peers_init(&n->peers); + n->last_sync_err_tu = 0; + n->have_master = false; +} + +static void peer_apply_view(awdl_peer_t *p, const awdl_peer_view_t *pv) { + if (pv->has_name) { + memcpy(p->name, pv->name, sizeof(p->name)); + p->has_name = true; + } + if (pv->has_version) { + p->version = pv->version; + p->devclass = pv->devclass; + p->has_version = true; + } + if (pv->has_chanseq) { + p->chanseq_enc = pv->chanseq_enc; + memcpy(p->chanseq, pv->chanseq, sizeof(p->chanseq)); + p->has_chanseq = true; + } + if (pv->has_election) { + /* The peer's election "self" is the peer itself. */ + memcpy(p->election.self_addr, pv->src_addr, 6); + memcpy(p->election.master_addr, pv->master_addr, 6); + memcpy(p->election.sync_addr, pv->sync_addr, 6); + p->election.height = pv->el_height; + p->election.master_metric = pv->el_master_metric; + p->election.master_counter = pv->el_master_counter; + p->election.self_metric = pv->el_self_metric; + p->election.self_counter = pv->el_self_counter; + } +} + +void awdl_node_on_rx_action(awdl_node_t *n, const awdl_peer_view_t *pv, + int8_t rssi, uint64_t now_us) { + (void)rssi; /* RSSI filtering is a later refinement */ + + bool created; + awdl_peer_t *p = awdl_peers_add(&n->peers, pv->src_addr, now_us, &created); + if (!p) return; /* table full */ + + peer_apply_view(p, pv); + + /* Re-run election now so a new or changed master is adopted immediately, + * then (if this sender is our master) slave our clock to its timing. */ + awdl_election_run(&n->election, &n->peers); + n->have_master = !awdl_election_is_sync_master(&n->election, n->election.self_addr); + + if (pv->has_sync) { + int64_t err = awdl_sync_error_tu(now_us, pv->sync_tx_down_tu, + pv->sync_aw_counter, &n->sync); + p->sync_offset_us = err * (int64_t)AWDL_TU_US; + p->has_sync_offset = true; + if (awdl_election_is_sync_master(&n->election, pv->src_addr)) { + n->last_sync_err_tu = err; /* how far we were off before adopting */ + awdl_sync_update(now_us, pv->sync_tx_down_tu, pv->sync_aw_counter, &n->sync); + } + } +} + +void awdl_node_tick(awdl_node_t *n, uint64_t now_us) { + if (now_us > n->peers.timeout_us) + awdl_peers_age(&n->peers, now_us - n->peers.timeout_us); + awdl_election_run(&n->election, &n->peers); + n->have_master = !awdl_election_is_sync_master(&n->election, n->election.self_addr); +} + +int awdl_node_build_action(awdl_node_t *n, uint8_t *buf, size_t cap, + uint8_t subtype, uint64_t now_us) { + uint16_t txd = awdl_sync_next_aw_tu(now_us, &n->sync); + uint16_t awc = awdl_sync_current_aw(now_us, &n->sync); + return awdl_build_action(buf, cap, &n->self, subtype, now_us, txd, awc); +} + +void awdl_node_status(const awdl_node_t *n, awdl_node_status_t *out) { + out->have_master = n->have_master; + memcpy(out->master_addr, n->election.sync_addr, 6); + out->height = n->election.height; + out->last_sync_err_tu = n->last_sync_err_tu; + out->peer_count = awdl_peers_count(&n->peers); +} diff --git a/firmware/spikes/components/awdl/include/awdl_frame.h b/firmware/spikes/components/awdl/include/awdl_frame.h index 0cc6b4f..b47e3ac 100644 --- a/firmware/spikes/components/awdl/include/awdl_frame.h +++ b/firmware/spikes/components/awdl/include/awdl_frame.h @@ -177,13 +177,16 @@ typedef struct { /* * Build a complete AWDL action frame (PSF or MIF) starting at the 802.11 MAC * header, ready to hand to esp_wifi_80211_tx() (no radiotap, no FCS). The - * destination is the AWDL broadcast target. now_us is a monotonic microsecond - * timestamp used to fill the sync-parameter timing fields. + * destination is the AWDL broadcast target. now_us fills the action header's + * phy_tx/target_tx timestamps; sync_tx_down_tu (TU until our next AW) and + * sync_aw_counter (our current AW counter) fill the synchronization-parameters + * TLV and must come from our AW clock (see awdl_sync.h / awdl_node.h), so the + * timing we advertise matches the master we have adopted. * * Returns the number of bytes written, or a negative enum awdl_result. */ -int awdl_build_action(uint8_t *buf, size_t cap, awdl_self_t *self, - uint8_t subtype, uint64_t now_us); +int awdl_build_action(uint8_t *buf, size_t cap, awdl_self_t *self, uint8_t subtype, + uint64_t now_us, uint16_t sync_tx_down_tu, uint16_t sync_aw_counter); /* * Build a complete AWDL data frame carrying an IPv6 payload: 802.11 data header diff --git a/firmware/spikes/components/awdl/include/awdl_node.h b/firmware/spikes/components/awdl/include/awdl_node.h new file mode 100644 index 0000000..2099f1b --- /dev/null +++ b/firmware/spikes/components/awdl/include/awdl_node.h @@ -0,0 +1,72 @@ +/* + * EspDrop - AWDL node brain (platform independent). + * + * Ties together our transmit state, the AW sync clock, the election state and + * the peer table. This is the logic OWL's daemon/core.c drives from its event + * loop, factored out with no ESP-IDF dependency so it stays host-testable; the + * firmware (main/) only provides a task, timers and the radio. + * + * All timestamps are microseconds on one monotonic clock (esp_timer on the + * ESP32). Call awdl_node_on_rx_action() for every received AWDL action frame + * and awdl_node_tick() periodically (~1 Hz) to age peers and re-run election. + * + * SPDX-License-Identifier: GPL-3.0-or-later + * Copyright (C) 2026 EspDrop contributors + */ +#ifndef ESPDROP_AWDL_NODE_H +#define ESPDROP_AWDL_NODE_H + +#include +#include + +#include "awdl_frame.h" +#include "awdl_sync.h" +#include "awdl_election.h" +#include "awdl_peers.h" + +#ifdef __cplusplus +extern "C" { +#endif + +typedef struct { + awdl_self_t self; /* transmit state (addr, name, channel sequence) */ + awdl_sync_t sync; /* our AW clock */ + awdl_election_t election; /* our election state (who we sync to) */ + awdl_peer_table_t peers; /* known peers */ + + /* Last phase error (TU) measured against our master just before we adopted + * its timing; small and stable means we are well synced. */ + int64_t last_sync_err_tu; + bool have_master; /* we sync to a peer, not to ourselves */ +} awdl_node_t; + +/* now_us: a monotonic microsecond timestamp (esp_timer_get_time() on hardware). */ +void awdl_node_init(awdl_node_t *n, const uint8_t self_addr[6], + const char *name, uint8_t chan_num, uint64_t now_us); + +/* Fold a received, parsed action frame into the node state. */ +void awdl_node_on_rx_action(awdl_node_t *n, const awdl_peer_view_t *pv, + int8_t rssi, uint64_t now_us); + +/* Age out stale peers and re-run election. Call ~1 Hz. */ +void awdl_node_tick(awdl_node_t *n, uint64_t now_us); + +/* Build one of our own PSF/MIF frames with timing from our AW clock. */ +int awdl_node_build_action(awdl_node_t *n, uint8_t *buf, size_t cap, + uint8_t subtype, uint64_t now_us); + +typedef struct { + bool have_master; + uint8_t master_addr[6]; + uint32_t height; + int64_t last_sync_err_tu; + int peer_count; +} awdl_node_status_t; + +void awdl_node_status(const awdl_node_t *n, awdl_node_status_t *out); + +#ifdef __cplusplus +} +#endif + +#endif /* ESPDROP_AWDL_NODE_H */ diff --git a/firmware/spikes/components/awdl/test/host/Makefile b/firmware/spikes/components/awdl/test/host/Makefile index 49a27d3..36eecc9 100644 --- a/firmware/spikes/components/awdl/test/host/Makefile +++ b/firmware/spikes/components/awdl/test/host/Makefile @@ -9,7 +9,7 @@ COMP = ../.. INCLUDE = -I$(COMP)/include SRCS = $(COMP)/awdl_frame.c $(COMP)/awdl_sync.c $(COMP)/awdl_election.c \ - $(COMP)/awdl_peers.c test_awdl_frame.c + $(COMP)/awdl_peers.c $(COMP)/awdl_node.c test_awdl_frame.c BIN = test_awdl_frame HEADERS = $(wildcard $(COMP)/include/*.h) diff --git a/firmware/spikes/components/awdl/test/host/test_awdl_frame.c b/firmware/spikes/components/awdl/test/host/test_awdl_frame.c index b1aae77..63d5714 100644 --- a/firmware/spikes/components/awdl/test/host/test_awdl_frame.c +++ b/firmware/spikes/components/awdl/test/host/test_awdl_frame.c @@ -14,6 +14,7 @@ #include "awdl_frame.h" #include "awdl_sync.h" #include "awdl_peers.h" +#include "awdl_node.h" #include #include @@ -55,7 +56,7 @@ static void test_mif_roundtrip(void) { awdl_self_init(&self, self_mac, "espdrop-c3", 6); uint8_t buf[1500]; - int n = awdl_build_action(buf, sizeof(buf), &self, AWDL_SUBTYPE_MIF, 123456789ull); + int n = awdl_build_action(buf, sizeof(buf), &self, AWDL_SUBTYPE_MIF, 123456789ull, 64, 0); CHECK(n > 0, "build MIF failed: %d", n); /* 802.11 action frame control */ @@ -88,7 +89,7 @@ static void test_psf_roundtrip(void) { awdl_self_init(&self, self_mac, "x", 149); uint8_t buf[1500]; - int n = awdl_build_action(buf, sizeof(buf), &self, AWDL_SUBTYPE_PSF, 0); + int n = awdl_build_action(buf, sizeof(buf), &self, AWDL_SUBTYPE_PSF, 0, 64, 0); CHECK(n > 0, "build PSF failed: %d", n); check_tlv_consistency(buf, n); @@ -136,7 +137,7 @@ static void test_sync_election_parse(void) { awdl_self_init(&self, self_mac, "espdrop-c3", 6); uint8_t buf[1500]; - int n = awdl_build_action(buf, sizeof(buf), &self, AWDL_SUBTYPE_MIF, 0 /* now_us */); + int n = awdl_build_action(buf, sizeof(buf), &self, AWDL_SUBTYPE_MIF, 0 /* now_us */, AWDL_EAW_TU, 0 /* tx_down, aw_counter */); CHECK(n > 0, "build failed: %d", n); awdl_peer_view_t pv; @@ -229,7 +230,7 @@ static void test_builder_respects_cap(void) { uint8_t buf[80]; memset(buf, 0xAB, sizeof(buf)); for (size_t cap = 24; cap <= 48; cap++) { - int r = awdl_build_action(buf, cap, &self, AWDL_SUBTYPE_MIF, 0); + int r = awdl_build_action(buf, cap, &self, AWDL_SUBTYPE_MIF, 0, 64, 0); CHECK(r == AWDL_ERR_ARG, "cap=%zu should fail, got %d", cap, r); /* nothing beyond cap may be touched */ for (size_t i = cap; i < sizeof(buf); i++) @@ -281,6 +282,78 @@ static void test_truncated_v2_no_clobber(void) { CHECK(pv.el_self_counter == 0, "self_counter should remain v1's 0"); } +/* Craft what a real iPhone's action frame parses to, with a clock `iph` so the + * announced timing is self-consistent and a strong election so it wins. */ +static void iphone_announce(awdl_sync_t *iph, uint64_t t, const uint8_t ip[6], + awdl_peer_view_t *pv) { + memset(pv, 0, sizeof(*pv)); + memcpy(pv->src_addr, ip, 6); + pv->subtype = AWDL_SUBTYPE_MIF; + pv->has_name = true; + snprintf(pv->name, sizeof(pv->name), "iPhone"); + pv->has_version = true; + pv->version = 0x3f; + pv->devclass = AWDL_DEVCLASS_IOS; + pv->has_chanseq = true; + pv->chanseq_enc = AWDL_CHAN_ENC_OPCLASS; + pv->chanseq[0] = 6; + pv->chanseq[8] = 6; /* present on channel 6 in slots 0 and 8 */ + pv->has_election = true; + memcpy(pv->master_addr, ip, 6); /* its own top master */ + pv->el_height = 0; + pv->el_master_metric = 1000; + pv->el_master_counter = 7; + pv->el_self_metric = 1000; + pv->el_self_counter = 7; + pv->has_sync = true; + pv->sync_tx_down_tu = awdl_sync_next_aw_tu(t, iph); + pv->sync_aw_counter = awdl_sync_current_aw(t, iph); +} + +static void test_node_sync_hold(void) { + uint8_t me[6] = {0x02, 0, 0, 0, 0, 0x01}; + uint8_t ip[6] = {0xca, 0x2a, 0xd6, 0x10, 0x8e, 0xae}; + + awdl_node_t n; + awdl_node_init(&n, me, "espdrop-c3", 6, 0); + + /* The iPhone has its own AW clock, offset from ours. */ + awdl_sync_t iph; + awdl_sync_init(&iph, 7000); + + awdl_peer_view_t pv; + uint64_t t1 = 100000; /* 100 ms */ + iphone_announce(&iph, t1, ip, &pv); + awdl_node_on_rx_action(&n, &pv, -40, t1); + + awdl_node_status_t st; + awdl_node_status(&n, &st); + CHECK(st.have_master, "no master adopted"); + CHECK(memcmp(st.master_addr, ip, 6) == 0, "master is not the iPhone"); + CHECK(st.height == 1, "height %u != 1", st.height); + CHECK(st.peer_count == 1, "peer_count %d", st.peer_count); + + /* After adopting the iPhone's timing, a later consistent announcement must + * measure a small residual error. */ + uint64_t t2 = t1 + 300000; + iphone_announce(&iph, t2, ip, &pv); + awdl_node_on_rx_action(&n, &pv, -40, t2); + awdl_node_status(&n, &st); + CHECK(st.last_sync_err_tu > -3 && st.last_sync_err_tu < 3, + "sync error should be ~0 after adopting, got %lld", + (long long)st.last_sync_err_tu); + + /* The peer is held within the timeout and aged out past it. */ + awdl_node_tick(&n, t2 + 1500000); /* +1.5 s, within 2 s */ + awdl_node_status(&n, &st); + CHECK(st.peer_count == 1, "peer aged out too early"); + + awdl_node_tick(&n, t2 + 3000000); /* +3 s past last update */ + awdl_node_status(&n, &st); + CHECK(st.peer_count == 0, "peer should have aged out"); + CHECK(!st.have_master, "master should be gone after the peer aged out"); +} + int main(void) { test_mif_roundtrip(); test_psf_roundtrip(); @@ -291,6 +364,7 @@ int main(void) { test_election_run(); test_builder_respects_cap(); test_truncated_v2_no_clobber(); + test_node_sync_hold(); if (g_fail == 0) printf("\nALL TESTS PASSED\n"); else printf("\n%d CHECK(S) FAILED\n", g_fail); diff --git a/firmware/spikes/main/CMakeLists.txt b/firmware/spikes/main/CMakeLists.txt index 2e8359f..9486849 100644 --- a/firmware/spikes/main/CMakeLists.txt +++ b/firmware/spikes/main/CMakeLists.txt @@ -1,5 +1,5 @@ idf_component_register( - SRCS "app_main.c" "radio.c" "console.c" + SRCS "app_main.c" "radio.c" "console.c" "awdl_app.c" INCLUDE_DIRS "." REQUIRES awdl esp_wifi esp_event esp_netif nvs_flash console esp_timer ) diff --git a/firmware/spikes/main/app_main.c b/firmware/spikes/main/app_main.c index 51e5906..91886fb 100644 --- a/firmware/spikes/main/app_main.c +++ b/firmware/spikes/main/app_main.c @@ -16,13 +16,14 @@ #include "esp_log.h" #include "radio.h" +#include "awdl_app.h" #define DEFAULT_CHANNEL 6 /* AWDL 2.4 GHz social channel, the only one on the C3 */ void console_register_commands(void); void app_main(void) { - ESP_LOGI("espdrop", "EspDrop AWDL spike (M0) starting"); + ESP_LOGI("espdrop", "EspDrop AWDL spike (M1) starting"); int err = radio_init(DEFAULT_CHANNEL); if (err != ESP_OK) { @@ -30,10 +31,16 @@ void app_main(void) { return; } + uint8_t mac[6]; + radio_get_self_mac(mac); + if (awdl_app_start(mac, "espdrop-c3", DEFAULT_CHANNEL) != 0) + ESP_LOGE("espdrop", "awdl_app_start failed"); + esp_console_repl_t *repl = NULL; esp_console_repl_config_t repl_cfg = ESP_CONSOLE_REPL_CONFIG_DEFAULT(); repl_cfg.prompt = "espdrop>"; repl_cfg.max_cmdline_length = 256; + repl_cfg.task_stack_size = 8192; /* cmd_txdata/tx build ~1.5 KB frames */ esp_console_dev_usb_serial_jtag_config_t usb_cfg = ESP_CONSOLE_DEV_USB_SERIAL_JTAG_CONFIG_DEFAULT(); diff --git a/firmware/spikes/main/awdl_app.c b/firmware/spikes/main/awdl_app.c new file mode 100644 index 0000000..5189e36 --- /dev/null +++ b/firmware/spikes/main/awdl_app.c @@ -0,0 +1,162 @@ +/* + * EspDrop - AWDL application layer (ESP32). See awdl_app.h. + * + * SPDX-License-Identifier: GPL-3.0-or-later + * Copyright (C) 2026 EspDrop contributors + */ +#include "awdl_app.h" + +#include + +#include "esp_log.h" +#include "esp_timer.h" +#include "freertos/FreeRTOS.h" +#include "freertos/task.h" +#include "freertos/queue.h" +#include "freertos/semphr.h" + +#include "radio.h" + +static const char *TAG = "awdl_app"; + +/* How often we emit our own frames and clean the peer table. PSF timing is not + * critical for M1 (sync is receive-driven); these just keep us advertising. */ +#define PSF_INTERVAL_US (AWDL_PSF_INTERVAL_TU * AWDL_TU_US) /* ~112 ms */ +#define MIF_INTERVAL_US (6 * AWDL_PSF_INTERVAL_TU * AWDL_TU_US) /* ~675 ms */ +#define TICK_INTERVAL_US AWDL_PEER_CLEAN_INTERVAL_US /* 1 s */ +#define RX_Q_LEN 16 + +typedef struct { + awdl_peer_view_t pv; + int8_t rssi; + uint8_t channel; + uint64_t now_us; +} rx_event_t; + +static awdl_node_t s_node; +static SemaphoreHandle_t s_lock; /* guards s_node */ +static QueueHandle_t s_rx_q; +static TaskHandle_t s_task; +static bool s_started; + +/* radio handler: Wi-Fi task context, must be quick. Copy into the queue. */ +static void on_action(const awdl_peer_view_t *pv, int8_t rssi, uint8_t channel, + uint64_t now_us, void *ctx) { + (void)ctx; + rx_event_t ev; + ev.pv = *pv; + ev.rssi = rssi; + ev.channel = channel; + ev.now_us = now_us; + /* drop on overflow rather than block the Wi-Fi task */ + (void)xQueueSend(s_rx_q, &ev, 0); +} + +static void send_frame(uint8_t subtype) { + /* Static (not on the stack): send_frame runs on both the awdl task and the + * console task, and a 1500-byte stack frame would risk overflowing either. + * The mutex serialises use of the shared buffer and the node. */ + static uint8_t buf[1500]; + xSemaphoreTake(s_lock, portMAX_DELAY); + int n = awdl_node_build_action(&s_node, buf, sizeof(buf), subtype, + (uint64_t)esp_timer_get_time()); + if (n > 0) + radio_tx(buf, n); + xSemaphoreGive(s_lock); +} + +static void awdl_task(void *arg) { + (void)arg; + int64_t now = esp_timer_get_time(); + int64_t next_psf = now + PSF_INTERVAL_US; + int64_t next_mif = now + MIF_INTERVAL_US; + int64_t next_tick = now + TICK_INTERVAL_US; + + for (;;) { + rx_event_t ev; + if (xQueueReceive(s_rx_q, &ev, pdMS_TO_TICKS(20)) == pdTRUE) { + xSemaphoreTake(s_lock, portMAX_DELAY); + awdl_node_on_rx_action(&s_node, &ev.pv, ev.rssi, ev.now_us); + xSemaphoreGive(s_lock); + } + + now = esp_timer_get_time(); + if (now >= next_psf) { + send_frame(AWDL_SUBTYPE_PSF); + next_psf = now + PSF_INTERVAL_US; + } + if (now >= next_mif) { + send_frame(AWDL_SUBTYPE_MIF); + next_mif = now + MIF_INTERVAL_US; + } + if (now >= next_tick) { + xSemaphoreTake(s_lock, portMAX_DELAY); + awdl_node_tick(&s_node, (uint64_t)now); + xSemaphoreGive(s_lock); + next_tick = now + TICK_INTERVAL_US; + } + } +} + +int awdl_app_start(const uint8_t self_mac[6], const char *name, uint8_t chan) { + if (s_started) return 0; + + s_lock = xSemaphoreCreateMutex(); + s_rx_q = xQueueCreate(RX_Q_LEN, sizeof(rx_event_t)); + if (!s_lock || !s_rx_q) return -1; + + awdl_node_init(&s_node, self_mac, name, chan, (uint64_t)esp_timer_get_time()); + + radio_set_action_handler(on_action, NULL); + + if (xTaskCreate(awdl_task, "awdl", 6144, NULL, 5, &s_task) != pdPASS) + return -1; + + s_started = true; + ESP_LOGI(TAG, "started on ch %u", chan); + return 0; +} + +void awdl_app_set_self(const uint8_t self_mac[6], uint8_t chan) { + if (!s_started) return; + xSemaphoreTake(s_lock, portMAX_DELAY); + awdl_node_init(&s_node, self_mac, "espdrop-c3", chan, + (uint64_t)esp_timer_get_time()); + xSemaphoreGive(s_lock); +} + +bool awdl_app_status(awdl_node_status_t *out) { + if (!s_started) return false; + xSemaphoreTake(s_lock, portMAX_DELAY); + awdl_node_status(&s_node, out); + xSemaphoreGive(s_lock); + return true; +} + +int awdl_app_format_peers(char *buf, size_t cap) { + if (!s_started || cap == 0) { if (cap) buf[0] = 0; return 0; } + int off = 0; + xSemaphoreTake(s_lock, portMAX_DELAY); + uint64_t now = (uint64_t)esp_timer_get_time(); + for (int i = 0; i < AWDL_MAX_PEERS; i++) { + const awdl_peer_t *p = &s_node.peers.peers[i]; + if (!p->used) continue; + int age_ms = (int)((now - p->last_update_us) / 1000); + off += snprintf(buf + off, (off < (int)cap) ? cap - off : 0, + "%02x:%02x:%02x:%02x:%02x:%02x %-10s age %dms seq", + p->addr[0], p->addr[1], p->addr[2], + p->addr[3], p->addr[4], p->addr[5], + p->has_name ? p->name : "?", age_ms); + for (int s = 0; s < AWDL_CHANSEQ_LEN && p->has_chanseq; s++) + off += snprintf(buf + off, (off < (int)cap) ? cap - off : 0, + " %u", p->chanseq[s]); + off += snprintf(buf + off, (off < (int)cap) ? cap - off : 0, "\n"); + } + xSemaphoreGive(s_lock); + return off; +} + +void awdl_app_tx(uint8_t subtype) { + if (!s_started) return; + send_frame(subtype); +} diff --git a/firmware/spikes/main/awdl_app.h b/firmware/spikes/main/awdl_app.h new file mode 100644 index 0000000..d184db1 --- /dev/null +++ b/firmware/spikes/main/awdl_app.h @@ -0,0 +1,49 @@ +/* + * EspDrop - AWDL application layer (ESP32). + * + * Owns the platform-independent awdl_node brain and drives it from one FreeRTOS + * task: received action frames arrive via a queue (fed by the radio handler), + * and PSF/MIF transmission plus peer cleanup are time-driven off + * esp_timer_get_time(). The node is only ever touched under one mutex, so the + * console can read a consistent snapshot. + * + * SPDX-License-Identifier: GPL-3.0-or-later + * Copyright (C) 2026 EspDrop contributors + */ +#ifndef ESPDROP_AWDL_APP_H +#define ESPDROP_AWDL_APP_H + +#include +#include +#include + +#include "awdl_node.h" + +#ifdef __cplusplus +extern "C" { +#endif + +/* + * Start the AWDL task on the given self MAC, name and 2.4 GHz channel. Registers + * the radio action handler. Call once, after radio_init(). Returns 0 on success. + */ +int awdl_app_start(const uint8_t self_mac[6], const char *name, uint8_t chan); + +/* Rebuild the node for a new self MAC / channel (resets sync). For the console + * mac/chan commands. Safe to call at runtime. */ +void awdl_app_set_self(const uint8_t self_mac[6], uint8_t chan); + +/* Copy a consistent status snapshot out. Returns false if not started. */ +bool awdl_app_status(awdl_node_status_t *out); + +/* Format the current peer list into buf (NUL-terminated). Returns bytes used. */ +int awdl_app_format_peers(char *buf, size_t cap); + +/* Build and transmit one of our own PSF/MIF frames immediately (console `tx`). */ +void awdl_app_tx(uint8_t subtype); + +#ifdef __cplusplus +} +#endif + +#endif /* ESPDROP_AWDL_APP_H */ diff --git a/firmware/spikes/main/console.c b/firmware/spikes/main/console.c index b8dcc33..ae2d1c3 100644 --- a/firmware/spikes/main/console.c +++ b/firmware/spikes/main/console.c @@ -27,6 +27,7 @@ #include "esp_wifi.h" #include "awdl_frame.h" +#include "awdl_app.h" #include "radio.h" static awdl_self_t s_self; @@ -69,6 +70,7 @@ static int cmd_mac(int argc, char **argv) { int err = radio_set_self_mac(mac); if (err) { printf("set_mac failed: 0x%x\n", err); return 1; } refresh_self(); + awdl_app_set_self(mac, radio_get_channel()); /* rebuild node (resets sync) */ printf("MAC set\n"); return 0; } @@ -80,6 +82,9 @@ static int cmd_chan(int argc, char **argv) { int err = radio_set_channel((uint8_t)ch); if (err) { printf("set_channel failed: 0x%x\n", err); return 1; } refresh_self(); + uint8_t mac[6]; + radio_get_self_mac(mac); + awdl_app_set_self(mac, (uint8_t)ch); /* rebuild node (resets sync) */ printf("channel %d\n", ch); return 0; } @@ -103,17 +108,10 @@ static int cmd_tx(int argc, char **argv) { int count = (argc >= 3) ? atoi(argv[2]) : 1; if (count < 1) count = 1; - uint8_t buf[1500]; - int sent = 0, failed = 0; - for (int i = 0; i < count; i++) { - int n = awdl_build_action(buf, sizeof(buf), &s_self, subtype, - (uint64_t)esp_timer_get_time()); - if (n <= 0) { printf("build failed: %d\n", n); return 1; } - if (radio_tx(buf, n) == 0) sent++; else failed++; - } - printf("tx %s x%d: %d ok, %d err (len=%d)\n", - argv[1], count, sent, failed, - awdl_build_action(buf, sizeof(buf), &s_self, subtype, 0)); + /* Built with the node's synchronised AW clock. Results show up in `stats`. */ + for (int i = 0; i < count; i++) + awdl_app_tx(subtype); + printf("tx %s x%d requested (see `stats` for tx counters)\n", argv[1], count); return 0; } @@ -147,23 +145,25 @@ static int cmd_txdata(int argc, char **argv) { static int cmd_peers(int argc, char **argv) { (void)argc; (void)argv; - radio_peer_event_t ev; - int n = 0; - while (radio_poll_peer_event(&ev)) { - n++; - printf("peer %02x:%02x:%02x:%02x:%02x:%02x %-4s rssi %d ch %u name '%s'\n", - ev.addr[0], ev.addr[1], ev.addr[2], ev.addr[3], ev.addr[4], ev.addr[5], - ev.subtype == AWDL_SUBTYPE_MIF ? "MIF" : "PSF", - ev.rssi, ev.channel, ev.has_name ? ev.name : "?"); - if (ev.has_chanseq) { - printf(" chanseq:"); - for (int i = 0; i < AWDL_CHANSEQ_LEN; i++) printf(" %u", ev.chanseq[i]); - printf("\n"); - } - printf(" rx_ts=%" PRIu32 " us esp_timer=%lld us\n", - ev.rx_timestamp, (long long)ev.esp_timer_us); - } - if (n == 0) printf("(no peer events queued)\n"); + static char buf[1024]; + int n = awdl_app_format_peers(buf, sizeof(buf)); + if (n <= 0) printf("(no peers)\n"); + else printf("%s", buf); + return 0; +} + +static int cmd_sync(int argc, char **argv) { + (void)argc; (void)argv; + awdl_node_status_t st; + if (!awdl_app_status(&st)) { printf("awdl not started\n"); return 1; } + if (st.have_master) + printf("master %02x:%02x:%02x:%02x:%02x:%02x height %" PRIu32 " " + "sync_err %lld TU peers %d\n", + st.master_addr[0], st.master_addr[1], st.master_addr[2], + st.master_addr[3], st.master_addr[4], st.master_addr[5], + st.height, (long long)st.last_sync_err_tu, st.peer_count); + else + printf("no master (we are our own master) peers %d\n", st.peer_count); return 0; } @@ -239,7 +239,8 @@ void console_register_commands(void) { reg("rx", "rx on|off - promiscuous AWDL receive", cmd_rx); reg("tx", "tx psf|mif [count] - inject action frame(s)", cmd_tx); reg("txdata", "txdata - inject one AWDL data frame", cmd_txdata); - reg("peers", "drain and print discovered-peer events", cmd_peers); + reg("peers", "print the current peer table (name, age, channel seq)", cmd_peers); + reg("sync", "print master, sync-tree height, sync error, peer count", cmd_sync); reg("stats", "print rx/tx counters", cmd_stats); reg("reset", "zero the counters", cmd_reset); reg("chanlat", "chanlat [n] - measure channel-switch latency", cmd_chanlat); diff --git a/firmware/spikes/main/radio.c b/firmware/spikes/main/radio.c index f2c5a2e..cbd84fa 100644 --- a/firmware/spikes/main/radio.c +++ b/firmware/spikes/main/radio.c @@ -21,13 +21,12 @@ static const char *TAG = "radio"; -static radio_stats_t s_stats; -static portMUX_TYPE s_stats_mux = portMUX_INITIALIZER_UNLOCKED; -static QueueHandle_t s_peer_q; /* radio_peer_event_t */ -static volatile bool s_promisc; -static uint8_t s_channel = 6; - -#define PEER_Q_LEN 16 +static radio_stats_t s_stats; +static portMUX_TYPE s_stats_mux = portMUX_INITIALIZER_UNLOCKED; +static volatile bool s_promisc; +static uint8_t s_channel = 6; +static radio_action_handler_t s_action_handler; +static void *s_action_ctx; static inline void stat_inc(uint64_t *field) { portENTER_CRITICAL(&s_stats_mux); @@ -67,34 +66,17 @@ static void rx_cb(void *buf, wifi_promiscuous_pkt_type_t type) { } if (type == WIFI_PKT_MGMT) { - radio_peer_event_t ev; awdl_peer_view_t pv; int r = awdl_parse_action(frame, (size_t)len, &pv); if (r != AWDL_OK) { stat_inc(&s_stats.rx_parse_err); return; } stat_inc(&s_stats.rx_action); - memset(&ev, 0, sizeof(ev)); - memcpy(ev.addr, pv.src_addr, 6); - ev.rssi = pkt->rx_ctrl.rssi; - ev.channel = pkt->rx_ctrl.channel; - ev.subtype = pv.subtype; - ev.has_name = pv.has_name; - if (pv.has_name) memcpy(ev.name, pv.name, sizeof(ev.name)); - ev.has_chanseq = pv.has_chanseq; - if (pv.has_chanseq) memcpy(ev.chanseq, pv.chanseq, sizeof(ev.chanseq)); - ev.rx_timestamp = pkt->rx_ctrl.timestamp; - ev.esp_timer_us = esp_timer_get_time(); - - if (s_peer_q) { - BaseType_t hp = pdFALSE; - /* drop oldest on overflow so the newest always gets through */ - if (xQueueSendFromISR(s_peer_q, &ev, &hp) != pdTRUE) { - radio_peer_event_t drop; - xQueueReceiveFromISR(s_peer_q, &drop, &hp); - xQueueSendFromISR(s_peer_q, &ev, &hp); - } - if (hp) portYIELD_FROM_ISR(); - } + /* Hand the parsed view to the registered consumer (the awdl task). The + * promiscuous callback runs in Wi-Fi task context, so the handler must + * be quick and non-blocking (copy into a queue and return). */ + if (s_action_handler) + s_action_handler(&pv, pkt->rx_ctrl.rssi, pkt->rx_ctrl.channel, + esp_timer_get_time(), s_action_ctx); } else { /* WIFI_PKT_DATA */ size_t poff; uint16_t et; if (awdl_parse_data(frame, (size_t)len, &poff, &et)) @@ -120,9 +102,6 @@ int radio_init(uint8_t channel) { err = esp_event_loop_create_default(); if (err != ESP_OK && err != ESP_ERR_INVALID_STATE) return err; /* already created: fine */ - s_peer_q = xQueueCreate(PEER_Q_LEN, sizeof(radio_peer_event_t)); - if (!s_peer_q) return ESP_ERR_NO_MEM; - wifi_init_config_t cfg = WIFI_INIT_CONFIG_DEFAULT(); if ((err = esp_wifi_init(&cfg)) != ESP_OK) return err; if ((err = esp_wifi_set_storage(WIFI_STORAGE_RAM)) != ESP_OK) return err; @@ -208,7 +187,7 @@ void radio_reset_stats(void) { portEXIT_CRITICAL(&s_stats_mux); } -bool radio_poll_peer_event(radio_peer_event_t *ev) { - if (!s_peer_q) return false; - return xQueueReceive(s_peer_q, ev, 0) == pdTRUE; +void radio_set_action_handler(radio_action_handler_t h, void *ctx) { + s_action_ctx = ctx; + s_action_handler = h; } diff --git a/firmware/spikes/main/radio.h b/firmware/spikes/main/radio.h index db84f9a..5d95a94 100644 --- a/firmware/spikes/main/radio.h +++ b/firmware/spikes/main/radio.h @@ -31,19 +31,14 @@ typedef struct { uint64_t tx_done_fail; /* 80211 tx-done callbacks with status FAIL */ } radio_stats_t; -/* A discovered peer, snapshotted from a received action frame. */ -typedef struct { - uint8_t addr[6]; - int8_t rssi; - uint8_t channel; /* channel the frame was received on */ - uint8_t subtype; /* PSF / MIF */ - bool has_name; - char name[AWDL_NAME_MAX + 1]; - bool has_chanseq; - uint8_t chanseq[AWDL_CHANSEQ_LEN]; - uint32_t rx_timestamp; /* rx_ctrl.timestamp (us), hardware receive time */ - int64_t esp_timer_us; /* esp_timer_get_time() at callback entry */ -} radio_peer_event_t; +/* + * Handler for a received, parsed AWDL action frame. Invoked from Wi-Fi task + * context inside the promiscuous callback, so it MUST be quick and must not + * block (copy what it needs into a queue and return). pv is only valid for the + * duration of the call. now_us is esp_timer_get_time() at reception. + */ +typedef void (*radio_action_handler_t)(const awdl_peer_view_t *pv, int8_t rssi, + uint8_t channel, uint64_t now_us, void *ctx); /* * Bring up Wi-Fi in station mode without connecting, on the given 2.4 GHz @@ -76,11 +71,8 @@ bool radio_promisc_enabled(void); void radio_get_stats(radio_stats_t *out); void radio_reset_stats(void); -/* - * Pop the next discovered-peer event, if any, without blocking. - * Returns true and fills *ev if one was queued, false otherwise. - */ -bool radio_poll_peer_event(radio_peer_event_t *ev); +/* Register the consumer for received AWDL action frames (NULL to clear). */ +void radio_set_action_handler(radio_action_handler_t h, void *ctx); #ifdef __cplusplus } From 2609f4b50cfabed0582e63d2b44c1a20eea25c43 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 14 Oct 2026 19:28:20 +0000 Subject: [PATCH 14/99] awdl: report 0 on first sync lock, real drift thereafter Der erste Lock auf einen Master verglich zwei fremde Uhren (Boot-Epoche vs. iPhone-Epoche) und meldete einen riesigen Scheinfehler (z.B. -235896 TU). Das ist kein Drift, sondern nur der einmalige Versatz. Der Node merkt sich jetzt den getrackten Master; der erste Lock meldet sync_err 0, erst Folge- Frames desselben Masters zeigen den echten (kleinen) Drift. sync_locked wird geloescht, sobald kein Master mehr da ist. Host-Test prueft beides. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01XzgaN4Bv1m8Ka9iqo7QVUN --- firmware/spikes/components/awdl/awdl_node.c | 11 ++++++++++- firmware/spikes/components/awdl/include/awdl_node.h | 8 ++++++-- .../components/awdl/test/host/test_awdl_frame.c | 3 +++ 3 files changed, 19 insertions(+), 3 deletions(-) diff --git a/firmware/spikes/components/awdl/awdl_node.c b/firmware/spikes/components/awdl/awdl_node.c index 083ef37..ab77114 100644 --- a/firmware/spikes/components/awdl/awdl_node.c +++ b/firmware/spikes/components/awdl/awdl_node.c @@ -61,6 +61,7 @@ void awdl_node_on_rx_action(awdl_node_t *n, const awdl_peer_view_t *pv, * then (if this sender is our master) slave our clock to its timing. */ awdl_election_run(&n->election, &n->peers); n->have_master = !awdl_election_is_sync_master(&n->election, n->election.self_addr); + if (!n->have_master) n->sync_locked = false; /* no master: next adopt is fresh */ if (pv->has_sync) { int64_t err = awdl_sync_error_tu(now_us, pv->sync_tx_down_tu, @@ -68,8 +69,15 @@ void awdl_node_on_rx_action(awdl_node_t *n, const awdl_peer_view_t *pv, p->sync_offset_us = err * (int64_t)AWDL_TU_US; p->has_sync_offset = true; if (awdl_election_is_sync_master(&n->election, pv->src_addr)) { - n->last_sync_err_tu = err; /* how far we were off before adopting */ + /* First lock to this master: just adopt its clock; the pre-update + * "error" is only the gap between two unrelated epochs, not drift, + * so report 0. Thereafter it is the real residual drift. */ + bool fresh = !n->sync_locked || + memcmp(n->synced_master, pv->src_addr, 6) != 0; + n->last_sync_err_tu = fresh ? 0 : err; awdl_sync_update(now_us, pv->sync_tx_down_tu, pv->sync_aw_counter, &n->sync); + memcpy(n->synced_master, pv->src_addr, 6); + n->sync_locked = true; } } } @@ -79,6 +87,7 @@ void awdl_node_tick(awdl_node_t *n, uint64_t now_us) { awdl_peers_age(&n->peers, now_us - n->peers.timeout_us); awdl_election_run(&n->election, &n->peers); n->have_master = !awdl_election_is_sync_master(&n->election, n->election.self_addr); + if (!n->have_master) n->sync_locked = false; /* no master: next adopt is fresh */ } int awdl_node_build_action(awdl_node_t *n, uint8_t *buf, size_t cap, diff --git a/firmware/spikes/components/awdl/include/awdl_node.h b/firmware/spikes/components/awdl/include/awdl_node.h index 2099f1b..8d6e6e2 100644 --- a/firmware/spikes/components/awdl/include/awdl_node.h +++ b/firmware/spikes/components/awdl/include/awdl_node.h @@ -34,10 +34,14 @@ typedef struct { awdl_election_t election; /* our election state (who we sync to) */ awdl_peer_table_t peers; /* known peers */ - /* Last phase error (TU) measured against our master just before we adopted - * its timing; small and stable means we are well synced. */ + /* Drift (TU) measured against our master just before the latest correction. + * Small and stable means we are well synced. Zero on the first lock to a + * master (there the "error" is only the gap between two unrelated clocks, + * not drift), and real drift on every subsequent frame from that master. */ int64_t last_sync_err_tu; bool have_master; /* we sync to a peer, not to ourselves */ + bool sync_locked; /* we have adopted the current master's timing */ + uint8_t synced_master[6]; /* the master we are currently tracking */ } awdl_node_t; /* now_us: a monotonic microsecond timestamp (esp_timer_get_time() on hardware). */ diff --git a/firmware/spikes/components/awdl/test/host/test_awdl_frame.c b/firmware/spikes/components/awdl/test/host/test_awdl_frame.c index 63d5714..63eaa86 100644 --- a/firmware/spikes/components/awdl/test/host/test_awdl_frame.c +++ b/firmware/spikes/components/awdl/test/host/test_awdl_frame.c @@ -332,6 +332,9 @@ static void test_node_sync_hold(void) { CHECK(memcmp(st.master_addr, ip, 6) == 0, "master is not the iPhone"); CHECK(st.height == 1, "height %u != 1", st.height); CHECK(st.peer_count == 1, "peer_count %d", st.peer_count); + /* First lock reports 0, not the (huge) gap between two unrelated clocks. */ + CHECK(st.last_sync_err_tu == 0, "first lock should report 0, got %lld", + (long long)st.last_sync_err_tu); /* After adopting the iPhone's timing, a later consistent announcement must * measure a small residual error. */ From c08b8b1f4a46016395763f33b17be62e4b4372ff Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 14 Oct 2026 19:34:46 +0000 Subject: [PATCH 15/99] awdl: advertise the master's channel-6 slots, not a flat all-6 sequence iOS lehnt eine Sequenz ab, die stur einen Kanal in allen 16 Slots behauptet (mt7921-Findings). Der Node spiegelt jetzt die Sequenz des Masters: Kanal 6 genau in dessen Ch-6-Slots, sonst 0 (abwesend) - ehrlich und strukturell Apple-aehnlich. Ohne Master faellt er auf die statische Einzelkanal-Sequenz zurueck. awdl_self_set_chanseq_intersect/_static ergaenzt; Node ruft das nach jeder Election. Host-Test prueft Spiegelung (6 in Slots 0/8, sonst 0) und Rueckfall; inkl. ASan/UBSan gruen, C3-Build sauber. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01XzgaN4Bv1m8Ka9iqo7QVUN --- firmware/spikes/components/awdl/awdl_frame.c | 26 +++++++++++- firmware/spikes/components/awdl/awdl_node.c | 17 ++++++++ .../components/awdl/include/awdl_frame.h | 15 +++++++ .../awdl/test/host/test_awdl_frame.c | 42 ++++++++++++++++++- 4 files changed, 98 insertions(+), 2 deletions(-) diff --git a/firmware/spikes/components/awdl/awdl_frame.c b/firmware/spikes/components/awdl/awdl_frame.c index 9ca79c9..cdd1e1a 100644 --- a/firmware/spikes/components/awdl/awdl_frame.c +++ b/firmware/spikes/components/awdl/awdl_frame.c @@ -88,13 +88,37 @@ void awdl_self_init(awdl_self_t *self, const uint8_t self_addr[6], self->chan_enc = AWDL_CHAN_ENC_OPCLASS; self->master_chan = chan_num; - uint8_t opclass = (chan_num <= 14) ? 0x51 : 0x80; + awdl_self_set_chanseq_static(self, chan_num); +} + +/* opclass byte for a channel number, opclass encoding. */ +static uint8_t opclass_for(uint8_t chan_num) { + return (chan_num <= 14) ? 0x51 : 0x80; +} + +void awdl_self_set_chanseq_static(awdl_self_t *self, uint8_t chan_num) { + uint8_t opclass = opclass_for(chan_num); for (unsigned i = 0; i < AWDL_CHANSEQ_LEN; i++) { self->seq[i][0] = chan_num; self->seq[i][1] = opclass; } } +void awdl_self_set_chanseq_intersect(awdl_self_t *self, + const uint8_t master_chanseq[AWDL_CHANSEQ_LEN], + uint8_t keep_chan) { + uint8_t opclass = opclass_for(keep_chan); + for (unsigned i = 0; i < AWDL_CHANSEQ_LEN; i++) { + if (master_chanseq[i] == keep_chan) { + self->seq[i][0] = keep_chan; /* present on our channel in this slot */ + self->seq[i][1] = opclass; + } else { + self->seq[i][0] = 0; /* absent (we cannot be on that channel) */ + self->seq[i][1] = 0; + } + } +} + /* ------------------------------------------------------------------------ */ /* 802.11 header */ /* ------------------------------------------------------------------------ */ diff --git a/firmware/spikes/components/awdl/awdl_node.c b/firmware/spikes/components/awdl/awdl_node.c index ab77114..ddda4b0 100644 --- a/firmware/spikes/components/awdl/awdl_node.c +++ b/firmware/spikes/components/awdl/awdl_node.c @@ -47,6 +47,20 @@ static void peer_apply_view(awdl_peer_t *p, const awdl_peer_view_t *pv) { } } +/* Re-derive the channel sequence we advertise. When we track a master that has + * advertised a channel sequence, mirror its slots on our own channel; otherwise + * fall back to the lone static sequence. */ +static void awdl_node_update_chanseq(awdl_node_t *n) { + if (n->have_master) { + awdl_peer_t *m = awdl_peers_get(&n->peers, n->election.sync_addr); + if (m && m->has_chanseq) { + awdl_self_set_chanseq_intersect(&n->self, m->chanseq, n->self.master_chan); + return; + } + } + awdl_self_set_chanseq_static(&n->self, n->self.master_chan); +} + void awdl_node_on_rx_action(awdl_node_t *n, const awdl_peer_view_t *pv, int8_t rssi, uint64_t now_us) { (void)rssi; /* RSSI filtering is a later refinement */ @@ -63,6 +77,8 @@ void awdl_node_on_rx_action(awdl_node_t *n, const awdl_peer_view_t *pv, n->have_master = !awdl_election_is_sync_master(&n->election, n->election.self_addr); if (!n->have_master) n->sync_locked = false; /* no master: next adopt is fresh */ + awdl_node_update_chanseq(n); + if (pv->has_sync) { int64_t err = awdl_sync_error_tu(now_us, pv->sync_tx_down_tu, pv->sync_aw_counter, &n->sync); @@ -88,6 +104,7 @@ void awdl_node_tick(awdl_node_t *n, uint64_t now_us) { awdl_election_run(&n->election, &n->peers); n->have_master = !awdl_election_is_sync_master(&n->election, n->election.self_addr); if (!n->have_master) n->sync_locked = false; /* no master: next adopt is fresh */ + awdl_node_update_chanseq(n); } int awdl_node_build_action(awdl_node_t *n, uint8_t *buf, size_t cap, diff --git a/firmware/spikes/components/awdl/include/awdl_frame.h b/firmware/spikes/components/awdl/include/awdl_frame.h index b47e3ac..c33e2c3 100644 --- a/firmware/spikes/components/awdl/include/awdl_frame.h +++ b/firmware/spikes/components/awdl/include/awdl_frame.h @@ -137,6 +137,21 @@ typedef struct { void awdl_self_init(awdl_self_t *self, const uint8_t self_addr[6], const char *name, uint8_t chan_num); +/* Fill our channel sequence with a single channel in all 16 slots (the lone, + * not-yet-synced default). */ +void awdl_self_set_chanseq_static(awdl_self_t *self, uint8_t chan_num); + +/* + * Mirror a master's channel sequence into ours: advertise keep_chan only in the + * slots where the master is on keep_chan, and "absent" (0) everywhere else. + * On a single-band device this is the honest sequence and the structurally + * Apple-like shape that an iPhone will answer, unlike a flat all-one-channel + * sequence (see docs/UEBERBLICK.md and the airdrop-mt7921 findings). + */ +void awdl_self_set_chanseq_intersect(awdl_self_t *self, + const uint8_t master_chanseq[AWDL_CHANSEQ_LEN], + uint8_t keep_chan); + /* ------------------------------------------------------------------------ */ /* Parsed peer view (what the parser extracts from a received action frame) */ /* ------------------------------------------------------------------------ */ diff --git a/firmware/spikes/components/awdl/test/host/test_awdl_frame.c b/firmware/spikes/components/awdl/test/host/test_awdl_frame.c index 63eaa86..ee4c779 100644 --- a/firmware/spikes/components/awdl/test/host/test_awdl_frame.c +++ b/firmware/spikes/components/awdl/test/host/test_awdl_frame.c @@ -297,7 +297,9 @@ static void iphone_announce(awdl_sync_t *iph, uint64_t t, const uint8_t ip[6], pv->has_chanseq = true; pv->chanseq_enc = AWDL_CHAN_ENC_OPCLASS; pv->chanseq[0] = 6; - pv->chanseq[8] = 6; /* present on channel 6 in slots 0 and 8 */ + pv->chanseq[2] = 44; /* a 5 GHz slot the C3 cannot reach */ + pv->chanseq[8] = 6; + pv->chanseq[10] = 44; pv->has_election = true; memcpy(pv->master_addr, ip, 6); /* its own top master */ pv->el_height = 0; @@ -357,6 +359,43 @@ static void test_node_sync_hold(void) { CHECK(!st.have_master, "master should be gone after the peer aged out"); } +static void test_node_chanseq_mirror(void) { + uint8_t me[6] = {0x02, 0, 0, 0, 0, 0x01}; + uint8_t ip[6] = {0xca, 0x2a, 0xd6, 0x10, 0x8e, 0xae}; + + awdl_node_t n; + awdl_node_init(&n, me, "espdrop-c3", 6, 0); + + awdl_sync_t iph; + awdl_sync_init(&iph, 0); + awdl_peer_view_t pv; + iphone_announce(&iph, 100000, ip, &pv); /* chanseq: 6 at 0/8, 44 at 2/10 */ + awdl_node_on_rx_action(&n, &pv, -40, 100000); + + /* What we now advertise must be channel 6 exactly in the master's channel-6 + * slots (0 and 8) and absent (0) everywhere else - never a flat all-6. */ + uint8_t buf[1500]; + int len = awdl_node_build_action(&n, buf, sizeof(buf), AWDL_SUBTYPE_MIF, + 150000); + CHECK(len > 0, "build our MIF: %d", len); + awdl_peer_view_t ours; + CHECK(awdl_parse_action(buf, (size_t)len, &ours) == AWDL_OK, "parse our MIF"); + CHECK(ours.has_chanseq, "our MIF carries no chanseq"); + for (int i = 0; i < (int)AWDL_CHANSEQ_LEN; i++) { + uint8_t expect = (i == 0 || i == 8) ? 6 : 0; + CHECK(ours.chanseq[i] == expect, "our chanseq[%d]=%u expected %u", + i, ours.chanseq[i], expect); + } + + /* After the master ages out, fall back to the lone static all-6 sequence. */ + awdl_node_tick(&n, 100000 + 3000000); + len = awdl_node_build_action(&n, buf, sizeof(buf), AWDL_SUBTYPE_MIF, 3200000); + CHECK(awdl_parse_action(buf, (size_t)len, &ours) == AWDL_OK, "parse MIF 2"); + for (int i = 0; i < (int)AWDL_CHANSEQ_LEN; i++) + CHECK(ours.chanseq[i] == 6, "fallback chanseq[%d]=%u expected 6", + i, ours.chanseq[i]); +} + int main(void) { test_mif_roundtrip(); test_psf_roundtrip(); @@ -368,6 +407,7 @@ int main(void) { test_builder_respects_cap(); test_truncated_v2_no_clobber(); test_node_sync_hold(); + test_node_chanseq_mirror(); if (g_fail == 0) printf("\nALL TESTS PASSED\n"); else printf("\n%d CHECK(S) FAILED\n", g_fail); From c2aab9f2fd962ae94fe72de5961c6e9f784b65c3 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 15 Oct 2026 05:54:51 +0000 Subject: [PATCH 16/99] awdl: harden channel-sequence mirroring (review follow-ups) Aus dem Design-Review der Kanalsequenz-Spiegelung: - Leere Schnittmenge (Master ohne Ch-6-Slot) faellt jetzt auf die statische Alles-6-Sequenz zurueck statt 'nirgends praesent' zu bewerben. - TX ist slot-bewusst: PSF/MIF gehen nur in Slots raus, die wir als praesent bewerben (awdl_node_tx_slot_ready); so senden wir nicht in als abwesend beworbenen Slots und treffen die Overlap-Slots des iPhones. Konsolen-tx sendet weiterhin sofort (force). Eigener Sync bleibt empfangsgetrieben. - Header-Kommentar entschaerft: Akzeptanz einer Nur-Ch6-Sequenz und von Slot-0=0 ist auf Hardware noch offen (M2). Host-Tests ergaenzt (leere Schnittmenge, realistische Slot-0-Infra-Sequenz, TX-Slot-Gating); inkl. ASan/UBSan gruen, C3-Build sauber. Keine Code-Bugs im Review, nur diese Design-Punkte. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01XzgaN4Bv1m8Ka9iqo7QVUN --- firmware/spikes/components/awdl/awdl_frame.c | 13 ++++ firmware/spikes/components/awdl/awdl_node.c | 5 ++ .../components/awdl/include/awdl_frame.h | 16 +++-- .../components/awdl/include/awdl_node.h | 10 ++++ .../awdl/test/host/test_awdl_frame.c | 59 +++++++++++++++++++ firmware/spikes/main/awdl_app.c | 48 +++++++++------ 6 files changed, 130 insertions(+), 21 deletions(-) diff --git a/firmware/spikes/components/awdl/awdl_frame.c b/firmware/spikes/components/awdl/awdl_frame.c index cdd1e1a..4b0dacd 100644 --- a/firmware/spikes/components/awdl/awdl_frame.c +++ b/firmware/spikes/components/awdl/awdl_frame.c @@ -107,6 +107,19 @@ void awdl_self_set_chanseq_static(awdl_self_t *self, uint8_t chan_num) { void awdl_self_set_chanseq_intersect(awdl_self_t *self, const uint8_t master_chanseq[AWDL_CHANSEQ_LEN], uint8_t keep_chan) { + unsigned present = 0; + for (unsigned i = 0; i < AWDL_CHANSEQ_LEN; i++) + if (master_chanseq[i] == keep_chan) present++; + + /* Never advertise "present nowhere": if the master names keep_chan in no + * slot, fall back to the static sequence. We really are on keep_chan in + * every slot, so claiming all 16 is honest and keeps us discoverable; an + * all-absent sequence would be strictly worse than the old flat one. */ + if (present == 0) { + awdl_self_set_chanseq_static(self, keep_chan); + return; + } + uint8_t opclass = opclass_for(keep_chan); for (unsigned i = 0; i < AWDL_CHANSEQ_LEN; i++) { if (master_chanseq[i] == keep_chan) { diff --git a/firmware/spikes/components/awdl/awdl_node.c b/firmware/spikes/components/awdl/awdl_node.c index ddda4b0..9d7948b 100644 --- a/firmware/spikes/components/awdl/awdl_node.c +++ b/firmware/spikes/components/awdl/awdl_node.c @@ -114,6 +114,11 @@ int awdl_node_build_action(awdl_node_t *n, uint8_t *buf, size_t cap, return awdl_build_action(buf, cap, &n->self, subtype, now_us, txd, awc); } +bool awdl_node_tx_slot_ready(const awdl_node_t *n, uint64_t now_us) { + uint16_t slot = awdl_sync_current_eaw(now_us, &n->sync) % AWDL_CHANSEQ_LEN; + return n->self.seq[slot][0] != 0; /* non-zero channel => we advertise presence */ +} + void awdl_node_status(const awdl_node_t *n, awdl_node_status_t *out) { out->have_master = n->have_master; memcpy(out->master_addr, n->election.sync_addr, 6); diff --git a/firmware/spikes/components/awdl/include/awdl_frame.h b/firmware/spikes/components/awdl/include/awdl_frame.h index c33e2c3..b896e36 100644 --- a/firmware/spikes/components/awdl/include/awdl_frame.h +++ b/firmware/spikes/components/awdl/include/awdl_frame.h @@ -143,10 +143,18 @@ void awdl_self_set_chanseq_static(awdl_self_t *self, uint8_t chan_num); /* * Mirror a master's channel sequence into ours: advertise keep_chan only in the - * slots where the master is on keep_chan, and "absent" (0) everywhere else. - * On a single-band device this is the honest sequence and the structurally - * Apple-like shape that an iPhone will answer, unlike a flat all-one-channel - * sequence (see docs/UEBERBLICK.md and the airdrop-mt7921 findings). + * slots where the master is on keep_chan, and "absent" (0) everywhere else. On a + * single-band device this is the honest sequence (we can only be on keep_chan), + * and far closer to the sparse shape Apple emits than a flat all-one-channel + * sequence, which iOS rejects (airdrop-mt7921 findings; docs/UEBERBLICK.md). + * + * If the master names keep_chan in NO slot, this falls back to the static + * all-keep_chan sequence rather than advertising presence nowhere. + * + * NOT yet confirmed on hardware: whether iOS accepts a sequence that carries 0 + * in slot 0 (a real Apple device puts its infra channel there, which we have + * none of) or that contains only keep_chan. Treat acceptance as an open + * question to validate against a real iPhone (see M2). */ void awdl_self_set_chanseq_intersect(awdl_self_t *self, const uint8_t master_chanseq[AWDL_CHANSEQ_LEN], diff --git a/firmware/spikes/components/awdl/include/awdl_node.h b/firmware/spikes/components/awdl/include/awdl_node.h index 8d6e6e2..331731e 100644 --- a/firmware/spikes/components/awdl/include/awdl_node.h +++ b/firmware/spikes/components/awdl/include/awdl_node.h @@ -59,6 +59,16 @@ void awdl_node_tick(awdl_node_t *n, uint64_t now_us); int awdl_node_build_action(awdl_node_t *n, uint8_t *buf, size_t cap, uint8_t subtype, uint64_t now_us); +/* + * True if, at now_us, the EAW slot we are in is one our advertised channel + * sequence marks as present (non-zero). Transmitting only in these slots keeps + * our behaviour consistent with what we advertise, and (since we are physically + * on our channel every slot) lines our frames up with the slots the master is + * also on that channel, i.e. when it can actually hear us. When we advertise a + * flat sequence (no master), every slot is present, so this is always true. + */ +bool awdl_node_tx_slot_ready(const awdl_node_t *n, uint64_t now_us); + typedef struct { bool have_master; uint8_t master_addr[6]; diff --git a/firmware/spikes/components/awdl/test/host/test_awdl_frame.c b/firmware/spikes/components/awdl/test/host/test_awdl_frame.c index ee4c779..0d472cb 100644 --- a/firmware/spikes/components/awdl/test/host/test_awdl_frame.c +++ b/firmware/spikes/components/awdl/test/host/test_awdl_frame.c @@ -396,6 +396,62 @@ static void test_node_chanseq_mirror(void) { i, ours.chanseq[i]); } +static void test_chanseq_empty_intersection(void) { + uint8_t me[6] = {0x02, 0, 0, 0, 0, 0x01}; + awdl_self_t self; + awdl_self_init(&self, me, "x", 6); + /* master with no channel-6 slot at all: must fall back to static all-6, + * never advertise presence nowhere. */ + uint8_t mseq[AWDL_CHANSEQ_LEN] = {0}; + mseq[2] = 44; mseq[10] = 149; + awdl_self_set_chanseq_intersect(&self, mseq, 6); + for (int i = 0; i < (int)AWDL_CHANSEQ_LEN; i++) + CHECK(self.seq[i][0] == 6, "empty-intersection fallback slot %d = %u", + i, self.seq[i][0]); +} + +static void test_chanseq_realistic_slot0(void) { + /* A realistic idle iPhone sequence: slot 0 is a 5 GHz infra channel, ch6 + * appears only at slot 8. We advertise 6 at slot 8 and absent (0) elsewhere, + * slot 0 included. This documents the actual behaviour; whether iOS accepts + * slot-0-absent is an open hardware question (see awdl_frame.h). */ + uint8_t me[6] = {0x02, 0, 0, 0, 0, 0x01}; + awdl_self_t self; + awdl_self_init(&self, me, "x", 6); + uint8_t mseq[AWDL_CHANSEQ_LEN] = {0}; + mseq[0] = 132; mseq[2] = 149; mseq[8] = 6; mseq[10] = 149; + awdl_self_set_chanseq_intersect(&self, mseq, 6); + for (int i = 0; i < (int)AWDL_CHANSEQ_LEN; i++) { + uint8_t exp = (i == 8) ? 6 : 0; + CHECK(self.seq[i][0] == exp, "slot0-infra case slot %d = %u expected %u", + i, self.seq[i][0], exp); + } +} + +static void test_tx_slot_gating(void) { + uint8_t me[6] = {0x02, 0, 0, 0, 0, 0x01}; + awdl_node_t n; + awdl_node_init(&n, me, "x", 6, 0); + + /* Alone (static all-6): every slot is present, so TX is always ready. */ + uint64_t eaw_us = (uint64_t)AWDL_EAW_TU * AWDL_TU_US; + for (int s = 0; s < (int)AWDL_CHANSEQ_LEN; s++) + CHECK(awdl_node_tx_slot_ready(&n, (uint64_t)s * eaw_us + 1000), + "static: slot %d not ready", s); + + /* Sparse (ch6 only in slots 0 and 8): ready only in those slots. */ + uint8_t mseq[AWDL_CHANSEQ_LEN] = {0}; + mseq[0] = 6; mseq[8] = 6; + awdl_self_set_chanseq_intersect(&n.self, mseq, 6); + awdl_sync_init(&n.sync, 0); + for (int s = 0; s < (int)AWDL_CHANSEQ_LEN; s++) { + bool present = (s == 0 || s == 8); + bool ready = awdl_node_tx_slot_ready(&n, (uint64_t)s * eaw_us + 1000); + CHECK(ready == present, "sparse: slot %d ready=%d expected %d", + s, ready, present); + } +} + int main(void) { test_mif_roundtrip(); test_psf_roundtrip(); @@ -408,6 +464,9 @@ int main(void) { test_truncated_v2_no_clobber(); test_node_sync_hold(); test_node_chanseq_mirror(); + test_chanseq_empty_intersection(); + test_chanseq_realistic_slot0(); + test_tx_slot_gating(); if (g_fail == 0) printf("\nALL TESTS PASSED\n"); else printf("\n%d CHECK(S) FAILED\n", g_fail); diff --git a/firmware/spikes/main/awdl_app.c b/firmware/spikes/main/awdl_app.c index 5189e36..4c58819 100644 --- a/firmware/spikes/main/awdl_app.c +++ b/firmware/spikes/main/awdl_app.c @@ -19,8 +19,10 @@ static const char *TAG = "awdl_app"; -/* How often we emit our own frames and clean the peer table. PSF timing is not - * critical for M1 (sync is receive-driven); these just keep us advertising. */ +/* How often we emit our own frames and clean the peer table. These are upper + * bounds: send_frame() additionally gates on the advertised slot (see there), + * so a due frame waits for a slot we advertise as present. Our own sync stays + * accurate regardless, because it is driven by the master's received frames. */ #define PSF_INTERVAL_US (AWDL_PSF_INTERVAL_TU * AWDL_TU_US) /* ~112 ms */ #define MIF_INTERVAL_US (6 * AWDL_PSF_INTERVAL_TU * AWDL_TU_US) /* ~675 ms */ #define TICK_INTERVAL_US AWDL_PEER_CLEAN_INTERVAL_US /* 1 s */ @@ -52,17 +54,30 @@ static void on_action(const awdl_peer_view_t *pv, int8_t rssi, uint8_t channel, (void)xQueueSend(s_rx_q, &ev, 0); } -static void send_frame(uint8_t subtype) { - /* Static (not on the stack): send_frame runs on both the awdl task and the - * console task, and a 1500-byte stack frame would risk overflowing either. - * The mutex serialises use of the shared buffer and the node. */ +/* + * Build and transmit a PSF/MIF. When force is false, the frame is only sent if + * the current EAW slot is one we advertise presence in (so we do not announce + * ourselves in slots our own sequence marks absent, and our frames land when + * the master is on our channel). Returns true if a frame was actually sent. + * The mutex is NOT recursive, so the slot check happens under the same single + * lock acquisition as the build. The 1500-byte buffer is static because this + * runs on both the awdl task and the console task, and a stack frame that large + * would risk overflowing either. + */ +static bool send_frame(uint8_t subtype, bool force) { static uint8_t buf[1500]; + bool sent = false; + uint64_t now = (uint64_t)esp_timer_get_time(); xSemaphoreTake(s_lock, portMAX_DELAY); - int n = awdl_node_build_action(&s_node, buf, sizeof(buf), subtype, - (uint64_t)esp_timer_get_time()); - if (n > 0) - radio_tx(buf, n); + if (force || awdl_node_tx_slot_ready(&s_node, now)) { + int n = awdl_node_build_action(&s_node, buf, sizeof(buf), subtype, now); + if (n > 0) { + radio_tx(buf, n); + sent = true; + } + } xSemaphoreGive(s_lock); + return sent; } static void awdl_task(void *arg) { @@ -81,14 +96,13 @@ static void awdl_task(void *arg) { } now = esp_timer_get_time(); - if (now >= next_psf) { - send_frame(AWDL_SUBTYPE_PSF); + /* Only advance the timer once the frame actually went out; if the + * current slot is one we advertise as absent, retry on the next loop + * (~20 ms) until we reach an advertised-present slot. */ + if (now >= next_psf && send_frame(AWDL_SUBTYPE_PSF, false)) next_psf = now + PSF_INTERVAL_US; - } - if (now >= next_mif) { - send_frame(AWDL_SUBTYPE_MIF); + if (now >= next_mif && send_frame(AWDL_SUBTYPE_MIF, false)) next_mif = now + MIF_INTERVAL_US; - } if (now >= next_tick) { xSemaphoreTake(s_lock, portMAX_DELAY); awdl_node_tick(&s_node, (uint64_t)now); @@ -158,5 +172,5 @@ int awdl_app_format_peers(char *buf, size_t cap) { void awdl_app_tx(uint8_t subtype) { if (!s_started) return; - send_frame(subtype); + send_frame(subtype, true); /* console `tx`: send now regardless of slot */ } From c4096a60a28872aa6ec3a0a5d91971cbc1de0127 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 15 Oct 2026 14:33:01 +0000 Subject: [PATCH 17/99] bridge: M2.0 - COBS USB host-link transport + echo (host-tested) Erster Baustein des M2-Datenpfads: ein COBS-gerahmtes, typ-getaggtes Nachrichtenprotokoll ueber USB-Serial/JTAG. - awdl_cobs.{h,c}: plattformunabhaengiger COBS-Encoder/-Decoder + Byte-weiser Stream-Reassembler. Host-Test mit Vektoren, Kantenfaellen (254/255-Byte- Bloecke, eingebettete Nullen, Overflow, Truncation) und 20000-Iterationen- Fuzz, inkl. ASan/UBSan. - main/bridge.{h,c}: ESP-Transport auf dem nativen usb_serial_jtag-Treiber, RX-Task, Dispatch; M2.0 behandelt ECHO (Transporttest) und LOG. Nachrichten- typen FRAME/PEER_*/CMD fuer spaetere Stufen schon definiert. Log-Ausgabe wird stummgeschaltet, damit sie den Binaerstrom nicht korrumpiert. - Kconfig-Option ESPDROP_BRIDGE_MODE: Bruecken- statt Konsolenmodus; app_main verzweigt. M2.0 bringt WLAN noch nicht hoch (reiner Transporttest). - tools/bridge_echo.py: Host-Echo-Test; sein COBS stimmt byte-genau mit der Firmware ueberein (gegengeprueft, 20k-Fuzz). Beide C3-Builds sauber (Konsole 822 KB, Bruecke 149 KB); Host-Tests gruen. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01XzgaN4Bv1m8Ka9iqo7QVUN --- .gitignore | 1 + .../spikes/components/awdl/CMakeLists.txt | 2 +- firmware/spikes/components/awdl/awdl_cobs.c | 84 +++++++++ .../components/awdl/include/awdl_cobs.h | 75 ++++++++ .../spikes/components/awdl/test/host/Makefile | 24 ++- .../awdl/test/host/test_awdl_cobs.c | 154 ++++++++++++++++ firmware/spikes/main/CMakeLists.txt | 4 +- firmware/spikes/main/Kconfig.projbuild | 12 ++ firmware/spikes/main/app_main.c | 37 +++- firmware/spikes/main/bridge.c | 121 ++++++++++++ firmware/spikes/main/bridge.h | 55 ++++++ tools/bridge_echo.py | 174 ++++++++++++++++++ 12 files changed, 721 insertions(+), 22 deletions(-) create mode 100644 firmware/spikes/components/awdl/awdl_cobs.c create mode 100644 firmware/spikes/components/awdl/include/awdl_cobs.h create mode 100644 firmware/spikes/components/awdl/test/host/test_awdl_cobs.c create mode 100644 firmware/spikes/main/Kconfig.projbuild create mode 100644 firmware/spikes/main/bridge.c create mode 100644 firmware/spikes/main/bridge.h create mode 100755 tools/bridge_echo.py diff --git a/.gitignore b/.gitignore index 2f326a5..1c78ed6 100644 --- a/.gitignore +++ b/.gitignore @@ -36,3 +36,4 @@ logs/ # Native host test binary test_awdl_frame +test_awdl_cobs diff --git a/firmware/spikes/components/awdl/CMakeLists.txt b/firmware/spikes/components/awdl/CMakeLists.txt index 79214b8..0d2703e 100644 --- a/firmware/spikes/components/awdl/CMakeLists.txt +++ b/firmware/spikes/components/awdl/CMakeLists.txt @@ -1,4 +1,4 @@ idf_component_register( - SRCS "awdl_frame.c" "awdl_sync.c" "awdl_election.c" "awdl_peers.c" "awdl_node.c" + SRCS "awdl_frame.c" "awdl_sync.c" "awdl_election.c" "awdl_peers.c" "awdl_node.c" "awdl_cobs.c" INCLUDE_DIRS "include" ) diff --git a/firmware/spikes/components/awdl/awdl_cobs.c b/firmware/spikes/components/awdl/awdl_cobs.c new file mode 100644 index 0000000..5c86fdf --- /dev/null +++ b/firmware/spikes/components/awdl/awdl_cobs.c @@ -0,0 +1,84 @@ +/* + * EspDrop - COBS framing (platform independent). See awdl_cobs.h. + * + * SPDX-License-Identifier: GPL-3.0-or-later + * Copyright (C) 2026 EspDrop contributors + */ +#include "awdl_cobs.h" + +size_t cobs_encode(const uint8_t *in, size_t len, uint8_t *out, size_t out_cap) { + if (out_cap == 0) return 0; + size_t out_i = 0; + size_t code_i = out_i++; /* reserve the first code byte */ + uint8_t code = 1; + + for (size_t i = 0; i < len; i++) { + if (in[i] == 0) { + out[code_i] = code; /* close the current block */ + if (out_i >= out_cap) return 0; + code_i = out_i++; /* reserve the next code byte */ + code = 1; + } else { + if (out_i >= out_cap) return 0; + out[out_i++] = in[i]; + code++; + if (code == 0xFF) { /* maximum-length block: close it */ + out[code_i] = code; + if (out_i >= out_cap) return 0; + code_i = out_i++; /* reserve the next code byte */ + code = 1; + } + } + } + out[code_i] = code; /* close the final block */ + return out_i; +} + +size_t cobs_decode(const uint8_t *in, size_t len, uint8_t *out, size_t out_cap) { + size_t in_i = 0, out_i = 0; + while (in_i < len) { + uint8_t code = in[in_i++]; + if (code == 0) return 0; /* a 0x00 must not appear inside a frame */ + for (uint8_t j = 1; j < code; j++) { + if (in_i >= len) return 0; /* truncated block */ + if (out_i >= out_cap) return 0; /* output overflow */ + uint8_t v = in[in_i++]; + if (v == 0) return 0; /* a well-formed frame has no 0x00 */ + out[out_i++] = v; + } + /* Every block but a maximal (0xFF) one implies a trailing zero, except + * the very last block (whose trailing zero was the stripped delimiter). */ + if (code != 0xFF && in_i < len) { + if (out_i >= out_cap) return 0; + out[out_i++] = 0; + } + } + return out_i; +} + +void cobs_rx_init(cobs_rx_t *rx) { + rx->len = 0; + rx->overflow = false; +} + +int cobs_rx_byte(cobs_rx_t *rx, uint8_t b, uint8_t *out, size_t out_cap) { + if (b != 0x00) { + if (rx->len < sizeof(rx->buf)) + rx->buf[rx->len++] = b; + else + rx->overflow = true; /* frame too long; drop until the next delimiter */ + return 0; + } + + /* delimiter: the current frame ends here */ + if (rx->overflow) { /* frame was too long */ + cobs_rx_init(rx); + return -1; + } + if (rx->len == 0) /* empty frame (leading/duplicate delimiter) */ + return 0; /* ignore silently */ + + size_t n = cobs_decode(rx->buf, rx->len, out, out_cap); + cobs_rx_init(rx); + return (n == 0) ? -1 : (int)n; +} diff --git a/firmware/spikes/components/awdl/include/awdl_cobs.h b/firmware/spikes/components/awdl/include/awdl_cobs.h new file mode 100644 index 0000000..e3bf0f9 --- /dev/null +++ b/firmware/spikes/components/awdl/include/awdl_cobs.h @@ -0,0 +1,75 @@ +/* + * EspDrop - COBS framing for the USB host link (platform independent). + * + * Consistent Overhead Byte Stuffing: encode a message so it contains no 0x00 + * byte, then a single 0x00 delimiter marks the end of a frame on the wire. + * This gives us unambiguous message boundaries over the USB-Serial/JTAG stream, + * which carries binary Ethernet frames and control messages (see the M2 plan). + * + * Pure C, no ESP-IDF: encode/decode and a byte-at-a-time stream reassembler, + * all host-testable. + * + * SPDX-License-Identifier: GPL-3.0-or-later + * Copyright (C) 2026 EspDrop contributors + */ +#ifndef ESPDROP_AWDL_COBS_H +#define ESPDROP_AWDL_COBS_H + +#include +#include +#include + +#ifdef __cplusplus +extern "C" { +#endif + +/* Worst-case COBS overhead is one byte per 254 input bytes, plus a leading + * code byte. A caller sizing an output buffer can use this bound. */ +#define COBS_ENCODE_BOUND(n) ((n) + (n) / 254 + 2) + +/* + * Encode `len` bytes from `in` into `out` (capacity `out_cap`). The result + * contains no 0x00 byte; the caller appends a single 0x00 delimiter on the + * wire. Returns the encoded length, or 0 on output overflow. + */ +size_t cobs_encode(const uint8_t *in, size_t len, uint8_t *out, size_t out_cap); + +/* + * Decode `len` COBS bytes from `in` (the frame contents, WITHOUT the trailing + * 0x00 delimiter) into `out` (capacity `out_cap`). Returns the decoded length, + * or 0 on a malformed frame or output overflow. (A genuinely empty decode also + * returns 0; callers never frame empty messages, so 0 means "drop".) + */ +size_t cobs_decode(const uint8_t *in, size_t len, uint8_t *out, size_t out_cap); + +/* ---- byte-at-a-time stream reassembler ------------------------------------ + * Feed received bytes one at a time; a complete frame is signalled when the + * 0x00 delimiter arrives. + */ +#ifndef COBS_RX_CAP +#define COBS_RX_CAP 1664 /* enough for a 1514-byte Ethernet frame + type + COBS */ +#endif + +typedef struct { + uint8_t buf[COBS_RX_CAP]; /* accumulated encoded bytes (no delimiter) */ + size_t len; + bool overflow; /* a frame exceeded the buffer; drop to next delim */ +} cobs_rx_t; + +void cobs_rx_init(cobs_rx_t *rx); + +/* + * Feed one received byte. Returns: + * 0 : byte accumulated, no complete frame yet + * >0 : a frame completed; its decoded bytes are in `out` and the return + * value is the decoded length (1..out_cap) + * -1 : a frame completed but was malformed / overflowed (nothing usable); + * the reassembler has reset and is ready for the next frame + */ +int cobs_rx_byte(cobs_rx_t *rx, uint8_t b, uint8_t *out, size_t out_cap); + +#ifdef __cplusplus +} +#endif + +#endif /* ESPDROP_AWDL_COBS_H */ diff --git a/firmware/spikes/components/awdl/test/host/Makefile b/firmware/spikes/components/awdl/test/host/Makefile index 36eecc9..f2f0c83 100644 --- a/firmware/spikes/components/awdl/test/host/Makefile +++ b/firmware/spikes/components/awdl/test/host/Makefile @@ -1,4 +1,4 @@ -# Native host build of the platform-independent AWDL frame unit test. +# Native host build of the platform-independent unit tests. # No ESP-IDF required: pure C against the component's own headers. # # SPDX-License-Identifier: GPL-3.0-or-later @@ -8,19 +8,25 @@ CFLAGS ?= -std=c11 -Wall -Wextra -Werror -O2 -g COMP = ../.. INCLUDE = -I$(COMP)/include -SRCS = $(COMP)/awdl_frame.c $(COMP)/awdl_sync.c $(COMP)/awdl_election.c \ - $(COMP)/awdl_peers.c $(COMP)/awdl_node.c test_awdl_frame.c -BIN = test_awdl_frame +# Platform-independent sources under test. +LIB = $(COMP)/awdl_frame.c $(COMP)/awdl_sync.c $(COMP)/awdl_election.c \ + $(COMP)/awdl_peers.c $(COMP)/awdl_node.c $(COMP)/awdl_cobs.c HEADERS = $(wildcard $(COMP)/include/*.h) +BINS = test_awdl_frame test_awdl_cobs + .PHONY: all run clean all: run -$(BIN): $(SRCS) $(HEADERS) - $(CC) $(CFLAGS) $(INCLUDE) $(SRCS) -o $(BIN) +test_awdl_frame: $(LIB) test_awdl_frame.c $(HEADERS) + $(CC) $(CFLAGS) $(INCLUDE) $(LIB) test_awdl_frame.c -o $@ + +test_awdl_cobs: $(LIB) test_awdl_cobs.c $(HEADERS) + $(CC) $(CFLAGS) $(INCLUDE) $(LIB) test_awdl_cobs.c -o $@ -run: $(BIN) - ./$(BIN) +run: $(BINS) + ./test_awdl_frame + ./test_awdl_cobs clean: - rm -f $(BIN) + rm -f $(BINS) diff --git a/firmware/spikes/components/awdl/test/host/test_awdl_cobs.c b/firmware/spikes/components/awdl/test/host/test_awdl_cobs.c new file mode 100644 index 0000000..a592b92 --- /dev/null +++ b/firmware/spikes/components/awdl/test/host/test_awdl_cobs.c @@ -0,0 +1,154 @@ +/* + * EspDrop - native host unit test for the COBS codec and stream reassembler. + * + * Build & run: make -C firmware/spikes/components/awdl/test/host run + * + * SPDX-License-Identifier: GPL-3.0-or-later + * Copyright (C) 2026 EspDrop contributors + */ +#include "awdl_cobs.h" + +#include +#include + +static int g_fail; + +#define CHECK(cond, ...) do { \ + if (!(cond)) { printf("FAIL %s:%d: ", __func__, __LINE__); printf(__VA_ARGS__); printf("\n"); g_fail++; } \ +} while (0) + +/* encode then decode must reproduce the input exactly */ +static void roundtrip(const uint8_t *in, size_t len, const char *label) { + uint8_t enc[COBS_ENCODE_BOUND(2048)]; + uint8_t dec[2048]; + size_t e = cobs_encode(in, len, enc, sizeof(enc)); + CHECK(e > 0 || len == 0, "%s: encode returned 0 for len %zu", label, len); + /* the encoded form must contain no zero byte */ + for (size_t i = 0; i < e; i++) + CHECK(enc[i] != 0, "%s: encoded byte %zu is zero", label, i); + size_t d = cobs_decode(enc, e, dec, sizeof(dec)); + CHECK(d == len, "%s: decoded len %zu != %zu", label, d, len); + CHECK(memcmp(dec, in, len) == 0, "%s: decoded payload differs", label); +} + +static void test_vectors(void) { + uint8_t a[] = {0x11, 0x22, 0x00, 0x33}; + roundtrip(a, sizeof(a), "mixed"); + + uint8_t z1[] = {0x00}; + roundtrip(z1, sizeof(z1), "single-zero"); + + uint8_t z3[] = {0x00, 0x00, 0x00}; + roundtrip(z3, sizeof(z3), "zeros"); + + uint8_t one[] = {0x42}; + roundtrip(one, sizeof(one), "one"); + + /* a 254-byte non-zero run (exercises the 0xFF block boundary) and 255 */ + uint8_t r254[254], r255[255], r300[300]; + for (int i = 0; i < 254; i++) r254[i] = (uint8_t)(i + 1); + for (int i = 0; i < 255; i++) r255[i] = (uint8_t)((i % 255) + 1); + for (int i = 0; i < 300; i++) r300[i] = (uint8_t)((i % 255) + 1); + roundtrip(r254, sizeof(r254), "run254"); + roundtrip(r255, sizeof(r255), "run255"); + roundtrip(r300, sizeof(r300), "run300"); + + /* zeros embedded around long runs */ + uint8_t mix[600]; + for (int i = 0; i < 600; i++) mix[i] = (i % 7 == 0) ? 0 : (uint8_t)((i & 0xff) | 1); + roundtrip(mix, sizeof(mix), "mix600"); +} + +/* deterministic pseudo-random fuzz: encode->decode identity over many buffers */ +static void test_fuzz(void) { + uint32_t s = 0x12345678u; + for (int iter = 0; iter < 20000; iter++) { + uint8_t in[400]; + s = s * 1103515245u + 12345u; + size_t len = (s >> 16) % (sizeof(in) + 1); + for (size_t i = 0; i < len; i++) { + s = s * 1103515245u + 12345u; + /* bias toward zeros so block boundaries get exercised */ + in[i] = ((s >> 20) % 4 == 0) ? 0 : (uint8_t)(s >> 8); + } + uint8_t enc[COBS_ENCODE_BOUND(sizeof(in))]; + uint8_t dec[sizeof(in)]; + size_t e = cobs_encode(in, len, enc, sizeof(enc)); + for (size_t i = 0; i < e; i++) + if (enc[i] == 0) { CHECK(0, "fuzz iter %d: zero in encoded", iter); return; } + size_t d = cobs_decode(enc, e, dec, sizeof(dec)); + if (d != len || memcmp(dec, in, len) != 0) { + CHECK(0, "fuzz iter %d: roundtrip mismatch (len %zu, d %zu)", iter, len, d); + return; + } + } +} + +static void test_encode_overflow(void) { + uint8_t in[300]; + memset(in, 0x55, sizeof(in)); + uint8_t small[8]; + CHECK(cobs_encode(in, sizeof(in), small, sizeof(small)) == 0, + "encode into too-small buffer should return 0"); +} + +static void test_decode_bad(void) { + /* a zero byte inside the frame body is malformed */ + uint8_t bad[] = {0x03, 0x11, 0x00}; + uint8_t out[16]; + CHECK(cobs_decode(bad, sizeof(bad), out, sizeof(out)) == 0, + "decode of frame containing 0x00 should fail"); + /* a code that points past the end is truncated */ + uint8_t trunc[] = {0x05, 0x11, 0x22}; + CHECK(cobs_decode(trunc, sizeof(trunc), out, sizeof(out)) == 0, + "decode of truncated block should fail"); +} + +/* feed an encoded frame + delimiter through the stream reassembler */ +static void test_stream(void) { + cobs_rx_t rx; + cobs_rx_init(&rx); + + uint8_t msg[] = {0x07, 'h', 'i', 0x00, 'x'}; /* includes a zero */ + uint8_t enc[COBS_ENCODE_BOUND(sizeof(msg))]; + size_t e = cobs_encode(msg, sizeof(msg), enc, sizeof(enc)); + + uint8_t out[64]; + /* leading stray delimiter must be ignored */ + CHECK(cobs_rx_byte(&rx, 0x00, out, sizeof(out)) == 0, "leading delim not ignored"); + int r = 0; + for (size_t i = 0; i < e; i++) + CHECK(cobs_rx_byte(&rx, enc[i], out, sizeof(out)) == 0, "mid-frame returned a frame"); + r = cobs_rx_byte(&rx, 0x00, out, sizeof(out)); /* delimiter completes it */ + CHECK(r == (int)sizeof(msg), "stream frame len %d != %zu", r, sizeof(msg)); + CHECK(memcmp(out, msg, sizeof(msg)) == 0, "stream payload differs"); + + /* a second, back-to-back frame */ + uint8_t msg2[] = {0x04, 0xAA, 0xBB}; + e = cobs_encode(msg2, sizeof(msg2), enc, sizeof(enc)); + for (size_t i = 0; i < e; i++) cobs_rx_byte(&rx, enc[i], out, sizeof(out)); + r = cobs_rx_byte(&rx, 0x00, out, sizeof(out)); + CHECK(r == (int)sizeof(msg2) && memcmp(out, msg2, sizeof(msg2)) == 0, "second frame"); + + /* overflow: more non-zero bytes than the buffer holds -> -1 at delimiter */ + cobs_rx_init(&rx); + for (size_t i = 0; i < COBS_RX_CAP + 10; i++) cobs_rx_byte(&rx, 0x42, out, sizeof(out)); + CHECK(cobs_rx_byte(&rx, 0x00, out, sizeof(out)) == -1, "overflow not reported"); + /* reassembler recovers for the next frame */ + e = cobs_encode(msg2, sizeof(msg2), enc, sizeof(enc)); + for (size_t i = 0; i < e; i++) cobs_rx_byte(&rx, enc[i], out, sizeof(out)); + CHECK(cobs_rx_byte(&rx, 0x00, out, sizeof(out)) == (int)sizeof(msg2), + "no recovery after overflow"); +} + +int main(void) { + test_vectors(); + test_fuzz(); + test_encode_overflow(); + test_decode_bad(); + test_stream(); + + if (g_fail == 0) printf("COBS: ALL TESTS PASSED\n"); + else printf("COBS: %d CHECK(S) FAILED\n", g_fail); + return g_fail ? 1 : 0; +} diff --git a/firmware/spikes/main/CMakeLists.txt b/firmware/spikes/main/CMakeLists.txt index 9486849..00b6a45 100644 --- a/firmware/spikes/main/CMakeLists.txt +++ b/firmware/spikes/main/CMakeLists.txt @@ -1,5 +1,5 @@ idf_component_register( - SRCS "app_main.c" "radio.c" "console.c" "awdl_app.c" + SRCS "app_main.c" "radio.c" "console.c" "awdl_app.c" "bridge.c" INCLUDE_DIRS "." - REQUIRES awdl esp_wifi esp_event esp_netif nvs_flash console esp_timer + REQUIRES awdl esp_wifi esp_event esp_netif nvs_flash console esp_timer esp_driver_usb_serial_jtag ) diff --git a/firmware/spikes/main/Kconfig.projbuild b/firmware/spikes/main/Kconfig.projbuild new file mode 100644 index 0000000..2bf70b2 --- /dev/null +++ b/firmware/spikes/main/Kconfig.projbuild @@ -0,0 +1,12 @@ +menu "EspDrop" + +config ESPDROP_BRIDGE_MODE + bool "Bridge mode (USB host-link instead of interactive console)" + default n + help + When enabled, the firmware starts the COBS-framed USB host-link bridge + (see bridge.c / the M2 data-path plan) instead of the interactive serial + console. Use this build to run the host-side awdl0 bridge and the M2.0 + echo test. Leave it off for the M0/M1 console workflow (tx/rx/sync/...). + +endmenu diff --git a/firmware/spikes/main/app_main.c b/firmware/spikes/main/app_main.c index 91886fb..31087b0 100644 --- a/firmware/spikes/main/app_main.c +++ b/firmware/spikes/main/app_main.c @@ -1,29 +1,44 @@ /* - * EspDrop - AWDL hardware spikes for the ESP32-C3. + * EspDrop - AWDL firmware for the ESP32-C3. * - * Milestone M0: prove the ESP32-C3 radio can do what AWDL needs. Brings up - * Wi-Fi, starts a USB-serial console, and lets you inject AWDL action/data - * frames, sniff AWDL frames from nearby Apple devices, and measure channel - * switch latency and USB throughput. See console.c for the command list and - * docs/SPIKES.md for the test plan. + * Two build modes (menuconfig -> EspDrop -> Bridge mode): + * - Console mode (default): brings up Wi-Fi + the AWDL node and an interactive + * USB-serial console (tx/rx/sync/peers/...). Used for the M0/M1 workflow; + * see console.c and docs/SPIKES.md. + * - Bridge mode (CONFIG_ESPDROP_BRIDGE_MODE): starts the COBS-framed USB + * host-link bridge instead of the console, for the M2 data path. M2.0 is a + * transport echo test and does not bring up Wi-Fi yet. * * SPDX-License-Identifier: GPL-3.0-or-later * Copyright (C) 2026 EspDrop contributors */ #include -#include "esp_console.h" #include "esp_log.h" +#include "sdkconfig.h" + +#define DEFAULT_CHANNEL 6 /* AWDL 2.4 GHz social channel, the only one on the C3 */ + +#if CONFIG_ESPDROP_BRIDGE_MODE +#include "bridge.h" + +void app_main(void) { + /* M2.0: transport only. Wi-Fi / AWDL data wiring comes in later M2 stages. */ + if (bridge_start() != 0) + ESP_LOGE("espdrop", "bridge_start failed"); +} + +#else /* console mode */ + +#include "esp_console.h" #include "radio.h" #include "awdl_app.h" -#define DEFAULT_CHANNEL 6 /* AWDL 2.4 GHz social channel, the only one on the C3 */ - void console_register_commands(void); void app_main(void) { - ESP_LOGI("espdrop", "EspDrop AWDL spike (M1) starting"); + ESP_LOGI("espdrop", "EspDrop AWDL (console mode) starting"); int err = radio_init(DEFAULT_CHANNEL); if (err != ESP_OK) { @@ -52,3 +67,5 @@ void app_main(void) { ESP_ERROR_CHECK(esp_console_start_repl(repl)); ESP_LOGI("espdrop", "console ready; type 'help'"); } + +#endif /* CONFIG_ESPDROP_BRIDGE_MODE */ diff --git a/firmware/spikes/main/bridge.c b/firmware/spikes/main/bridge.c new file mode 100644 index 0000000..cda2d09 --- /dev/null +++ b/firmware/spikes/main/bridge.c @@ -0,0 +1,121 @@ +/* + * EspDrop - USB host-link bridge transport (ESP32). See bridge.h. + * + * SPDX-License-Identifier: GPL-3.0-or-later + * Copyright (C) 2026 EspDrop contributors + */ +#include "bridge.h" + +#include +#include +#include + +#include "driver/usb_serial_jtag.h" +#include "esp_log.h" +#include "freertos/FreeRTOS.h" +#include "freertos/task.h" +#include "freertos/semphr.h" + +#include "awdl_cobs.h" + +#define BRIDGE_MAX_PAYLOAD 1600 +#define BRIDGE_MAX_MSG (1 + BRIDGE_MAX_PAYLOAD) /* type + payload */ +#define USB_RX_BUF 1024 +#define USB_TX_BUF 2048 + +static SemaphoreHandle_t s_tx_lock; +static bool s_started; + +/* Dispatch a fully decoded message (type byte + payload). */ +static void handle_msg(const uint8_t *msg, size_t len) { + if (len < 1) return; + uint8_t type = msg[0]; + const uint8_t *payload = msg + 1; + size_t plen = len - 1; + + switch (type) { + case BRIDGE_MSG_ECHO: + bridge_send(BRIDGE_MSG_ECHO_REPLY, payload, plen); + break; + /* FRAME / CMD / PEER_* are wired up in later M2 stages. */ + default: + break; + } +} + +static void bridge_rx_task(void *arg) { + (void)arg; + static cobs_rx_t rx; + static uint8_t out[BRIDGE_MAX_MSG]; + uint8_t chunk[256]; + cobs_rx_init(&rx); + + for (;;) { + int n = usb_serial_jtag_read_bytes(chunk, sizeof(chunk), portMAX_DELAY); + for (int i = 0; i < n; i++) { + int r = cobs_rx_byte(&rx, chunk[i], out, sizeof(out)); + if (r > 0) + handle_msg(out, (size_t)r); + /* r == -1: malformed/overflowed frame, already reset; ignore. */ + } + } +} + +int bridge_start(void) { + if (s_started) return 0; + + s_tx_lock = xSemaphoreCreateMutex(); + if (!s_tx_lock) return -1; + + usb_serial_jtag_driver_config_t cfg = { + .tx_buffer_size = USB_TX_BUF, + .rx_buffer_size = USB_RX_BUF, + }; + esp_err_t e = usb_serial_jtag_driver_install(&cfg); + if (e != ESP_OK && e != ESP_ERR_INVALID_STATE) /* INVALID_STATE: console already installed it */ + return e; + + /* Nothing else may write to the USB-Serial/JTAG now, or it would corrupt the + * binary frame stream. Silence the default log output; visibility goes + * through BRIDGE_MSG_LOG instead. */ + esp_log_level_set("*", ESP_LOG_NONE); + + if (xTaskCreate(bridge_rx_task, "bridge_rx", 4096, NULL, 6, NULL) != pdPASS) + return -1; + + s_started = true; + bridge_log("bridge up (M2.0 echo)"); + return 0; +} + +int bridge_send(uint8_t type, const void *payload, size_t len) { + if (!s_started || len > BRIDGE_MAX_PAYLOAD) return -1; + + static uint8_t msg[BRIDGE_MAX_MSG]; + static uint8_t enc[COBS_ENCODE_BOUND(BRIDGE_MAX_MSG) + 1]; + + xSemaphoreTake(s_tx_lock, portMAX_DELAY); + msg[0] = type; + if (len && payload) memcpy(msg + 1, payload, len); + size_t mlen = 1 + len; + size_t enc_len = cobs_encode(msg, mlen, enc, sizeof(enc) - 1); + int ret = -1; + if (enc_len > 0) { + enc[enc_len++] = 0x00; /* frame delimiter */ + usb_serial_jtag_write_bytes(enc, enc_len, portMAX_DELAY); + ret = 0; + } + xSemaphoreGive(s_tx_lock); + return ret; +} + +void bridge_log(const char *fmt, ...) { + char buf[200]; + va_list ap; + va_start(ap, fmt); + int n = vsnprintf(buf, sizeof(buf), fmt, ap); + va_end(ap); + if (n < 0) return; + if (n > (int)sizeof(buf) - 1) n = sizeof(buf) - 1; + bridge_send(BRIDGE_MSG_LOG, buf, (size_t)n); +} diff --git a/firmware/spikes/main/bridge.h b/firmware/spikes/main/bridge.h new file mode 100644 index 0000000..5879339 --- /dev/null +++ b/firmware/spikes/main/bridge.h @@ -0,0 +1,55 @@ +/* + * EspDrop - USB host-link bridge transport (ESP32). + * + * A COBS-framed, type-tagged message stream over the built-in USB-Serial/JTAG, + * replacing the interactive console when the firmware is built in bridge mode + * (CONFIG_ESPDROP_BRIDGE_MODE). The platform-independent framing lives in + * awdl_cobs.*; this module owns the USB driver, the RX task and the dispatch. + * + * M2.0 handles only ECHO (for a host-side transport test) and LOG. The data and + * control message types are declared now so the host protocol is fixed, and are + * wired to AWDL in later M2 stages. + * + * Wire format per message: COBS(type_byte || payload) followed by a 0x00 + * delimiter. COBS guarantees the body carries no 0x00, so the delimiter is + * unambiguous and the stream resynchronises after any stray bytes. + * + * SPDX-License-Identifier: GPL-3.0-or-later + * Copyright (C) 2026 EspDrop contributors + */ +#ifndef ESPDROP_BRIDGE_H +#define ESPDROP_BRIDGE_H + +#include +#include + +#ifdef __cplusplus +extern "C" { +#endif + +enum bridge_msg_type { + BRIDGE_MSG_ECHO = 1, /* host -> device: payload echoed back */ + BRIDGE_MSG_ECHO_REPLY = 2, /* device -> host: the echoed payload */ + BRIDGE_MSG_LOG = 3, /* device -> host: UTF-8 log text */ + BRIDGE_MSG_FRAME = 4, /* both: one Ethernet frame for awdl0 (M2.2+) */ + BRIDGE_MSG_PEER_ADD = 5, /* device -> host: 6-byte peer MAC appeared */ + BRIDGE_MSG_PEER_REMOVE = 6, /* device -> host: 6-byte peer MAC gone */ + BRIDGE_MSG_CMD = 7, /* host -> device: console-style command text */ + BRIDGE_MSG_CMD_REPLY = 8, /* device -> host: command output text */ +}; + +/* Start the bridge: install the USB driver, silence stdout logging (so it can + * not corrupt the binary stream), and spawn the RX task. Returns 0 on success. */ +int bridge_start(void); + +/* Send one framed message. Thread-safe. Returns 0 on success. */ +int bridge_send(uint8_t type, const void *payload, size_t len); + +/* printf-style LOG message to the host. */ +void bridge_log(const char *fmt, ...) __attribute__((format(printf, 1, 2))); + +#ifdef __cplusplus +} +#endif + +#endif /* ESPDROP_BRIDGE_H */ diff --git a/tools/bridge_echo.py b/tools/bridge_echo.py new file mode 100755 index 0000000..48c5f7d --- /dev/null +++ b/tools/bridge_echo.py @@ -0,0 +1,174 @@ +#!/usr/bin/env python3 +# EspDrop - M2.0 host-side echo test for the USB bridge transport. +# +# Flash the firmware in bridge mode (menuconfig -> EspDrop -> Bridge mode, i.e. +# CONFIG_ESPDROP_BRIDGE_MODE=y), then run this against the device's USB-Serial/ +# JTAG port. It sends COBS-framed ECHO messages and checks that the device +# returns them byte-for-byte, which validates the framing and USB transport +# before any AWDL data flows. +# +# sudo tools/bridge_echo.py /dev/ttyACM0 # one echo, incl. zeros +# sudo tools/bridge_echo.py /dev/ttyACM0 --count 500 --size 1024 # throughput +# +# The COBS codec here mirrors firmware/spikes/components/awdl/awdl_cobs.c; a +# mismatch would show up immediately as a failed echo. +# +# SPDX-License-Identifier: GPL-3.0-or-later +# Copyright (C) 2026 EspDrop contributors + +import argparse +import os +import select +import sys +import termios +import time +import tty + +MSG_ECHO = 1 +MSG_ECHO_REPLY = 2 +MSG_LOG = 3 + + +def cobs_encode(data: bytes) -> bytes: + out = bytearray() + code_i = len(out) + out.append(0) # placeholder for the first code byte + code = 1 + for b in data: + if b == 0: + out[code_i] = code + code_i = len(out) + out.append(0) + code = 1 + else: + out.append(b) + code += 1 + if code == 0xFF: + out[code_i] = code + code_i = len(out) + out.append(0) + code = 1 + out[code_i] = code + return bytes(out) + + +def cobs_decode(data: bytes) -> bytes: + out = bytearray() + i = 0 + n = len(data) + while i < n: + code = data[i] + i += 1 + if code == 0: + raise ValueError("zero byte inside frame") + for _ in range(code - 1): + if i >= n: + raise ValueError("truncated") + out.append(data[i]) + i += 1 + if code != 0xFF and i < n: + out.append(0) + return bytes(out) + + +def frame(msg_type: int, payload: bytes) -> bytes: + return cobs_encode(bytes([msg_type]) + payload) + b"\x00" + + +class Reader: + """Accumulates bytes and yields (type, payload) per 0x00-delimited frame.""" + def __init__(self): + self.buf = bytearray() + + def feed(self, data: bytes): + for b in data: + if b == 0: + if self.buf: + try: + dec = cobs_decode(bytes(self.buf)) + if dec: + yield dec[0], dec[1:] + except ValueError: + pass + self.buf = bytearray() + else: + self.buf.append(b) + + +def open_raw(port: str) -> int: + fd = os.open(port, os.O_RDWR | os.O_NOCTTY | os.O_NONBLOCK) + try: + tty.setraw(fd) + except termios.error: + pass + return fd + + +def read_for(fd: int, reader: Reader, seconds: float): + end = time.monotonic() + seconds + while time.monotonic() < end: + r, _, _ = select.select([fd], [], [], max(0.0, end - time.monotonic())) + if not r: + continue + try: + data = os.read(fd, 65536) + except BlockingIOError: + continue + if data: + yield from reader.feed(data) + + +def main() -> int: + ap = argparse.ArgumentParser(description="EspDrop bridge echo test") + ap.add_argument("port") + ap.add_argument("--count", type=int, default=1, help="echo messages to send") + ap.add_argument("--size", type=int, default=32, help="payload size per echo") + ap.add_argument("--timeout", type=float, default=2.0, help="per-echo reply timeout") + args = ap.parse_args() + + fd = open_raw(args.port) + reader = Reader() + + # Drain and print any startup LOG lines first. + for t, p in read_for(fd, reader, 0.3): + if t == MSG_LOG: + print(f"[device] {p.decode('utf-8', 'replace')}") + + # A payload that includes 0x00 bytes, to exercise COBS stuffing. + base = bytes((i * 7 + 1) & 0xFF for i in range(args.size)) + payload = bytearray(base) + for k in range(0, args.size, 5): + payload[k] = 0 # scatter zeros through it + payload = bytes(payload) + + ok = 0 + t0 = time.monotonic() + total_bytes = 0 + for n in range(args.count): + os.write(fd, frame(MSG_ECHO, payload)) + got = False + for t, p in read_for(fd, reader, args.timeout): + if t == MSG_LOG: + print(f"[device] {p.decode('utf-8', 'replace')}") + elif t == MSG_ECHO_REPLY: + if p == payload: + ok += 1 + total_bytes += len(p) + got = True + else: + print(f"echo {n}: MISMATCH ({len(p)} bytes back, {len(payload)} sent)") + got = True + break + if not got: + print(f"echo {n}: no reply within {args.timeout}s") + dt = time.monotonic() - t0 + os.close(fd) + + print(f"\n{ok}/{args.count} echoes OK") + if ok and dt > 0: + print(f"~{total_bytes / dt / 1000:.1f} kB/s payload round-tripped over {dt:.2f}s") + return 0 if ok == args.count else 1 + + +if __name__ == "__main__": + sys.exit(main()) From 611d5b02700237fa8c736f543bbddd0537fdb4a5 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 15 Oct 2026 14:40:43 +0000 Subject: [PATCH 18/99] bridge: M2.0 review follow-ups (startup race, short writes, cleanup) Aus dem Design-Review der Bruecke (COBS-Codec war sauber): - s_started wird vor xTaskCreate gesetzt, damit der hoeher-priorisierte RX-Task ein sofort anliegendes ECHO nicht verwirft (haette das erste Echo im Test verschlucken koennen). - bridge_send schreibt den ganzen Frame in einer Schleife ueber Kurz-Writes und nutzt ein beschraenktes Write-Timeout (1 s), damit ein nicht leerender Host nicht den Mutex und damit alle Sender dauerhaft blockiert. - s_tx_lock wird auf den Fehlerpfaden freigegeben. - Log-Stummschaltung ehrlicher kommentiert (ROM-/Panic-Ausgabe bleibt; COBS resynchronisiert am naechsten Delimiter). - RX-Task ignoriert n<=0 defensiv. Beide C3-Builds sauber, Host-Tests inkl. ASan/UBSan gruen. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01XzgaN4Bv1m8Ka9iqo7QVUN --- firmware/spikes/main/bridge.c | 36 +- firmware/spikes/main/bridge.h | 5 +- firmware/spikes/sdkconfig.bridge | 3489 ++++++++++++++++++++++++++++++ 3 files changed, 3521 insertions(+), 9 deletions(-) create mode 100644 firmware/spikes/sdkconfig.bridge diff --git a/firmware/spikes/main/bridge.c b/firmware/spikes/main/bridge.c index cda2d09..8b19420 100644 --- a/firmware/spikes/main/bridge.c +++ b/firmware/spikes/main/bridge.c @@ -52,6 +52,7 @@ static void bridge_rx_task(void *arg) { for (;;) { int n = usb_serial_jtag_read_bytes(chunk, sizeof(chunk), portMAX_DELAY); + if (n <= 0) continue; for (int i = 0; i < n; i++) { int r = cobs_rx_byte(&rx, chunk[i], out, sizeof(out)); if (r > 0) @@ -72,18 +73,30 @@ int bridge_start(void) { .rx_buffer_size = USB_RX_BUF, }; esp_err_t e = usb_serial_jtag_driver_install(&cfg); - if (e != ESP_OK && e != ESP_ERR_INVALID_STATE) /* INVALID_STATE: console already installed it */ + if (e != ESP_OK && e != ESP_ERR_INVALID_STATE) { /* INVALID_STATE: console already installed it */ + vSemaphoreDelete(s_tx_lock); + s_tx_lock = NULL; return e; + } - /* Nothing else may write to the USB-Serial/JTAG now, or it would corrupt the - * binary frame stream. Silence the default log output; visibility goes - * through BRIDGE_MSG_LOG instead. */ + /* Reduce corruption of the binary frame stream by silencing ESP_LOG. This is + * not absolute: ROM/early-boot output (before this runs) and the panic + * handler (esp_rom_printf, crash only) still reach the same peripheral. Those + * are pre-stream or crash-only, and COBS resynchronises at the next 0x00 + * delimiter, so steady-state framing is unaffected. */ esp_log_level_set("*", ESP_LOG_NONE); - if (xTaskCreate(bridge_rx_task, "bridge_rx", 4096, NULL, 6, NULL) != pdPASS) + /* Set started before creating the task: the RX task runs at a higher + * priority and may dispatch (and reply to) a queued ECHO before xTaskCreate + * returns; bridge_send must not reject that first reply. */ + s_started = true; + if (xTaskCreate(bridge_rx_task, "bridge_rx", 4096, NULL, 6, NULL) != pdPASS) { + s_started = false; + vSemaphoreDelete(s_tx_lock); + s_tx_lock = NULL; return -1; + } - s_started = true; bridge_log("bridge up (M2.0 echo)"); return 0; } @@ -102,8 +115,17 @@ int bridge_send(uint8_t type, const void *payload, size_t len) { int ret = -1; if (enc_len > 0) { enc[enc_len++] = 0x00; /* frame delimiter */ - usb_serial_jtag_write_bytes(enc, enc_len, portMAX_DELAY); + /* Write the whole frame, looping over short writes. A bounded timeout + * keeps a host that stops draining from wedging the lock (and thus every + * other sender) forever; on timeout we drop the rest of this frame. */ + size_t off = 0; ret = 0; + while (off < enc_len) { + int w = usb_serial_jtag_write_bytes(enc + off, enc_len - off, + pdMS_TO_TICKS(1000)); + if (w <= 0) { ret = -1; break; } /* host not draining: drop */ + off += (size_t)w; + } } xSemaphoreGive(s_tx_lock); return ret; diff --git a/firmware/spikes/main/bridge.h b/firmware/spikes/main/bridge.h index 5879339..ab10757 100644 --- a/firmware/spikes/main/bridge.h +++ b/firmware/spikes/main/bridge.h @@ -38,8 +38,9 @@ enum bridge_msg_type { BRIDGE_MSG_CMD_REPLY = 8, /* device -> host: command output text */ }; -/* Start the bridge: install the USB driver, silence stdout logging (so it can - * not corrupt the binary stream), and spawn the RX task. Returns 0 on success. */ +/* Start the bridge: install the USB driver, silence ESP_LOG (to reduce + * corruption of the binary stream; the host resynchronises at the next frame + * delimiter after any stray bytes), and spawn the RX task. Returns 0 on success. */ int bridge_start(void); /* Send one framed message. Thread-safe. Returns 0 on success. */ diff --git a/firmware/spikes/sdkconfig.bridge b/firmware/spikes/sdkconfig.bridge new file mode 100644 index 0000000..6c481fe --- /dev/null +++ b/firmware/spikes/sdkconfig.bridge @@ -0,0 +1,3489 @@ +# +# Automatically generated file. DO NOT EDIT. +# Espressif IoT Development Framework (ESP-IDF) 6.0.3 Project Configuration +# +# default: +CONFIG_SOC_ADC_SUPPORTED=y +# default: +CONFIG_SOC_DEDICATED_GPIO_SUPPORTED=y +# default: +CONFIG_SOC_UART_SUPPORTED=y +# default: +CONFIG_SOC_GDMA_SUPPORTED=y +# default: +CONFIG_SOC_UHCI_SUPPORTED=y +# default: +CONFIG_SOC_AHB_GDMA_SUPPORTED=y +# default: +CONFIG_SOC_GPTIMER_SUPPORTED=y +# default: +CONFIG_SOC_TWAI_SUPPORTED=y +# default: +CONFIG_SOC_BT_SUPPORTED=y +# default: +CONFIG_SOC_ASYNC_MEMCPY_SUPPORTED=y +# default: +CONFIG_SOC_USB_SERIAL_JTAG_SUPPORTED=y +# default: +CONFIG_SOC_TEMP_SENSOR_SUPPORTED=y +# default: +CONFIG_SOC_XT_WDT_SUPPORTED=y +# default: +CONFIG_SOC_PHY_SUPPORTED=y +# default: +CONFIG_SOC_WIFI_SUPPORTED=y +# default: +CONFIG_SOC_SUPPORTS_SECURE_DL_MODE=y +# default: +CONFIG_SOC_EFUSE_KEY_PURPOSE_FIELD=y +# default: +CONFIG_SOC_EFUSE_HAS_EFUSE_RST_BUG=y +# default: +CONFIG_SOC_EFUSE_SUPPORTED=y +# default: +CONFIG_SOC_RTC_FAST_MEM_SUPPORTED=y +# default: +CONFIG_SOC_RTC_MEM_SUPPORTED=y +# default: +CONFIG_SOC_RTC_TIMER_V1_SUPPORTED=y +# default: +CONFIG_SOC_I2S_SUPPORTED=y +# default: +CONFIG_SOC_RMT_SUPPORTED=y +# default: +CONFIG_SOC_SDM_SUPPORTED=y +# default: +CONFIG_SOC_GPSPI_SUPPORTED=y +# default: +CONFIG_SOC_LEDC_SUPPORTED=y +# default: +CONFIG_SOC_I2C_SUPPORTED=y +# default: +CONFIG_SOC_SYSTIMER_SUPPORTED=y +# default: +CONFIG_SOC_SUPPORT_COEXISTENCE=y +# default: +CONFIG_SOC_AES_SUPPORTED=y +# default: +CONFIG_SOC_MPI_SUPPORTED=y +# default: +CONFIG_SOC_SHA_SUPPORTED=y +# default: +CONFIG_SOC_HMAC_SUPPORTED=y +# default: +CONFIG_SOC_DIG_SIGN_SUPPORTED=y +# default: +CONFIG_SOC_FLASH_ENC_SUPPORTED=y +# default: +CONFIG_SOC_SECURE_BOOT_SUPPORTED=y +# default: +CONFIG_SOC_MEMPROT_SUPPORTED=y +# default: +CONFIG_SOC_BOD_SUPPORTED=y +# default: +CONFIG_SOC_CLK_TREE_SUPPORTED=y +# default: +CONFIG_SOC_ASSIST_DEBUG_SUPPORTED=y +# default: +CONFIG_SOC_WDT_SUPPORTED=y +# default: +CONFIG_SOC_SPI_FLASH_SUPPORTED=y +# default: +CONFIG_SOC_RNG_SUPPORTED=y +# default: +CONFIG_SOC_LIGHT_SLEEP_SUPPORTED=y +# default: +CONFIG_SOC_DEEP_SLEEP_SUPPORTED=y +# default: +CONFIG_SOC_LP_PERIPH_SHARE_INTERRUPT=y +# default: +CONFIG_SOC_PM_SUPPORTED=y +# default: +CONFIG_SOC_XTAL_SUPPORT_40M=y +# default: +CONFIG_SOC_AES_SUPPORT_DMA=y +# default: +CONFIG_SOC_AES_GDMA=y +# default: +CONFIG_SOC_AES_SUPPORT_AES_128=y +# default: +CONFIG_SOC_AES_SUPPORT_AES_256=y +# default: +CONFIG_SOC_ADC_DIG_CTRL_SUPPORTED=y +# default: +CONFIG_SOC_ADC_ARBITER_SUPPORTED=y +# default: +CONFIG_SOC_ADC_DIG_IIR_FILTER_SUPPORTED=y +# default: +CONFIG_SOC_ADC_MONITOR_SUPPORTED=y +# default: +CONFIG_SOC_ADC_DMA_SUPPORTED=y +# default: +CONFIG_SOC_ADC_PERIPH_NUM=2 +# default: +CONFIG_SOC_ADC_MAX_CHANNEL_NUM=5 +# default: +CONFIG_SOC_ADC_ATTEN_NUM=4 +# default: +CONFIG_SOC_ADC_DIGI_CONTROLLER_NUM=1 +# default: +CONFIG_SOC_ADC_PATT_LEN_MAX=8 +# default: +CONFIG_SOC_ADC_DIGI_MIN_BITWIDTH=12 +# default: +CONFIG_SOC_ADC_DIGI_MAX_BITWIDTH=12 +# default: +CONFIG_SOC_ADC_DIGI_RESULT_BYTES=4 +# default: +CONFIG_SOC_ADC_DIGI_DATA_BYTES_PER_CONV=4 +# default: +CONFIG_SOC_ADC_DIGI_IIR_FILTER_NUM=2 +# default: +CONFIG_SOC_ADC_DIGI_MONITOR_NUM=2 +# default: +CONFIG_SOC_ADC_SAMPLE_FREQ_THRES_HIGH=83333 +# default: +CONFIG_SOC_ADC_SAMPLE_FREQ_THRES_LOW=611 +# default: +CONFIG_SOC_ADC_RTC_MIN_BITWIDTH=12 +# default: +CONFIG_SOC_ADC_RTC_MAX_BITWIDTH=12 +# default: +CONFIG_SOC_ADC_CALIBRATION_V1_SUPPORTED=y +# default: +CONFIG_SOC_ADC_SELF_HW_CALI_SUPPORTED=y +# default: +CONFIG_SOC_ADC_SHARED_POWER=y +# default: +CONFIG_SOC_APB_BACKUP_DMA=y +# default: +CONFIG_SOC_BROWNOUT_RESET_SUPPORTED=y +# default: +CONFIG_SOC_SHARED_IDCACHE_SUPPORTED=y +# default: +CONFIG_SOC_CACHE_FREEZE_SUPPORTED=y +# default: +CONFIG_SOC_CACHE_MEMORY_IBANK_SIZE=0x4000 +# default: +CONFIG_SOC_CPU_CORES_NUM=1 +# default: +CONFIG_SOC_CPU_INTR_NUM=32 +# default: +CONFIG_SOC_CPU_HAS_FLEXIBLE_INTC=y +# default: +CONFIG_SOC_CPU_HAS_CSR_PC=y +# default: +CONFIG_SOC_CPU_BREAKPOINTS_NUM=8 +# default: +CONFIG_SOC_CPU_WATCHPOINTS_NUM=8 +# default: +CONFIG_SOC_CPU_WATCHPOINT_MAX_REGION_SIZE=0x80000000 +# default: +CONFIG_SOC_DS_SIGNATURE_MAX_BIT_LEN=3072 +# default: +CONFIG_SOC_DS_KEY_PARAM_MD_IV_LENGTH=16 +# default: +CONFIG_SOC_DS_KEY_CHECK_MAX_WAIT_US=1100 +# default: +CONFIG_SOC_AHB_GDMA_VERSION=1 +# default: +CONFIG_SOC_GPIO_PORT=1 +# default: +CONFIG_SOC_GPIO_PIN_COUNT=22 +# default: +CONFIG_SOC_GPIO_SUPPORT_PIN_GLITCH_FILTER=y +# default: +CONFIG_SOC_GPIO_SUPPORT_FORCE_HOLD=y +# default: +CONFIG_SOC_GPIO_SUPPORT_HP_PERIPH_PD_SLEEP_WAKEUP=y +# default: +CONFIG_SOC_GPIO_IN_RANGE_MAX=21 +# default: +CONFIG_SOC_GPIO_OUT_RANGE_MAX=21 +# default: +CONFIG_SOC_GPIO_HP_PERIPH_PD_SLEEP_WAKEABLE_MASK=0 +# default: +CONFIG_SOC_GPIO_HP_PERIPH_PD_SLEEP_WAKEABLE_PIN_CNT=6 +# default: +CONFIG_SOC_GPIO_VALID_DIGITAL_IO_PAD_MASK=0x00000000003FFFC0 +# default: +CONFIG_SOC_GPIO_CLOCKOUT_BY_GPIO_MATRIX=y +# default: +CONFIG_SOC_GPIO_CLOCKOUT_CHANNEL_NUM=3 +# default: +CONFIG_SOC_I2C_NUM=1 +# default: +CONFIG_SOC_HP_I2C_NUM=1 +# default: +CONFIG_SOC_I2C_SUPPORT_XTAL=y +# default: +CONFIG_SOC_I2C_SUPPORT_RTC=y +# default: +CONFIG_SOC_I2C_SUPPORT_10BIT_ADDR=y +# default: +CONFIG_SOC_I2C_SUPPORT_SLAVE=y +# default: +CONFIG_SOC_I2C_SLAVE_SUPPORT_BROADCAST=y +# default: +CONFIG_SOC_I2C_SLAVE_CAN_GET_STRETCH_CAUSE=y +# default: +CONFIG_SOC_I2S_HW_VERSION_2=y +# default: +CONFIG_SOC_I2S_SUPPORTS_PCM=y +# default: +CONFIG_SOC_I2S_SUPPORTS_PDM=y +# default: +CONFIG_SOC_I2S_SUPPORTS_PDM_TX=y +# default: +CONFIG_SOC_I2S_SUPPORTS_PCM2PDM=y +# default: +CONFIG_SOC_I2S_SUPPORTS_PDM_RX=y +# default: +CONFIG_SOC_I2S_PDM_MAX_TX_LINES=2 +# default: +CONFIG_SOC_I2S_PDM_MAX_RX_LINES=1 +# default: +CONFIG_SOC_I2S_SUPPORTS_TDM=y +# default: +CONFIG_SOC_LEDC_SUPPORT_APB_CLOCK=y +# default: +CONFIG_SOC_LEDC_SUPPORT_XTAL_CLOCK=y +# default: +CONFIG_SOC_LEDC_TIMER_NUM=4 +# default: +CONFIG_SOC_LEDC_CHANNEL_NUM=6 +# default: +CONFIG_SOC_LEDC_TIMER_BIT_WIDTH=14 +# default: +CONFIG_SOC_LEDC_SUPPORT_FADE_STOP=y +# default: +CONFIG_SOC_MMU_LINEAR_ADDRESS_REGION_NUM=1 +# default: +CONFIG_SOC_MMU_PERIPH_NUM=1 +# default: +CONFIG_SOC_MPU_MIN_REGION_SIZE=0x20000000 +# default: +CONFIG_SOC_MPU_REGIONS_MAX_NUM=8 +# default: +CONFIG_SOC_RMT_MEM_WORDS_PER_CHANNEL=48 +# default: +CONFIG_SOC_RMT_SUPPORT_RX_PINGPONG=y +# default: +CONFIG_SOC_RMT_SUPPORT_TX_LOOP_COUNT=y +# default: +CONFIG_SOC_RTC_CNTL_CPU_PD_DMA_BUS_WIDTH=128 +# default: +CONFIG_SOC_RTC_CNTL_CPU_PD_REG_FILE_NUM=108 +# default: +CONFIG_SOC_RTC_CNTL_NEEDS_ATOMIC_ACCESS=y +# default: +CONFIG_SOC_SLEEP_SYSTIMER_STALL_WORKAROUND=y +# default: +CONFIG_SOC_SLEEP_TGWDT_STOP_WORKAROUND=y +# default: +CONFIG_SOC_RTCIO_PIN_COUNT=0 +# default: +CONFIG_SOC_MPI_MEM_BLOCKS_NUM=4 +# default: +CONFIG_SOC_MPI_OPERATIONS_NUM=3 +# default: +CONFIG_SOC_RSA_MAX_BIT_LEN=3072 +# default: +CONFIG_SOC_SHA_DMA_MAX_BUFFER_SIZE=3968 +# default: +CONFIG_SOC_SHA_SUPPORT_DMA=y +# default: +CONFIG_SOC_SHA_SUPPORT_RESUME=y +# default: +CONFIG_SOC_SHA_GDMA=y +# default: +CONFIG_SOC_SHA_SUPPORT_SHA1=y +# default: +CONFIG_SOC_SHA_SUPPORT_SHA224=y +# default: +CONFIG_SOC_SHA_SUPPORT_SHA256=y +# default: +CONFIG_SOC_SPI_PERIPH_NUM=2 +# default: +CONFIG_SOC_SPI_MAX_CS_NUM=6 +# default: +CONFIG_SOC_SPI_MAXIMUM_BUFFER_SIZE=64 +# default: +CONFIG_SOC_SPI_SUPPORT_DDRCLK=y +# default: +CONFIG_SOC_SPI_SLAVE_SUPPORT_SEG_TRANS=y +# default: +CONFIG_SOC_SPI_SUPPORT_CD_SIG=y +# default: +CONFIG_SOC_SPI_SUPPORT_CONTINUOUS_TRANS=y +# default: +CONFIG_SOC_SPI_SUPPORT_SLAVE_HD_VER2=y +# default: +CONFIG_SOC_SPI_SUPPORT_CLK_APB=y +# default: +CONFIG_SOC_SPI_SUPPORT_CLK_XTAL=y +# default: +CONFIG_SOC_SPI_PERIPH_SUPPORT_CONTROL_DUMMY_OUT=y +# default: +CONFIG_SOC_SPI_SCT_SUPPORTED=y +# default: +CONFIG_SOC_SPI_SCT_REG_NUM=14 +# default: +CONFIG_SOC_SPI_SCT_BUFFER_NUM_MAX=y +# default: +CONFIG_SOC_SPI_SCT_CONF_BITLEN_MAX=0x3FFFA +# default: +CONFIG_SOC_MEMSPI_IS_INDEPENDENT=y +# default: +CONFIG_SOC_SPI_MAX_PRE_DIVIDER=16 +# default: +CONFIG_SOC_SPI_MEM_SUPPORT_AUTO_WAIT_IDLE=y +# default: +CONFIG_SOC_SPI_MEM_SUPPORT_AUTO_SUSPEND=y +# default: +CONFIG_SOC_SPI_MEM_SUPPORT_AUTO_RESUME=y +# default: +CONFIG_SOC_SPI_MEM_SUPPORT_IDLE_INTR=y +# default: +CONFIG_SOC_SPI_MEM_SUPPORT_SW_SUSPEND=y +# default: +CONFIG_SOC_SPI_MEM_SUPPORT_CHECK_SUS=y +# default: +CONFIG_SOC_SPI_MEM_SUPPORT_CONFIG_GPIO_BY_EFUSE=y +# default: +CONFIG_SOC_SPI_MEM_SUPPORT_WRAP=y +# default: +CONFIG_SOC_SYSTIMER_COUNTER_NUM=2 +# default: +CONFIG_SOC_SYSTIMER_ALARM_NUM=3 +# default: +CONFIG_SOC_SYSTIMER_BIT_WIDTH_LO=32 +# default: +CONFIG_SOC_SYSTIMER_BIT_WIDTH_HI=20 +# default: +CONFIG_SOC_SYSTIMER_FIXED_DIVIDER=y +# default: +CONFIG_SOC_SYSTIMER_INT_LEVEL=y +# default: +CONFIG_SOC_SYSTIMER_ALARM_MISS_COMPENSATE=y +# default: +CONFIG_SOC_MEMSPI_ENCRYPTION_ALIGNMENT=16 +# default: +CONFIG_SOC_LP_TIMER_BIT_WIDTH_LO=32 +# default: +CONFIG_SOC_LP_TIMER_BIT_WIDTH_HI=16 +# default: +CONFIG_SOC_MWDT_SUPPORT_XTAL=y +# default: +CONFIG_SOC_TWAI_CONTROLLER_NUM=1 +# default: +CONFIG_SOC_TWAI_MASK_FILTER_NUM=1 +# default: +CONFIG_SOC_EFUSE_DIS_DOWNLOAD_ICACHE=y +# default: +CONFIG_SOC_EFUSE_DIS_PAD_JTAG=y +# default: +CONFIG_SOC_EFUSE_DIS_USB_JTAG=y +# default: +CONFIG_SOC_EFUSE_DIS_DIRECT_BOOT=y +# default: +CONFIG_SOC_EFUSE_SOFT_DIS_JTAG=y +# default: +CONFIG_SOC_EFUSE_DIS_ICACHE=y +# default: +CONFIG_SOC_EFUSE_BLOCK9_KEY_PURPOSE_QUIRK=y +# default: +CONFIG_SOC_EFUSE_XTS_AES_KEY_128=y +# default: +CONFIG_SOC_SECURE_BOOT_V2_RSA=y +# default: +CONFIG_SOC_EFUSE_SECURE_BOOT_KEY_DIGESTS=3 +# default: +CONFIG_SOC_EFUSE_REVOKE_BOOT_KEY_DIGESTS=y +# default: +CONFIG_SOC_SUPPORT_SECURE_BOOT_REVOKE_KEY=y +# default: +CONFIG_SOC_FLASH_ENCRYPTED_XTS_AES_BLOCK_MAX=32 +# default: +CONFIG_SOC_FLASH_ENCRYPTION_XTS_AES=y +# default: +CONFIG_SOC_FLASH_ENCRYPTION_XTS_AES_128=y +# default: +CONFIG_SOC_MEMPROT_CPU_PREFETCH_PAD_SIZE=16 +# default: +CONFIG_SOC_MEMPROT_MEM_ALIGN_SIZE=512 +# default: +CONFIG_SOC_UART_NUM=2 +# default: +CONFIG_SOC_UART_HP_NUM=2 +# default: +CONFIG_SOC_UART_FIFO_LEN=128 +# default: +CONFIG_SOC_UART_BITRATE_MAX=5000000 +# default: +CONFIG_SOC_UART_SUPPORT_APB_CLK=y +# default: +CONFIG_SOC_UART_SUPPORT_RTC_CLK=y +# default: +CONFIG_SOC_UART_SUPPORT_XTAL_CLK=y +# default: +CONFIG_SOC_UART_SUPPORT_WAKEUP_INT=y +# default: +CONFIG_SOC_UART_WAKEUP_SUPPORT_ACTIVE_THRESH_MODE=y +# default: +CONFIG_SOC_UHCI_NUM=1 +# default: +CONFIG_SOC_COEX_HW_PTI=y +# default: +CONFIG_SOC_PHY_DIG_REGS_MEM_SIZE=21 +# default: +CONFIG_SOC_MAC_BB_PD_MEM_SIZE=192 +# default: +CONFIG_SOC_WIFI_LIGHT_SLEEP_CLK_WIDTH=12 +# default: +CONFIG_SOC_PM_SUPPORT_WIFI_WAKEUP=y +# default: +CONFIG_SOC_PM_SUPPORT_BT_WAKEUP=y +# default: +CONFIG_SOC_PM_SUPPORT_CPU_PD=y +# default: +CONFIG_SOC_PM_SUPPORT_WIFI_PD=y +# default: +CONFIG_SOC_PM_SUPPORT_BT_PD=y +# default: +CONFIG_SOC_PM_SUPPORT_RC_FAST_PD=y +# default: +CONFIG_SOC_PM_SUPPORT_VDDSDIO_PD=y +# default: +CONFIG_SOC_PM_SUPPORT_MAC_BB_PD=y +# default: +CONFIG_SOC_PM_CPU_RETENTION_BY_RTCCNTL=y +# default: +CONFIG_SOC_PM_MODEM_RETENTION_BY_BACKUPDMA=y +# default: +CONFIG_SOC_PM_MODEM_PD_BY_SW=y +# default: +CONFIG_SOC_CLK_RC_FAST_D256_SUPPORTED=y +# default: +CONFIG_SOC_RTC_SLOW_CLK_SUPPORT_RC_FAST_D256=y +# default: +CONFIG_SOC_CLK_RC_FAST_SUPPORT_CALIBRATION=y +# default: +CONFIG_SOC_CLK_XTAL32K_SUPPORTED=y +# default: +CONFIG_SOC_CLK_LP_FAST_SUPPORT_XTAL_D2=y +# default: +CONFIG_SOC_TEMPERATURE_SENSOR_SUPPORT_FAST_RC=y +# default: +CONFIG_SOC_TEMPERATURE_SENSOR_SUPPORT_XTAL=y +# default: +CONFIG_SOC_WIFI_HW_TSF=y +# default: +CONFIG_SOC_WIFI_FTM_SUPPORT=y +# default: +CONFIG_SOC_WIFI_GCMP_SUPPORT=y +# default: +CONFIG_SOC_WIFI_WAPI_SUPPORT=y +# default: +CONFIG_SOC_WIFI_TXOP_SUPPORT=y +# default: +CONFIG_SOC_WIFI_CSI_SUPPORT=y +# default: +CONFIG_SOC_WIFI_MESH_SUPPORT=y +# default: +CONFIG_SOC_WIFI_SUPPORT_VARIABLE_BEACON_WINDOW=y +# default: +CONFIG_SOC_WIFI_PHY_NEEDS_USB_WORKAROUND=y +# default: +CONFIG_SOC_BLE_SUPPORTED=y +# default: +CONFIG_SOC_BLE_MESH_SUPPORTED=y +# default: +CONFIG_SOC_BLE_50_SUPPORTED=y +# default: +CONFIG_SOC_BLE_DEVICE_PRIVACY_SUPPORTED=y +# default: +CONFIG_SOC_BLUFI_SUPPORTED=y +# default: +CONFIG_SOC_PHY_COMBO_MODULE=y +# default: +CONFIG_IDF_CMAKE=y +# default: +CONFIG_IDF_TOOLCHAIN="gcc" +# default: +CONFIG_IDF_TOOLCHAIN_GCC=y +# default: +CONFIG_IDF_TARGET_ARCH_RISCV=y +# default: +CONFIG_IDF_TARGET_ARCH="riscv" +# default: +CONFIG_IDF_TARGET="esp32c3" +# default: +CONFIG_IDF_INIT_VERSION="6.0.3" +# default: +CONFIG_IDF_TARGET_ESP32C3=y +# default: +CONFIG_IDF_FIRMWARE_CHIP_ID=0x0005 + +# +# Build type +# +# default: +CONFIG_APP_BUILD_TYPE_APP_2NDBOOT=y +# default: +# CONFIG_APP_BUILD_TYPE_RAM is not set +# default: +CONFIG_APP_BUILD_GENERATE_BINARIES=y +# default: +CONFIG_APP_BUILD_BOOTLOADER=y +# default: +CONFIG_APP_BUILD_USE_FLASH_SECTIONS=y +# default: +# CONFIG_APP_REPRODUCIBLE_BUILD is not set +# default: +# CONFIG_APP_NO_BLOBS is not set +# end of Build type + +# +# App Update config +# +# end of App Update config + +# +# Bootloader config +# + +# +# Bootloader manager +# +# default: +CONFIG_BOOTLOADER_COMPILE_TIME_DATE=y +# default: +CONFIG_BOOTLOADER_PROJECT_VER=1 +# end of Bootloader manager + +# +# Application Rollback +# +# default: +# CONFIG_BOOTLOADER_APP_ROLLBACK_ENABLE is not set +# end of Application Rollback + +# +# Recovery Bootloader and Rollback +# +# end of Recovery Bootloader and Rollback + +# default: +CONFIG_BOOTLOADER_OFFSET_IN_FLASH=0x0 +# default: +CONFIG_BOOTLOADER_COMPILER_OPTIMIZATION_SIZE=y +# default: +# CONFIG_BOOTLOADER_COMPILER_OPTIMIZATION_DEBUG is not set +# default: +# CONFIG_BOOTLOADER_COMPILER_OPTIMIZATION_PERF is not set + +# +# Log +# +# default: +CONFIG_BOOTLOADER_LOG_VERSION_1=y +# default: +CONFIG_BOOTLOADER_LOG_VERSION=1 +# default: +# CONFIG_BOOTLOADER_LOG_LEVEL_NONE is not set +# default: +# CONFIG_BOOTLOADER_LOG_LEVEL_ERROR is not set +# default: +# CONFIG_BOOTLOADER_LOG_LEVEL_WARN is not set +# default: +CONFIG_BOOTLOADER_LOG_LEVEL_INFO=y +# default: +# CONFIG_BOOTLOADER_LOG_LEVEL_DEBUG is not set +# default: +# CONFIG_BOOTLOADER_LOG_LEVEL_VERBOSE is not set +# default: +CONFIG_BOOTLOADER_LOG_LEVEL=3 + +# +# Format +# +# default: +# CONFIG_BOOTLOADER_LOG_COLORS is not set +# default: +CONFIG_BOOTLOADER_LOG_TIMESTAMP_SOURCE_CPU_TICKS=y +# end of Format + +# +# Settings +# +# default: +CONFIG_BOOTLOADER_LOG_MODE_TEXT_EN=y +# default: +CONFIG_BOOTLOADER_LOG_MODE_TEXT=y +# end of Settings +# end of Log + +# default: +CONFIG_BOOTLOADER_CPU_CLK_FREQ_MHZ=80 + +# +# Serial Flash Configurations +# +# default: +# CONFIG_BOOTLOADER_FLASH_DC_AWARE is not set +# default: +CONFIG_BOOTLOADER_FLASH_XMC_SUPPORT=y +# end of Serial Flash Configurations + +# default: +# CONFIG_BOOTLOADER_FACTORY_RESET is not set +# default: +# CONFIG_BOOTLOADER_APP_TEST is not set +# default: +CONFIG_BOOTLOADER_REGION_PROTECTION_ENABLE=y +# default: +CONFIG_BOOTLOADER_WDT_ENABLE=y +# default: +# CONFIG_BOOTLOADER_WDT_DISABLE_IN_USER_CODE is not set +# default: +CONFIG_BOOTLOADER_WDT_TIME_MS=9000 +# default: +# CONFIG_BOOTLOADER_SKIP_VALIDATE_IN_DEEP_SLEEP is not set +# default: +# CONFIG_BOOTLOADER_SKIP_VALIDATE_ON_POWER_ON is not set +# default: +# CONFIG_BOOTLOADER_SKIP_VALIDATE_ALWAYS is not set +# default: +CONFIG_BOOTLOADER_RESERVE_RTC_SIZE=0 +# default: +# CONFIG_BOOTLOADER_CUSTOM_RESERVE_RTC is not set +# end of Bootloader config + +# +# Security features +# +# default: +CONFIG_SECURE_BOOT_V2_RSA_SUPPORTED=y +# default: +CONFIG_SECURE_BOOT_V2_PREFERRED=y +# default: +# CONFIG_SECURE_SIGNED_APPS_NO_SECURE_BOOT is not set +# default: +CONFIG_SECURE_BOOT_IMAGE_DIGEST_LEN=32 +# default: +CONFIG_SECURE_BOOT_ROM_FAST_WAKE_RESERVE_SIZE=0 +# default: +# CONFIG_SECURE_BOOT is not set +# default: +# CONFIG_SECURE_FLASH_ENC_ENABLED is not set +# default: +CONFIG_SECURE_ROM_DL_MODE_ENABLED=y +# end of Security features + +# +# Application manager +# +# default: +CONFIG_APP_COMPILE_TIME_DATE=y +# default: +# CONFIG_APP_EXCLUDE_PROJECT_VER_VAR is not set +# default: +# CONFIG_APP_EXCLUDE_PROJECT_NAME_VAR is not set +# default: +# CONFIG_APP_PROJECT_VER_FROM_CONFIG is not set +# default: +CONFIG_APP_RETRIEVE_LEN_ELF_SHA=9 +# end of Application manager + +# default: +CONFIG_ESP_ROM_HAS_CRC_LE=y +# default: +CONFIG_ESP_ROM_HAS_CRC_BE=y +# default: +CONFIG_ESP_ROM_HAS_MZ_CRC32=y +# default: +CONFIG_ESP_ROM_HAS_JPEG_DECODE=y +# default: +CONFIG_ESP_ROM_UART_CLK_IS_XTAL=y +# default: +CONFIG_ESP_ROM_USB_SERIAL_DEVICE_NUM=3 +# default: +CONFIG_ESP_ROM_HAS_RETARGETABLE_LOCKING=y +# default: +CONFIG_ESP_ROM_HAS_ERASE_0_REGION_BUG=y +# default: +CONFIG_ESP_ROM_HAS_ENCRYPTED_WRITES_USING_LEGACY_DRV=y +# default: +CONFIG_ESP_ROM_GET_CLK_FREQ=y +# default: +CONFIG_ESP_ROM_NEEDS_SWSETUP_WORKAROUND=y +# default: +CONFIG_ESP_ROM_HAS_LAYOUT_TABLE=y +# default: +CONFIG_ESP_ROM_HAS_SPI_FLASH=y +# default: +CONFIG_ESP_ROM_HAS_SPI_FLASH_MMAP=y +# default: +CONFIG_ESP_ROM_HAS_ETS_PRINTF_BUG=y +# default: +CONFIG_ESP_ROM_HAS_NEWLIB=y +# default: +CONFIG_ESP_ROM_HAS_NEWLIB_NANO_FORMAT=y +# default: +CONFIG_ESP_ROM_HAS_NEWLIB_32BIT_TIME=y +# default: +CONFIG_ESP_ROM_NEEDS_SET_CACHE_MMU_SIZE=y +# default: +CONFIG_ESP_ROM_RAM_APP_NEEDS_MMU_INIT=y +# default: +CONFIG_ESP_ROM_HAS_SW_FLOAT=y +# default: +CONFIG_ESP_ROM_USB_OTG_NUM=-1 +# default: +CONFIG_ESP_ROM_HAS_VERSION=y +# default: +CONFIG_ESP_ROM_SUPPORT_DEEP_SLEEP_WAKEUP_STUB=y +# default: +CONFIG_ESP_ROM_CONSOLE_OUTPUT_SECONDARY=y +# default: +CONFIG_ESP_ROM_HAS_SUBOPTIMAL_NEWLIB_ON_MISALIGNED_MEMORY=y + +# +# Boot ROM Behavior +# +# default: +CONFIG_BOOT_ROM_LOG_ALWAYS_ON=y +# default: +# CONFIG_BOOT_ROM_LOG_ALWAYS_OFF is not set +# default: +# CONFIG_BOOT_ROM_LOG_ON_GPIO_HIGH is not set +# default: +# CONFIG_BOOT_ROM_LOG_ON_GPIO_LOW is not set +# end of Boot ROM Behavior + +# +# Serial flasher config +# +# default: +# CONFIG_ESPTOOLPY_NO_STUB is not set +# default: +# CONFIG_ESPTOOLPY_FLASHMODE_QIO is not set +# default: +# CONFIG_ESPTOOLPY_FLASHMODE_QOUT is not set +# default: +CONFIG_ESPTOOLPY_FLASHMODE_DIO=y +# default: +# CONFIG_ESPTOOLPY_FLASHMODE_DOUT is not set +# default: +CONFIG_ESPTOOLPY_FLASH_SAMPLE_MODE_STR=y +# default: +CONFIG_ESPTOOLPY_FLASHMODE="dio" +# default: +CONFIG_ESPTOOLPY_FLASHFREQ_80M=y +# default: +# CONFIG_ESPTOOLPY_FLASHFREQ_40M is not set +# default: +# CONFIG_ESPTOOLPY_FLASHFREQ_26M is not set +# default: +# CONFIG_ESPTOOLPY_FLASHFREQ_20M is not set +# default: +CONFIG_ESPTOOLPY_FLASHFREQ="80m" +# default: +# CONFIG_ESPTOOLPY_FLASHSIZE_1MB is not set +# default: +CONFIG_ESPTOOLPY_FLASHSIZE_2MB=y +# default: +# CONFIG_ESPTOOLPY_FLASHSIZE_4MB is not set +# default: +# CONFIG_ESPTOOLPY_FLASHSIZE_8MB is not set +# default: +# CONFIG_ESPTOOLPY_FLASHSIZE_16MB is not set +# default: +# CONFIG_ESPTOOLPY_FLASHSIZE_32MB is not set +# default: +# CONFIG_ESPTOOLPY_FLASHSIZE_64MB is not set +# default: +# CONFIG_ESPTOOLPY_FLASHSIZE_128MB is not set +# default: +CONFIG_ESPTOOLPY_FLASHSIZE="2MB" +# default: +# CONFIG_ESPTOOLPY_HEADER_FLASHSIZE_UPDATE is not set +# default: +CONFIG_ESPTOOLPY_BEFORE_RESET=y +# default: +# CONFIG_ESPTOOLPY_BEFORE_NORESET is not set +# default: +CONFIG_ESPTOOLPY_BEFORE="default-reset" +# default: +CONFIG_ESPTOOLPY_AFTER_RESET=y +# default: +# CONFIG_ESPTOOLPY_AFTER_NORESET is not set +# default: +CONFIG_ESPTOOLPY_AFTER="hard-reset" +# default: +CONFIG_ESPTOOLPY_MONITOR_BAUD=115200 +# end of Serial flasher config + +# +# Partition Table +# +# default: +CONFIG_PARTITION_TABLE_SINGLE_APP=y +# default: +# CONFIG_PARTITION_TABLE_SINGLE_APP_LARGE is not set +# default: +# CONFIG_PARTITION_TABLE_TWO_OTA is not set +# default: +# CONFIG_PARTITION_TABLE_TWO_OTA_LARGE is not set +# default: +# CONFIG_PARTITION_TABLE_CUSTOM is not set +# default: +CONFIG_PARTITION_TABLE_CUSTOM_FILENAME="partitions.csv" +# default: +CONFIG_PARTITION_TABLE_FILENAME="partitions_singleapp.csv" +# default: +CONFIG_PARTITION_TABLE_OFFSET=0x8000 +# default: +CONFIG_PARTITION_TABLE_MD5=y +# end of Partition Table + +# +# EspDrop +# +# default: +# CONFIG_ESPDROP_BRIDGE_MODE is not set +# end of EspDrop + +# +# Compiler options +# +# default: +CONFIG_COMPILER_OPTIMIZATION_DEBUG=y +# default: +# CONFIG_COMPILER_OPTIMIZATION_SIZE is not set +# default: +# CONFIG_COMPILER_OPTIMIZATION_PERF is not set +# default: +# CONFIG_COMPILER_OPTIMIZATION_NONE is not set +# default: +CONFIG_COMPILER_OPTIMIZATION_ASSERTIONS_ENABLE=y +# default: +# CONFIG_COMPILER_OPTIMIZATION_ASSERTIONS_SILENT is not set +# default: +# CONFIG_COMPILER_OPTIMIZATION_ASSERTIONS_DISABLE is not set +# default: +# CONFIG_COMPILER_ASSERT_NDEBUG_EVALUATE is not set +# default: +CONFIG_COMPILER_FLOAT_LIB_FROM_GCCLIB=y +# default: +CONFIG_COMPILER_OPTIMIZATION_ASSERTION_LEVEL=2 +# default: +# CONFIG_COMPILER_OPTIMIZATION_CHECKS_SILENT is not set +# default: +CONFIG_COMPILER_HIDE_PATHS_MACROS=y +# default: +# CONFIG_COMPILER_CXX_EXCEPTIONS is not set +# default: +# CONFIG_COMPILER_CXX_RTTI is not set +# default: +CONFIG_COMPILER_STACK_CHECK_MODE_NONE=y +# default: +# CONFIG_COMPILER_STACK_CHECK_MODE_NORM is not set +# default: +# CONFIG_COMPILER_STACK_CHECK_MODE_STRONG is not set +# default: +# CONFIG_COMPILER_STACK_CHECK_MODE_ALL is not set +# default: +# CONFIG_COMPILER_NO_MERGE_CONSTANTS is not set +# default: +# CONFIG_COMPILER_WARN_WRITE_STRINGS is not set +# default: +# CONFIG_COMPILER_SAVE_RESTORE_LIBCALLS is not set +# default: +# CONFIG_COMPILER_DISABLE_DEFAULT_ERRORS is not set +# default: +# CONFIG_COMPILER_DISABLE_GCC12_WARNINGS is not set +# default: +# CONFIG_COMPILER_DISABLE_GCC13_WARNINGS is not set +# default: +# CONFIG_COMPILER_DISABLE_GCC14_WARNINGS is not set +# default: +# CONFIG_COMPILER_DISABLE_GCC15_WARNINGS is not set +# default: +# CONFIG_COMPILER_DUMP_RTL_FILES is not set +# default: +CONFIG_COMPILER_RT_LIB_GCCLIB=y +# default: +CONFIG_COMPILER_RT_LIB_NAME="gcc" +# default: +CONFIG_COMPILER_ORPHAN_SECTIONS_ERROR=y +# default: +# CONFIG_COMPILER_ORPHAN_SECTIONS_WARNING is not set +# default: +# CONFIG_COMPILER_ORPHAN_SECTIONS_PLACE is not set +# default: +# CONFIG_COMPILER_STATIC_ANALYZER is not set +# default: +CONFIG_COMPILER_CXX_GLIBCXX_CONSTEXPR_NO_CHANGE=y +# default: +# CONFIG_COMPILER_CXX_GLIBCXX_CONSTEXPR_COLD_CONSTEXPR is not set +# default: +# CONFIG_COMPILER_CXX_GLIBCXX_CONSTEXPR_COLD is not set +# end of Compiler options + +# +# Component config +# + +# +# Bluetooth +# +# default: +# CONFIG_BT_ENABLED is not set + +# +# Common Options +# + +# +# BT Logs +# + +# +# Log Sources +# +# end of Log Sources + +# default: +# CONFIG_BT_LOG_CRITICAL_ONLY is not set +# default: +# CONFIG_BLE_LOG_ENABLED is not set + +# +# Legacy SPI Log Output (Deprecated - use BT Log Async Output instead) +# +# default: +# CONFIG_BT_BLE_LOG_SPI_OUT_ENABLED is not set +# end of Legacy SPI Log Output (Deprecated - use BT Log Async Output instead) +# end of BT Logs + +# default: +# CONFIG_BT_LE_USED_MEM_STATISTICS_ENABLED is not set +# end of Common Options +# end of Bluetooth + +# +# Console Library +# +# default: +# CONFIG_CONSOLE_SORTED_HELP is not set +# end of Console Library + +# +# Legacy Driver Configurations +# + +# +# Legacy TWAI Driver Configurations +# +# default: +# CONFIG_TWAI_ISR_IN_IRAM_LEGACY is not set +# default: +# CONFIG_TWAI_SUPPRESS_DEPRECATE_WARN is not set +# default: +# CONFIG_TWAI_SKIP_LEGACY_CONFLICT_CHECK is not set +# end of Legacy TWAI Driver Configurations + +# +# Legacy I2C Driver Configurations +# +# default: +# CONFIG_I2C_SUPPRESS_DEPRECATE_WARN is not set +# default: +# CONFIG_I2C_SKIP_LEGACY_CONFLICT_CHECK is not set +# end of Legacy I2C Driver Configurations +# end of Legacy Driver Configurations + +# +# eFuse Bit Manager +# +# default: +# CONFIG_EFUSE_CUSTOM_TABLE is not set +# default: +# CONFIG_EFUSE_VIRTUAL is not set +# default: +CONFIG_EFUSE_MAX_BLK_LEN=256 +# end of eFuse Bit Manager + +# +# ESP-TLS +# +# default: +CONFIG_ESP_TLS_USING_MBEDTLS=y +# default: +# CONFIG_ESP_TLS_CUSTOM_STACK is not set +# default: +CONFIG_ESP_TLS_USE_DS_PERIPHERAL=y +# default: +# CONFIG_ESP_TLS_CLIENT_SESSION_TICKETS is not set +# default: +# CONFIG_ESP_TLS_SERVER_SESSION_TICKETS is not set +# default: +# CONFIG_ESP_TLS_SERVER_CERT_SELECT_HOOK is not set +# default: +# CONFIG_ESP_TLS_SERVER_MIN_AUTH_MODE_OPTIONAL is not set +# default: +# CONFIG_ESP_TLS_PSK_VERIFICATION is not set +# default: +# CONFIG_ESP_TLS_INSECURE is not set +# default: +CONFIG_ESP_TLS_DYN_BUF_STRATEGY_SUPPORTED=y +# end of ESP-TLS + +# +# ADC and ADC Calibration +# +# default: +# CONFIG_ADC_ONESHOT_CTRL_FUNC_IN_IRAM is not set +# default: +# CONFIG_ADC_CONTINUOUS_ISR_IRAM_SAFE is not set +# default: +# CONFIG_ADC_CONTINUOUS_FORCE_USE_ADC2_ON_C3_S3 is not set +# default: +# CONFIG_ADC_ONESHOT_FORCE_USE_ADC2_ON_C3 is not set +# default: +# CONFIG_ADC_ENABLE_DEBUG_LOG is not set +# end of ADC and ADC Calibration + +# +# Wireless Coexistence +# +# default: +CONFIG_ESP_COEX_ENABLED=y +# default: +# CONFIG_ESP_COEX_EXTERNAL_COEXIST_ENABLE is not set +# default: +# CONFIG_ESP_COEX_GPIO_DEBUG is not set +# end of Wireless Coexistence + +# +# Common ESP-related +# +# default: +CONFIG_ESP_ERR_TO_NAME_LOOKUP=y +# end of Common ESP-related + +# +# GDMA Configurations +# +# default: +CONFIG_GDMA_CTRL_FUNC_IN_IRAM=y +# default: +CONFIG_GDMA_ISR_HANDLER_IN_IRAM=y +# default: +CONFIG_GDMA_OBJ_DRAM_SAFE=y +# default: +# CONFIG_GDMA_ENABLE_DEBUG_LOG is not set +# end of GDMA Configurations + +# +# ESP-Driver:GPIO Configurations +# +# default: +# CONFIG_GPIO_CTRL_FUNC_IN_IRAM is not set +# end of ESP-Driver:GPIO Configurations + +# +# ESP-Driver:GPTimer Configurations +# +# default: +CONFIG_GPTIMER_ISR_HANDLER_IN_IRAM=y +# default: +# CONFIG_GPTIMER_CTRL_FUNC_IN_IRAM is not set +# default: +# CONFIG_GPTIMER_ISR_CACHE_SAFE is not set +# default: +CONFIG_GPTIMER_OBJ_CACHE_SAFE=y +# default: +# CONFIG_GPTIMER_ENABLE_DEBUG_LOG is not set +# end of ESP-Driver:GPTimer Configurations + +# +# ESP-Driver:I2C Configurations +# +# default: +# CONFIG_I2C_ISR_IRAM_SAFE is not set +# default: +# CONFIG_I2C_ENABLE_DEBUG_LOG is not set +# default: +CONFIG_I2C_MASTER_ISR_HANDLER_IN_IRAM=y +# end of ESP-Driver:I2C Configurations + +# +# ESP-Driver:I2S Configurations +# +# default: +# CONFIG_I2S_ISR_IRAM_SAFE is not set +# default: +# CONFIG_I2S_CTRL_FUNC_IN_IRAM is not set +# default: +# CONFIG_I2S_ENABLE_DEBUG_LOG is not set +# end of ESP-Driver:I2S Configurations + +# +# ESP-Driver:I3C Master Configurations +# +# default: +# CONFIG_I3C_MASTER_ISR_CACHE_SAFE is not set +# default: +# CONFIG_I3C_MASTER_ENABLE_DEBUG_LOG is not set +# default: +# CONFIG_I3C_MASTER_ISR_HANDLER_IN_IRAM is not set +# end of ESP-Driver:I3C Master Configurations + +# +# ESP-Driver:LEDC Configurations +# +# default: +# CONFIG_LEDC_CTRL_FUNC_IN_IRAM is not set +# end of ESP-Driver:LEDC Configurations + +# +# ESP-Driver:RMT Configurations +# +# default: +CONFIG_RMT_ENCODER_FUNC_IN_IRAM=y +# default: +CONFIG_RMT_TX_ISR_HANDLER_IN_IRAM=y +# default: +CONFIG_RMT_RX_ISR_HANDLER_IN_IRAM=y +# default: +# CONFIG_RMT_RECV_FUNC_IN_IRAM is not set +# default: +# CONFIG_RMT_TX_ISR_CACHE_SAFE is not set +# default: +# CONFIG_RMT_RX_ISR_CACHE_SAFE is not set +# default: +CONFIG_RMT_OBJ_CACHE_SAFE=y +# default: +# CONFIG_RMT_ENABLE_DEBUG_LOG is not set +# default: +# CONFIG_RMT_ISR_IRAM_SAFE is not set +# end of ESP-Driver:RMT Configurations + +# +# ESP-Driver:Sigma Delta Modulator Configurations +# +# default: +# CONFIG_SDM_CTRL_FUNC_IN_IRAM is not set +# default: +# CONFIG_SDM_ENABLE_DEBUG_LOG is not set +# end of ESP-Driver:Sigma Delta Modulator Configurations + +# +# ESP-Driver:SPI Configurations +# +# default: +CONFIG_SPI_MASTER_ISR_IN_IRAM=y +# default: +# CONFIG_SPI_SLAVE_IN_IRAM is not set +# default: +CONFIG_SPI_SLAVE_ISR_IN_IRAM=y +# end of ESP-Driver:SPI Configurations + +# +# ESP-Driver:Temperature Sensor Configurations +# +# default: +# CONFIG_TEMP_SENSOR_ENABLE_DEBUG_LOG is not set +# end of ESP-Driver:Temperature Sensor Configurations + +# +# ESP-Driver:TWAI Configurations +# +# default: +# CONFIG_TWAI_ISR_IN_IRAM is not set +# default: +# CONFIG_TWAI_IO_FUNC_IN_IRAM is not set +# default: +# CONFIG_TWAI_ISR_CACHE_SAFE is not set +# default: +# CONFIG_TWAI_ENABLE_DEBUG_LOG is not set +# end of ESP-Driver:TWAI Configurations + +# +# ESP-Driver:UART Configurations +# +# default: +# CONFIG_UART_ISR_IN_IRAM is not set +# end of ESP-Driver:UART Configurations + +# +# ESP-Driver:UHCI Configurations +# +# default: +# CONFIG_UHCI_ISR_HANDLER_IN_IRAM is not set +# default: +# CONFIG_UHCI_ISR_CACHE_SAFE is not set +# default: +# CONFIG_UHCI_ENABLE_DEBUG_LOG is not set +# end of ESP-Driver:UHCI Configurations + +# +# ESP-Driver:USB Serial/JTAG Configuration +# +# default: +CONFIG_USJ_ENABLE_USB_SERIAL_JTAG=y +# end of ESP-Driver:USB Serial/JTAG Configuration + +# +# Ethernet +# +# default: +CONFIG_ETH_ENABLED=y +# default: +CONFIG_ETH_USE_SPI_ETHERNET=y +# default: +# CONFIG_ETH_USE_OPENETH is not set +# default: +# CONFIG_ETH_TRANSMIT_MUTEX is not set +# end of Ethernet + +# +# Event Loop Library +# +# default: +# CONFIG_ESP_EVENT_LOOP_PROFILING is not set +# default: +CONFIG_ESP_EVENT_POST_FROM_ISR=y +# default: +CONFIG_ESP_EVENT_POST_FROM_IRAM_ISR=y +# end of Event Loop Library + +# +# GDB Stub +# +# default: +CONFIG_ESP_GDBSTUB_ENABLED=y +# default: +# CONFIG_ESP_SYSTEM_GDBSTUB_RUNTIME is not set +# default: +CONFIG_ESP_GDBSTUB_SUPPORT_TASKS=y +# default: +CONFIG_ESP_GDBSTUB_MAX_TASKS=32 +# end of GDB Stub + +# +# ESP HID +# +# default: +CONFIG_ESPHID_TASK_SIZE_BT=2048 +# default: +CONFIG_ESPHID_TASK_SIZE_BLE=4096 +# end of ESP HID + +# +# ESP HTTP client +# +# default: +CONFIG_ESP_HTTP_CLIENT_ENABLE_HTTPS=y +# default: +# CONFIG_ESP_HTTP_CLIENT_ENABLE_BASIC_AUTH is not set +# default: +# CONFIG_ESP_HTTP_CLIENT_ENABLE_DIGEST_AUTH is not set +# default: +# CONFIG_ESP_HTTP_CLIENT_ENABLE_CUSTOM_TRANSPORT is not set +# default: +# CONFIG_ESP_HTTP_CLIENT_ENABLE_GET_CONTENT_RANGE is not set +# default: +CONFIG_ESP_HTTP_CLIENT_EVENT_POST_TIMEOUT=2000 +# default: +# CONFIG_ESP_HTTP_CLIENT_SAVE_RESPONSE_HEADERS is not set +# default: +CONFIG_ESP_HTTP_CLIENT_STRICT_HEADER_BUFFER=y +# end of ESP HTTP client + +# +# HTTP Server +# +# default: +CONFIG_HTTPD_MAX_REQ_HDR_LEN=1024 +# default: +CONFIG_HTTPD_MAX_URI_LEN=512 +# default: +CONFIG_HTTPD_ERR_RESP_NO_DELAY=y +# default: +CONFIG_HTTPD_PURGE_BUF_LEN=32 +# default: +# CONFIG_HTTPD_LOG_PURGE_DATA is not set +# default: +# CONFIG_HTTPD_WS_SUPPORT is not set +# default: +# CONFIG_HTTPD_QUEUE_WORK_BLOCKING is not set +# default: +CONFIG_HTTPD_ENABLE_EVENTS=y +# default: +CONFIG_HTTPD_SERVER_EVENT_POST_TIMEOUT=2000 +# end of HTTP Server + +# +# ESP HTTPS OTA +# +# default: +# CONFIG_ESP_HTTPS_OTA_DECRYPT_CB is not set +# default: +# CONFIG_ESP_HTTPS_OTA_ALLOW_HTTP is not set +# default: +CONFIG_ESP_HTTPS_OTA_EVENT_POST_TIMEOUT=2000 +# default: +# CONFIG_ESP_HTTPS_OTA_ENABLE_PARTIAL_DOWNLOAD is not set +# end of ESP HTTPS OTA + +# +# ESP HTTPS server +# +# default: +# CONFIG_ESP_HTTPS_SERVER_ENABLE is not set +# default: +# CONFIG_ESP_HTTPS_SERVER_CERT_SELECT_HOOK is not set +# end of ESP HTTPS server + +# +# Hardware Settings +# +# default: +CONFIG_ESP_HW_SUPPORT_FUNC_IN_IRAM=y + +# +# Chip revision +# +# default: +# CONFIG_ESP32C3_REV_MIN_0 is not set +# default: +# CONFIG_ESP32C3_REV_MIN_1 is not set +# default: +# CONFIG_ESP32C3_REV_MIN_2 is not set +# default: +CONFIG_ESP32C3_REV_MIN_3=y +# default: +# CONFIG_ESP32C3_REV_MIN_4 is not set +# default: +# CONFIG_ESP32C3_REV_MIN_101 is not set +# default: +CONFIG_ESP32C3_REV_MIN_FULL=3 +# default: +CONFIG_ESP_REV_MIN_FULL=3 + +# +# Maximum Supported ESP32-C3 Revision (Rev v1.99) +# +# default: +CONFIG_ESP32C3_REV_MAX_FULL=199 +# default: +CONFIG_ESP_REV_MAX_FULL=199 +# default: +CONFIG_ESP_EFUSE_BLOCK_REV_MIN_FULL=0 +# default: +CONFIG_ESP_EFUSE_BLOCK_REV_MAX_FULL=199 + +# +# Maximum Supported ESP32-C3 eFuse Block Revision (eFuse Block Rev v1.99) +# +# end of Chip revision + +# +# MAC Config +# +# default: +CONFIG_ESP_MAC_ADDR_UNIVERSE_WIFI_STA=y +# default: +CONFIG_ESP_MAC_ADDR_UNIVERSE_WIFI_AP=y +# default: +CONFIG_ESP_MAC_ADDR_UNIVERSE_BT=y +# default: +CONFIG_ESP_MAC_ADDR_UNIVERSE_ETH=y +# default: +CONFIG_ESP_MAC_UNIVERSAL_MAC_ADDRESSES_FOUR=y +# default: +CONFIG_ESP_MAC_UNIVERSAL_MAC_ADDRESSES=4 +# default: +# CONFIG_ESP32C3_UNIVERSAL_MAC_ADDRESSES_TWO is not set +# default: +CONFIG_ESP32C3_UNIVERSAL_MAC_ADDRESSES_FOUR=y +# default: +CONFIG_ESP32C3_UNIVERSAL_MAC_ADDRESSES=4 +# default: +# CONFIG_ESP_MAC_USE_CUSTOM_MAC_AS_BASE_MAC is not set +# end of MAC Config + +# +# Sleep Config +# +# default: +# CONFIG_ESP_SLEEP_POWER_DOWN_FLASH is not set +# default: +CONFIG_ESP_SLEEP_FLASH_LEAKAGE_WORKAROUND=y +# default: +# CONFIG_ESP_SLEEP_MSPI_NEED_ALL_IO_PU is not set +# default: +CONFIG_ESP_SLEEP_GPIO_RESET_WORKAROUND=y +# default: +CONFIG_ESP_SLEEP_WAIT_FLASH_READY_EXTRA_DELAY=0 +# default: +# CONFIG_ESP_SLEEP_CACHE_SAFE_ASSERTION is not set +# default: +# CONFIG_ESP_SLEEP_DEBUG is not set +# default: +CONFIG_ESP_SLEEP_GPIO_ENABLE_INTERNAL_RESISTORS=y +# end of Sleep Config + +# +# RTC Clock Config +# +# default: +CONFIG_RTC_CLK_SRC_INT_RC=y +# default: +# CONFIG_RTC_CLK_SRC_EXT_CRYS is not set +# default: +# CONFIG_RTC_CLK_SRC_EXT_OSC is not set +# default: +# CONFIG_RTC_CLK_SRC_INT_8MD256 is not set +# default: +CONFIG_RTC_CLK_CAL_CYCLES=1024 +# default: +CONFIG_RTC_CLK_FUNC_IN_IRAM=y +# default: +CONFIG_RTC_TIME_FUNC_IN_IRAM=y +# end of RTC Clock Config + +# +# Peripheral Control +# +# default: +CONFIG_ESP_PERIPH_CTRL_FUNC_IN_IRAM=y +# default: +CONFIG_ESP_REGI2C_CTRL_FUNC_IN_IRAM=y +# end of Peripheral Control + +# +# Main XTAL Config +# +# default: +CONFIG_XTAL_FREQ_40=y +# default: +CONFIG_XTAL_FREQ=40 +# end of Main XTAL Config + +# +# Power Supplier +# + +# +# Brownout Detector +# +# default: +CONFIG_ESP_BROWNOUT_DET=y +# default: +CONFIG_ESP_BROWNOUT_DET_LVL_SEL_7=y +# default: +# CONFIG_ESP_BROWNOUT_DET_LVL_SEL_6 is not set +# default: +# CONFIG_ESP_BROWNOUT_DET_LVL_SEL_5 is not set +# default: +# CONFIG_ESP_BROWNOUT_DET_LVL_SEL_4 is not set +# default: +# CONFIG_ESP_BROWNOUT_DET_LVL_SEL_3 is not set +# default: +# CONFIG_ESP_BROWNOUT_DET_LVL_SEL_2 is not set +# default: +CONFIG_ESP_BROWNOUT_DET_LVL=7 +# default: +CONFIG_ESP_BROWNOUT_USE_INTR=y +# end of Brownout Detector +# end of Power Supplier + +# default: +CONFIG_ESP_SPI_BUS_LOCK_ISR_FUNCS_IN_IRAM=y +# default: +CONFIG_ESP_INTR_IN_IRAM=y +# end of Hardware Settings + +# +# ESP-Driver:LCD Controller Configurations +# +# default: +# CONFIG_LCD_ENABLE_DEBUG_LOG is not set +# end of ESP-Driver:LCD Controller Configurations + +# +# LibC +# +# default: +# CONFIG_LIBC_NEWLIB is not set +# default: +CONFIG_LIBC_PICOLIBC=y +# default: +CONFIG_LIBC_PICOLIBC_NEWLIB_COMPATIBILITY=y +# default: +CONFIG_LIBC_MISC_IN_IRAM=y +# default: +CONFIG_LIBC_LOCKS_PLACE_IN_IRAM=y +# default: +CONFIG_LIBC_STDOUT_LINE_ENDING_CRLF=y +# default: +# CONFIG_LIBC_STDOUT_LINE_ENDING_LF is not set +# default: +# CONFIG_LIBC_STDOUT_LINE_ENDING_CR is not set +# default: +# CONFIG_LIBC_STDIN_LINE_ENDING_CRLF is not set +# default: +# CONFIG_LIBC_STDIN_LINE_ENDING_LF is not set +# default: +CONFIG_LIBC_STDIN_LINE_ENDING_CR=y +# default: +CONFIG_LIBC_TIME_SYSCALL_USE_RTC_HRT=y +# default: +# CONFIG_LIBC_TIME_SYSCALL_USE_RTC is not set +# default: +# CONFIG_LIBC_TIME_SYSCALL_USE_HRT is not set +# default: +# CONFIG_LIBC_TIME_SYSCALL_USE_NONE is not set +# default: +CONFIG_LIBC_OPTIMIZED_MISALIGNED_ACCESS=y +# default: +CONFIG_LIBC_ASSERT_BUFFER_SIZE=200 +# end of LibC + +# +# ESP-MM: Memory Management Configurations +# +# end of ESP-MM: Memory Management Configurations + +# +# ESP NETIF Adapter +# +# default: +CONFIG_ESP_NETIF_LOST_IP_TIMER_ENABLE=y +# default: +CONFIG_ESP_NETIF_IP_LOST_TIMER_INTERVAL=120 +# default: +# CONFIG_ESP_NETIF_PROVIDE_CUSTOM_IMPLEMENTATION is not set +# default: +CONFIG_ESP_NETIF_TCPIP_LWIP=y +# default: +# CONFIG_ESP_NETIF_LOOPBACK is not set +# default: +CONFIG_ESP_NETIF_USES_TCPIP_WITH_BSD_API=y +# default: +CONFIG_ESP_NETIF_REPORT_DATA_TRAFFIC=y +# default: +CONFIG_ESP_NETIF_RECEIVE_REPORT_ERRORS=y +# default: +# CONFIG_ESP_NETIF_L2_TAP is not set +# default: +# CONFIG_ESP_NETIF_BRIDGE_EN is not set +# default: +# CONFIG_ESP_NETIF_SET_DNS_PER_DEFAULT_NETIF is not set +# end of ESP NETIF Adapter + +# +# Partition API Configuration +# +# end of Partition API Configuration + +# +# PHY +# +# default: +CONFIG_ESP_PHY_ENABLED=y +# default: +CONFIG_ESP_PHY_CALIBRATION_AND_DATA_STORAGE=y +# default: +# CONFIG_ESP_PHY_INIT_DATA_IN_PARTITION is not set +# default: +CONFIG_ESP_PHY_MAX_WIFI_TX_POWER=20 +# default: +CONFIG_ESP_PHY_MAX_TX_POWER=20 +# default: +# CONFIG_ESP_PHY_REDUCE_TX_POWER is not set +# default: +CONFIG_ESP_PHY_ENABLE_USB=y +# default: +# CONFIG_ESP_PHY_ENABLE_CERT_TEST is not set +# default: +CONFIG_ESP_PHY_RF_CAL_PARTIAL=y +# default: +# CONFIG_ESP_PHY_RF_CAL_NONE is not set +# default: +# CONFIG_ESP_PHY_RF_CAL_FULL is not set +# default: +CONFIG_ESP_PHY_CALIBRATION_MODE=0 +# default: +CONFIG_ESP_PHY_PLL_TRACK_PERIOD_MS=1000 +# default: +# CONFIG_ESP_PHY_PLL_TRACK_DEBUG is not set +# default: +# CONFIG_ESP_PHY_RECORD_USED_TIME is not set +# default: +CONFIG_ESP_PHY_IRAM_OPT=y +# default: +# CONFIG_ESP_PHY_DEBUG is not set +# default: +CONFIG_ESP_PHY_ENABLE_VERSION_PRINT=y +# end of PHY + +# +# Power Management +# +# default: +# CONFIG_PM_SLEEP_FUNC_IN_IRAM is not set +# default: +# CONFIG_PM_ENABLE is not set +# default: +# CONFIG_PM_SLP_IRAM_OPT is not set +# default: +CONFIG_PM_POWER_DOWN_CPU_IN_LIGHT_SLEEP=y +# default: +CONFIG_PM_ESP_SLEEP_POWER_DOWN_CPU=y +# end of Power Management + +# +# ESP PSRAM +# + +# +# ESP Ringbuf +# +# default: +# CONFIG_RINGBUF_IN_IRAM is not set +# default: +# CONFIG_RINGBUF_PLACE_ISR_FUNCTIONS_INTO_FLASH is not set +# end of ESP Ringbuf + +# +# ESP-ROM +# +# default: +CONFIG_ESP_ROM_PRINT_IN_IRAM=y +# end of ESP-ROM + +# +# ESP Security Specific +# +# end of ESP Security Specific + +# +# ESP-STDIO +# +# CONFIG_ESP_CONSOLE_UART_DEFAULT is not set +CONFIG_ESP_CONSOLE_USB_SERIAL_JTAG=y +# CONFIG_ESP_CONSOLE_UART_CUSTOM is not set +# CONFIG_ESP_CONSOLE_NONE is not set +CONFIG_ESP_CONSOLE_SECONDARY_NONE=y +# default: +CONFIG_ESP_CONSOLE_USB_SERIAL_JTAG_ENABLED=y +# default: +CONFIG_ESP_CONSOLE_UART_NUM=-1 +# default: +CONFIG_ESP_CONSOLE_ROM_SERIAL_PORT_NUM=3 +# end of ESP-STDIO + +# +# ESP System Settings +# +# default: +# CONFIG_ESP_DEFAULT_CPU_FREQ_MHZ_80 is not set +# default: +CONFIG_ESP_DEFAULT_CPU_FREQ_MHZ_160=y +# default: +CONFIG_ESP_DEFAULT_CPU_FREQ_MHZ=160 +# default: +CONFIG_ESP_SYSTEM_IN_IRAM=y +# default: +# CONFIG_ESP_SYSTEM_PANIC_PRINT_HALT is not set +# default: +CONFIG_ESP_SYSTEM_PANIC_PRINT_REBOOT=y +# default: +# CONFIG_ESP_SYSTEM_PANIC_SILENT_REBOOT is not set +# default: +# CONFIG_ESP_SYSTEM_PANIC_GDBSTUB is not set +# default: +CONFIG_ESP_SYSTEM_PANIC_REBOOT_DELAY_SECONDS=0 +# default: +CONFIG_ESP_SYSTEM_SINGLE_CORE_MODE=y +# default: +CONFIG_ESP_SYSTEM_RTC_FAST_MEM_AS_HEAP_DEPCHECK=y +# default: +CONFIG_ESP_SYSTEM_ALLOW_RTC_FAST_MEM_AS_HEAP=y +# default: +CONFIG_ESP_SYSTEM_NO_BACKTRACE=y +# default: +# CONFIG_ESP_SYSTEM_USE_EH_FRAME is not set +# default: +# CONFIG_ESP_SYSTEM_USE_FRAME_POINTER is not set +# default: +CONFIG_ESP_SYSTEM_MEMPROT=y +# default: +CONFIG_ESP_SYSTEM_MEMPROT_PMS=y +# default: +CONFIG_ESP_SYSTEM_MEMPROT_PMS_LOCK=y +# default: +CONFIG_ESP_SYSTEM_EVENT_QUEUE_SIZE=32 +# default: +CONFIG_ESP_SYSTEM_EVENT_TASK_STACK_SIZE=2304 +CONFIG_ESP_MAIN_TASK_STACK_SIZE=6144 +# default: +CONFIG_ESP_MAIN_TASK_AFFINITY_CPU0=y +# default: +# CONFIG_ESP_MAIN_TASK_AFFINITY_NO_AFFINITY is not set +# default: +CONFIG_ESP_MAIN_TASK_AFFINITY=0x0 +# default: +CONFIG_ESP_MINIMAL_SHARED_STACK_SIZE=2048 +# default: +CONFIG_ESP_INT_WDT=y +# default: +CONFIG_ESP_INT_WDT_TIMEOUT_MS=300 +# default: +CONFIG_ESP_TASK_WDT_EN=y +# default: +CONFIG_ESP_TASK_WDT_INIT=y +# default: +# CONFIG_ESP_TASK_WDT_PANIC is not set +# default: +CONFIG_ESP_TASK_WDT_TIMEOUT_S=5 +# default: +CONFIG_ESP_TASK_WDT_CHECK_IDLE_TASK_CPU0=y +# default: +# CONFIG_ESP_PANIC_HANDLER_IRAM is not set +# default: +# CONFIG_ESP_DEBUG_STUBS_ENABLE is not set +# default: +CONFIG_ESP_DEBUG_OCDAWARE=y +# default: +CONFIG_ESP_SYSTEM_CHECK_INT_LEVEL_4=y +# default: +CONFIG_ESP_SYSTEM_HW_STACK_GUARD=y +# default: +CONFIG_ESP_SYSTEM_HW_PC_RECORD=y +# end of ESP System Settings + +# +# IPC (Inter-Processor Call) +# +# default: +CONFIG_ESP_IPC_TASK_STACK_SIZE=1024 +# end of IPC (Inter-Processor Call) + +# +# ESP Timer (High Resolution Timer) +# +# default: +CONFIG_ESP_TIMER_IN_IRAM=y +# default: +# CONFIG_ESP_TIMER_PROFILING is not set +# default: +CONFIG_ESP_TIME_FUNCS_USE_RTC_TIMER=y +# default: +CONFIG_ESP_TIME_FUNCS_USE_ESP_TIMER=y +# default: +CONFIG_ESP_TIMER_TASK_STACK_SIZE=3584 +# default: +CONFIG_ESP_TIMER_INTERRUPT_LEVEL=1 +# default: +# CONFIG_ESP_TIMER_SHOW_EXPERIMENTAL is not set +# default: +CONFIG_ESP_TIMER_TASK_AFFINITY=0x0 +# default: +CONFIG_ESP_TIMER_TASK_AFFINITY_CPU0=y +# default: +CONFIG_ESP_TIMER_ISR_AFFINITY_CPU0=y +# default: +# CONFIG_ESP_TIMER_SUPPORTS_ISR_DISPATCH_METHOD is not set +# default: +CONFIG_ESP_TIMER_IMPL_SYSTIMER=y +# end of ESP Timer (High Resolution Timer) + +# +# ESP Trace Configuration +# +# default: +# CONFIG_ESP_TRACE_LIB_EXTERNAL is not set +# default: +CONFIG_ESP_TRACE_LIB_NONE=y +# default: +CONFIG_ESP_TRACE_LIB_NAME="none" +# default: +# CONFIG_ESP_TRACE_TRANSPORT_APPTRACE is not set +# default: +CONFIG_ESP_TRACE_TRANSPORT_NONE=y +# default: +CONFIG_ESP_TRACE_TRANSPORT_NAME="none" +# end of ESP Trace Configuration + +# +# Wi-Fi +# +# default: +CONFIG_ESP_WIFI_ENABLED=y +CONFIG_ESP_WIFI_STATIC_RX_BUFFER_NUM=16 +CONFIG_ESP_WIFI_DYNAMIC_RX_BUFFER_NUM=32 +# default: +# CONFIG_ESP_WIFI_STATIC_TX_BUFFER is not set +# default: +CONFIG_ESP_WIFI_DYNAMIC_TX_BUFFER=y +# default: +CONFIG_ESP_WIFI_TX_BUFFER_TYPE=1 +CONFIG_ESP_WIFI_DYNAMIC_TX_BUFFER_NUM=32 +# default: +CONFIG_ESP_WIFI_STATIC_RX_MGMT_BUFFER=y +# default: +# CONFIG_ESP_WIFI_DYNAMIC_RX_MGMT_BUFFER is not set +# default: +CONFIG_ESP_WIFI_DYNAMIC_RX_MGMT_BUF=0 +# default: +CONFIG_ESP_WIFI_RX_MGMT_BUF_NUM_DEF=5 +# default: +# CONFIG_ESP_WIFI_CSI_ENABLED is not set +# default: +CONFIG_ESP_WIFI_AMPDU_TX_ENABLED=y +# default: +CONFIG_ESP_WIFI_TX_BA_WIN=6 +# default: +CONFIG_ESP_WIFI_AMPDU_RX_ENABLED=y +# default: +CONFIG_ESP_WIFI_RX_BA_WIN=6 +# default: +CONFIG_ESP_WIFI_NVS_ENABLED=y +# default: +CONFIG_ESP_WIFI_SOFTAP_BEACON_MAX_LEN=752 +# default: +CONFIG_ESP_WIFI_MGMT_SBUF_NUM=32 +# default: +CONFIG_ESP_WIFI_IRAM_OPT=y +# default: +# CONFIG_ESP_WIFI_EXTRA_IRAM_OPT is not set +# default: +CONFIG_ESP_WIFI_RX_IRAM_OPT=y +# default: +CONFIG_ESP_WIFI_ENABLE_WPA3_SAE=y +# default: +CONFIG_ESP_WIFI_ENABLE_SAE_H2E=y +# default: +CONFIG_ESP_WIFI_ENABLE_SAE_PK=y +# default: +CONFIG_ESP_WIFI_SOFTAP_SAE_SUPPORT=y +# default: +CONFIG_ESP_WIFI_ENABLE_WPA3_OWE_STA=y +# default: +CONFIG_ESP_WIFI_WPA3_COMPATIBLE_SUPPORT=y +# default: +# CONFIG_ESP_WIFI_SLP_IRAM_OPT is not set +# default: +CONFIG_ESP_WIFI_SLP_DEFAULT_MIN_ACTIVE_TIME=50 +# default: +# CONFIG_ESP_WIFI_BSS_MAX_IDLE_SUPPORT is not set +# default: +CONFIG_ESP_WIFI_SLP_DEFAULT_MAX_ACTIVE_TIME=10 +# default: +CONFIG_ESP_WIFI_SLP_DEFAULT_WAIT_BROADCAST_DATA_TIME=15 +# default: +# CONFIG_ESP_WIFI_FTM_ENABLE is not set +# default: +CONFIG_ESP_WIFI_STA_DISCONNECTED_PM_ENABLE=y +# default: +# CONFIG_ESP_WIFI_GCMP_SUPPORT is not set +# default: +CONFIG_ESP_WIFI_GMAC_SUPPORT=y +# default: +CONFIG_ESP_WIFI_SOFTAP_SUPPORT=y +# default: +# CONFIG_ESP_WIFI_SLP_BEACON_LOST_OPT is not set +# default: +CONFIG_ESP_WIFI_ESPNOW_MAX_ENCRYPT_NUM=7 +# default: +CONFIG_ESP_WIFI_MBEDTLS_CRYPTO=y +# default: +CONFIG_ESP_WIFI_MBEDTLS_TLS_CLIENT=y +# default: +# CONFIG_ESP_WIFI_WAPI_PSK is not set +# default: +# CONFIG_ESP_WIFI_SUITE_B_192 is not set +# default: +# CONFIG_ESP_WIFI_11KV_SUPPORT is not set +# default: +# CONFIG_ESP_WIFI_MBO_SUPPORT is not set +# default: +# CONFIG_ESP_WIFI_DPP_SUPPORT is not set +# default: +# CONFIG_ESP_WIFI_11R_SUPPORT is not set +# default: +# CONFIG_ESP_WIFI_WPS_SOFTAP_REGISTRAR is not set + +# +# WPS Configuration Options +# +# default: +# CONFIG_ESP_WIFI_WPS_STRICT is not set +# default: +# CONFIG_ESP_WIFI_WPS_PASSPHRASE is not set +# default: +# CONFIG_ESP_WIFI_WPS_RECONNECT_ON_FAIL is not set +# end of WPS Configuration Options + +# default: +# CONFIG_ESP_WIFI_DEBUG_PRINT is not set +# default: +CONFIG_ESP_WIFI_ENTERPRISE_SUPPORT=y +# default: +# CONFIG_ESP_WIFI_ENT_FREE_DYNAMIC_BUFFER is not set +# default: +# CONFIG_ESP_WIFI_PASSIVE_HIDDEN_AP_SUPPORT is not set +# end of Wi-Fi + +# +# Core dump +# +# default: +# CONFIG_ESP_COREDUMP_ENABLE_TO_FLASH is not set +# default: +# CONFIG_ESP_COREDUMP_ENABLE_TO_UART is not set +# default: +CONFIG_ESP_COREDUMP_ENABLE_TO_NONE=y +# end of Core dump + +# +# FAT Filesystem support +# +# default: +CONFIG_FATFS_VOLUME_COUNT=2 +# default: +# CONFIG_FATFS_LFN_NONE is not set +# default: +CONFIG_FATFS_LFN_HEAP=y +# default: +# CONFIG_FATFS_LFN_STACK is not set +# default: +# CONFIG_FATFS_SECTOR_512 is not set +# default: +CONFIG_FATFS_SECTOR_4096=y +# default: +# CONFIG_FATFS_CODEPAGE_DYNAMIC is not set +# default: +CONFIG_FATFS_CODEPAGE_437=y +# default: +# CONFIG_FATFS_CODEPAGE_720 is not set +# default: +# CONFIG_FATFS_CODEPAGE_737 is not set +# default: +# CONFIG_FATFS_CODEPAGE_771 is not set +# default: +# CONFIG_FATFS_CODEPAGE_775 is not set +# default: +# CONFIG_FATFS_CODEPAGE_850 is not set +# default: +# CONFIG_FATFS_CODEPAGE_852 is not set +# default: +# CONFIG_FATFS_CODEPAGE_855 is not set +# default: +# CONFIG_FATFS_CODEPAGE_857 is not set +# default: +# CONFIG_FATFS_CODEPAGE_860 is not set +# default: +# CONFIG_FATFS_CODEPAGE_861 is not set +# default: +# CONFIG_FATFS_CODEPAGE_862 is not set +# default: +# CONFIG_FATFS_CODEPAGE_863 is not set +# default: +# CONFIG_FATFS_CODEPAGE_864 is not set +# default: +# CONFIG_FATFS_CODEPAGE_865 is not set +# default: +# CONFIG_FATFS_CODEPAGE_866 is not set +# default: +# CONFIG_FATFS_CODEPAGE_869 is not set +# default: +# CONFIG_FATFS_CODEPAGE_932 is not set +# default: +# CONFIG_FATFS_CODEPAGE_936 is not set +# default: +# CONFIG_FATFS_CODEPAGE_949 is not set +# default: +# CONFIG_FATFS_CODEPAGE_950 is not set +# default: +CONFIG_FATFS_CODEPAGE=437 +# default: +CONFIG_FATFS_MAX_LFN=255 +# default: +CONFIG_FATFS_API_ENCODING_ANSI_OEM=y +# default: +# CONFIG_FATFS_API_ENCODING_UTF_8 is not set +# default: +CONFIG_FATFS_FS_LOCK=0 +# default: +CONFIG_FATFS_TIMEOUT_MS=10000 +# default: +CONFIG_FATFS_PER_FILE_CACHE=y +# default: +# CONFIG_FATFS_USE_FASTSEEK is not set +# default: +CONFIG_FATFS_USE_STRFUNC_NONE=y +# default: +# CONFIG_FATFS_USE_STRFUNC_WITHOUT_CRLF_CONV is not set +# default: +# CONFIG_FATFS_USE_STRFUNC_WITH_CRLF_CONV is not set +# default: +CONFIG_FATFS_VFS_FSTAT_BLKSIZE=0 +# default: +# CONFIG_FATFS_IMMEDIATE_FSYNC is not set +# default: +# CONFIG_FATFS_USE_LABEL is not set +# default: +CONFIG_FATFS_LINK_LOCK=y +# default: +CONFIG_FATFS_USE_DYN_BUFFERS=y + +# +# File system free space calculation behavior +# +# default: +CONFIG_FATFS_DONT_TRUST_FREE_CLUSTER_CNT=0 +# default: +CONFIG_FATFS_DONT_TRUST_LAST_ALLOC=0 +# end of File system free space calculation behavior +# end of FAT Filesystem support + +# +# FreeRTOS +# + +# +# Kernel +# +# default: +# CONFIG_FREERTOS_SMP is not set +# default: +CONFIG_FREERTOS_UNICORE=y +CONFIG_FREERTOS_HZ=1000 +# default: +CONFIG_FREERTOS_OPTIMIZED_SCHEDULER=y +# default: +# CONFIG_FREERTOS_CHECK_STACKOVERFLOW_NONE is not set +# default: +# CONFIG_FREERTOS_CHECK_STACKOVERFLOW_PTRVAL is not set +# default: +CONFIG_FREERTOS_CHECK_STACKOVERFLOW_CANARY=y +# default: +CONFIG_FREERTOS_THREAD_LOCAL_STORAGE_POINTERS=1 +# default: +CONFIG_FREERTOS_IDLE_TASK_STACKSIZE=1536 +# default: +# CONFIG_FREERTOS_USE_IDLE_HOOK is not set +# default: +# CONFIG_FREERTOS_USE_TICK_HOOK is not set +# default: +CONFIG_FREERTOS_MAX_TASK_NAME_LEN=16 +# default: +# CONFIG_FREERTOS_ENABLE_BACKWARD_COMPATIBILITY is not set +# default: +CONFIG_FREERTOS_USE_TIMERS=y +# default: +CONFIG_FREERTOS_TIMER_SERVICE_TASK_NAME="Tmr Svc" +# default: +# CONFIG_FREERTOS_TIMER_TASK_AFFINITY_CPU0 is not set +# default: +CONFIG_FREERTOS_TIMER_TASK_NO_AFFINITY=y +# default: +CONFIG_FREERTOS_TIMER_SERVICE_TASK_CORE_AFFINITY=0x7FFFFFFF +# default: +CONFIG_FREERTOS_TIMER_TASK_PRIORITY=1 +# default: +CONFIG_FREERTOS_TIMER_TASK_STACK_DEPTH=2048 +# default: +CONFIG_FREERTOS_TIMER_QUEUE_LENGTH=10 +# default: +CONFIG_FREERTOS_QUEUE_REGISTRY_SIZE=0 +# default: +CONFIG_FREERTOS_TASK_NOTIFICATION_ARRAY_ENTRIES=1 +# default: +# CONFIG_FREERTOS_USE_TRACE_FACILITY is not set +# default: +# CONFIG_FREERTOS_USE_LIST_DATA_INTEGRITY_CHECK_BYTES is not set +# default: +# CONFIG_FREERTOS_GENERATE_RUN_TIME_STATS is not set +# default: +# CONFIG_FREERTOS_USE_APPLICATION_TASK_TAG is not set +# end of Kernel + +# +# Port +# +# default: +CONFIG_FREERTOS_TASK_FUNCTION_WRAPPER=y +# default: +# CONFIG_FREERTOS_WATCHPOINT_END_OF_STACK is not set +# default: +CONFIG_FREERTOS_TLSP_DELETION_CALLBACKS=y +# default: +# CONFIG_FREERTOS_TASK_PRE_DELETION_HOOK is not set +# default: +CONFIG_FREERTOS_CHECK_MUTEX_GIVEN_BY_OWNER=y +# default: +CONFIG_FREERTOS_ISR_STACKSIZE=1536 +# default: +CONFIG_FREERTOS_INTERRUPT_BACKTRACE=y +# default: +CONFIG_FREERTOS_TICK_SUPPORT_SYSTIMER=y +# default: +CONFIG_FREERTOS_CORETIMER_SYSTIMER_LVL1=y +# default: +# CONFIG_FREERTOS_CORETIMER_SYSTIMER_LVL3 is not set +# default: +CONFIG_FREERTOS_SYSTICK_USES_SYSTIMER=y +# default: +# CONFIG_FREERTOS_IN_IRAM is not set +# default: +# CONFIG_FREERTOS_CHECK_PORT_CRITICAL_COMPLIANCE is not set +# end of Port + +# +# Extra +# +# end of Extra + +# default: +CONFIG_FREERTOS_PORT=y +# default: +CONFIG_FREERTOS_NO_AFFINITY=0x7FFFFFFF +# default: +CONFIG_FREERTOS_SUPPORT_STATIC_ALLOCATION=y +# default: +CONFIG_FREERTOS_DEBUG_OCDAWARE=y +# default: +CONFIG_FREERTOS_NUMBER_OF_CORES=1 +# end of FreeRTOS + +# +# Hardware Abstraction Layer (HAL) and Low Level (LL) +# +# default: +CONFIG_HAL_ASSERTION_EQUALS_SYSTEM=y +# default: +# CONFIG_HAL_ASSERTION_DISABLE is not set +# default: +# CONFIG_HAL_ASSERTION_SILENT is not set +# default: +# CONFIG_HAL_ASSERTION_ENABLE is not set +# default: +CONFIG_HAL_DEFAULT_ASSERTION_LEVEL=2 +# default: +CONFIG_HAL_GPIO_USE_ROM_IMPL=y +# end of Hardware Abstraction Layer (HAL) and Low Level (LL) + +# +# Heap memory debugging +# +# default: +CONFIG_HEAP_POISONING_DISABLED=y +# default: +# CONFIG_HEAP_POISONING_LIGHT is not set +# default: +# CONFIG_HEAP_POISONING_COMPREHENSIVE is not set +# default: +CONFIG_HEAP_TRACING_OFF=y +# default: +# CONFIG_HEAP_TRACING_STANDALONE is not set +# default: +# CONFIG_HEAP_TRACING_TOHOST is not set +# default: +# CONFIG_HEAP_USE_HOOKS is not set +# default: +# CONFIG_HEAP_TASK_TRACKING is not set +# default: +# CONFIG_HEAP_ABORT_WHEN_ALLOCATION_FAILS is not set +# default: +# CONFIG_HEAP_PLACE_FUNCTION_INTO_FLASH is not set +# end of Heap memory debugging + +# +# Log +# +# default: +CONFIG_LOG_VERSION_1=y +# default: +# CONFIG_LOG_VERSION_2 is not set +# default: +CONFIG_LOG_VERSION=1 + +# +# Log Level +# +# CONFIG_LOG_DEFAULT_LEVEL_NONE is not set +# CONFIG_LOG_DEFAULT_LEVEL_ERROR is not set +# CONFIG_LOG_DEFAULT_LEVEL_WARN is not set +CONFIG_LOG_DEFAULT_LEVEL_INFO=y +# CONFIG_LOG_DEFAULT_LEVEL_DEBUG is not set +# CONFIG_LOG_DEFAULT_LEVEL_VERBOSE is not set +# default: +CONFIG_LOG_DEFAULT_LEVEL=3 +# default: +CONFIG_LOG_MAXIMUM_EQUALS_DEFAULT=y +# default: +# CONFIG_LOG_MAXIMUM_LEVEL_DEBUG is not set +# default: +# CONFIG_LOG_MAXIMUM_LEVEL_VERBOSE is not set +# default: +CONFIG_LOG_MAXIMUM_LEVEL=3 + +# +# Level Settings +# +# default: +# CONFIG_LOG_MASTER_LEVEL is not set +# default: +CONFIG_LOG_DYNAMIC_LEVEL_CONTROL=y +# default: +# CONFIG_LOG_TAG_LEVEL_IMPL_NONE is not set +# default: +# CONFIG_LOG_TAG_LEVEL_IMPL_LINKED_LIST is not set +# default: +CONFIG_LOG_TAG_LEVEL_IMPL_CACHE_AND_LINKED_LIST=y +# default: +# CONFIG_LOG_TAG_LEVEL_CACHE_ARRAY is not set +# default: +CONFIG_LOG_TAG_LEVEL_CACHE_BINARY_MIN_HEAP=y +# default: +CONFIG_LOG_TAG_LEVEL_IMPL_CACHE_SIZE=31 +# end of Level Settings +# end of Log Level + +# +# Format +# +# default: +# CONFIG_LOG_COLORS is not set +# default: +CONFIG_LOG_TIMESTAMP_SOURCE_RTOS=y +# default: +# CONFIG_LOG_TIMESTAMP_SOURCE_SYSTEM is not set +# end of Format + +# +# Settings +# +# default: +CONFIG_LOG_MODE_TEXT_EN=y +# default: +CONFIG_LOG_MODE_TEXT=y +# end of Settings + +# default: +CONFIG_LOG_IN_IRAM=y +# end of Log + +# +# LWIP +# +# default: +CONFIG_LWIP_ENABLE=y +# default: +CONFIG_LWIP_LOCAL_HOSTNAME="espressif" +# default: +CONFIG_LWIP_TCPIP_TASK_PRIO=18 +# default: +# CONFIG_LWIP_TCPIP_CORE_LOCKING is not set +# default: +# CONFIG_LWIP_CHECK_THREAD_SAFETY is not set +# default: +CONFIG_LWIP_DNS_SUPPORT_MDNS_QUERIES=y +# default: +# CONFIG_LWIP_L2_TO_L3_COPY is not set +# default: +# CONFIG_LWIP_IRAM_OPTIMIZATION is not set +# default: +# CONFIG_LWIP_EXTRA_IRAM_OPTIMIZATION is not set +# default: +CONFIG_LWIP_TIMERS_ONDEMAND=y +# default: +CONFIG_LWIP_ND6=y +# default: +# CONFIG_LWIP_FORCE_ROUTER_FORWARDING is not set +# default: +CONFIG_LWIP_MAX_SOCKETS=10 +# default: +# CONFIG_LWIP_USE_ONLY_LWIP_SELECT is not set +# default: +# CONFIG_LWIP_SO_LINGER is not set +# default: +CONFIG_LWIP_SO_REUSE=y +# default: +CONFIG_LWIP_SO_REUSE_RXTOALL=y +# default: +# CONFIG_LWIP_SO_RCVBUF is not set +# default: +# CONFIG_LWIP_NETBUF_RECVINFO is not set +# default: +CONFIG_LWIP_IP_DEFAULT_TTL=64 +# default: +CONFIG_LWIP_IP4_FRAG=y +# default: +CONFIG_LWIP_IP6_FRAG=y +# default: +# CONFIG_LWIP_IP4_REASSEMBLY is not set +# default: +# CONFIG_LWIP_IP6_REASSEMBLY is not set +# default: +CONFIG_LWIP_IP_REASS_MAX_PBUFS=10 +# default: +CONFIG_LWIP_IPV6_DUP_DETECT_ATTEMPTS=1 +# default: +# CONFIG_LWIP_IP_FORWARD is not set +# default: +# CONFIG_LWIP_STATS is not set +# default: +CONFIG_LWIP_ESP_GRATUITOUS_ARP=y +# default: +CONFIG_LWIP_GARP_TMR_INTERVAL=60 +# default: +CONFIG_LWIP_ESP_MLDV6_REPORT=y +# default: +CONFIG_LWIP_MLDV6_TMR_INTERVAL=40 +# default: +CONFIG_LWIP_TCPIP_RECVMBOX_SIZE=32 +# default: +CONFIG_LWIP_DHCP_DOES_ARP_CHECK=y +# default: +# CONFIG_LWIP_DHCP_DOES_ACD_CHECK is not set +# default: +# CONFIG_LWIP_DHCP_DOES_NOT_CHECK_OFFERED_IP is not set +# default: +# CONFIG_LWIP_DHCP_DISABLE_CLIENT_ID is not set +# default: +CONFIG_LWIP_DHCP_DISABLE_VENDOR_CLASS_ID=y +# default: +# CONFIG_LWIP_DHCP_RESTORE_LAST_IP is not set +# default: +CONFIG_LWIP_DHCP_OPTIONS_LEN=69 +# default: +CONFIG_LWIP_NUM_NETIF_CLIENT_DATA=0 +# default: +CONFIG_LWIP_DHCP_COARSE_TIMER_SECS=1 + +# +# DHCP server +# +# default: +CONFIG_LWIP_DHCPS=y +# default: +CONFIG_LWIP_DHCPS_REPORT_CLIENT_HOSTNAME=y +# default: +CONFIG_LWIP_DHCPS_LEASE_UNIT=60 +# default: +CONFIG_LWIP_DHCPS_MAX_STATION_NUM=8 +# default: +CONFIG_LWIP_DHCPS_MAX_HOSTNAME_LEN=64 +# default: +CONFIG_LWIP_DHCPS_STATIC_ENTRIES=y +# end of DHCP server + +# default: +# CONFIG_LWIP_AUTOIP is not set +# default: +CONFIG_LWIP_IPV4=y +# default: +CONFIG_LWIP_IPV6=y +# default: +# CONFIG_LWIP_IPV6_AUTOCONFIG is not set +# default: +CONFIG_LWIP_IPV6_NUM_ADDRESSES=3 +# default: +# CONFIG_LWIP_IPV6_FORWARD is not set +# default: +# CONFIG_LWIP_NETIF_STATUS_CALLBACK is not set +# default: +# CONFIG_LWIP_NETIF_LINK_CALLBACK is not set +# default: +CONFIG_LWIP_NETIF_LOOPBACK=y +# default: +CONFIG_LWIP_LOOPBACK_MAX_PBUFS=8 + +# +# TCP +# +# default: +CONFIG_LWIP_MAX_ACTIVE_TCP=16 +# default: +CONFIG_LWIP_MAX_LISTENING_TCP=16 +# default: +CONFIG_LWIP_TCP_HIGH_SPEED_RETRANSMISSION=y +# default: +CONFIG_LWIP_TCP_MAXRTX=12 +# default: +CONFIG_LWIP_TCP_SYNMAXRTX=12 +# default: +CONFIG_LWIP_TCP_MSS=1440 +# default: +CONFIG_LWIP_TCP_TMR_INTERVAL=250 +# default: +CONFIG_LWIP_TCP_MSL=60000 +# default: +CONFIG_LWIP_TCP_FIN_WAIT_TIMEOUT=20000 +# default: +CONFIG_LWIP_TCP_SND_BUF_DEFAULT=5760 +# default: +CONFIG_LWIP_TCP_WND_DEFAULT=5760 +# default: +CONFIG_LWIP_TCP_RECVMBOX_SIZE=6 +# default: +CONFIG_LWIP_TCP_ACCEPTMBOX_SIZE=6 +# default: +CONFIG_LWIP_TCP_QUEUE_OOSEQ=y +# default: +CONFIG_LWIP_TCP_OOSEQ_TIMEOUT=6 +# default: +CONFIG_LWIP_TCP_OOSEQ_MAX_PBUFS=4 +# default: +# CONFIG_LWIP_TCP_SACK_OUT is not set +# default: +CONFIG_LWIP_TCP_OVERSIZE_MSS=y +# default: +# CONFIG_LWIP_TCP_OVERSIZE_QUARTER_MSS is not set +# default: +# CONFIG_LWIP_TCP_OVERSIZE_DISABLE is not set +# default: +CONFIG_LWIP_TCP_RTO_TIME=1500 +# end of TCP + +# +# UDP +# +# default: +CONFIG_LWIP_MAX_UDP_PCBS=16 +# default: +CONFIG_LWIP_UDP_RECVMBOX_SIZE=6 +# end of UDP + +# +# Checksums +# +# default: +# CONFIG_LWIP_CHECKSUM_CHECK_IP is not set +# default: +# CONFIG_LWIP_CHECKSUM_CHECK_UDP is not set +# default: +CONFIG_LWIP_CHECKSUM_CHECK_ICMP=y +# end of Checksums + +# default: +CONFIG_LWIP_TCPIP_TASK_STACK_SIZE=3072 +# default: +CONFIG_LWIP_TCPIP_TASK_AFFINITY_NO_AFFINITY=y +# default: +# CONFIG_LWIP_TCPIP_TASK_AFFINITY_CPU0 is not set +# default: +CONFIG_LWIP_TCPIP_TASK_AFFINITY=0x7FFFFFFF +# default: +CONFIG_LWIP_IPV6_MEMP_NUM_ND6_QUEUE=3 +# default: +CONFIG_LWIP_IPV6_ND6_NUM_NEIGHBORS=5 +# default: +CONFIG_LWIP_IPV6_ND6_NUM_PREFIXES=5 +# default: +CONFIG_LWIP_IPV6_ND6_NUM_ROUTERS=3 +# default: +CONFIG_LWIP_IPV6_ND6_NUM_DESTINATIONS=10 +# default: +# CONFIG_LWIP_IPV6_ND6_ROUTE_INFO_OPTION_SUPPORT is not set +# default: +# CONFIG_LWIP_PPP_SUPPORT is not set +# default: +# CONFIG_LWIP_SLIP_SUPPORT is not set + +# +# ICMP +# +# default: +CONFIG_LWIP_ICMP=y +# default: +# CONFIG_LWIP_MULTICAST_PING is not set +# default: +# CONFIG_LWIP_BROADCAST_PING is not set +# end of ICMP + +# +# LWIP RAW API +# +# default: +CONFIG_LWIP_MAX_RAW_PCBS=16 +# end of LWIP RAW API + +# +# SNTP +# +# default: +CONFIG_LWIP_SNTP_MAX_SERVERS=1 +# default: +# CONFIG_LWIP_DHCP_GET_NTP_SRV is not set +# default: +CONFIG_LWIP_SNTP_UPDATE_DELAY=3600000 +# default: +CONFIG_LWIP_SNTP_STARTUP_DELAY=y +# default: +CONFIG_LWIP_SNTP_MAXIMUM_STARTUP_DELAY=5000 +# end of SNTP + +# +# DNS +# +# default: +CONFIG_LWIP_DNS_MAX_HOST_IP=1 +# default: +CONFIG_LWIP_DNS_MAX_SERVERS=3 +# default: +# CONFIG_LWIP_FALLBACK_DNS_SERVER_SUPPORT is not set +# default: +# CONFIG_LWIP_DNS_SETSERVER_WITH_NETIF is not set +# default: +# CONFIG_LWIP_USE_ESP_GETADDRINFO is not set +# end of DNS + +# default: +CONFIG_LWIP_BRIDGEIF_MAX_PORTS=7 +# default: +CONFIG_LWIP_ESP_LWIP_ASSERT=y + +# +# Hooks +# +# default: +# CONFIG_LWIP_HOOK_TCP_ISN_NONE is not set +# default: +CONFIG_LWIP_HOOK_TCP_ISN_DEFAULT=y +# default: +# CONFIG_LWIP_HOOK_TCP_ISN_CUSTOM is not set +# default: +CONFIG_LWIP_HOOK_IP6_ROUTE_NONE=y +# default: +# CONFIG_LWIP_HOOK_IP6_ROUTE_DEFAULT is not set +# default: +# CONFIG_LWIP_HOOK_IP6_ROUTE_CUSTOM is not set +# default: +CONFIG_LWIP_HOOK_ND6_GET_GW_NONE=y +# default: +# CONFIG_LWIP_HOOK_ND6_GET_GW_DEFAULT is not set +# default: +# CONFIG_LWIP_HOOK_ND6_GET_GW_CUSTOM is not set +# default: +CONFIG_LWIP_HOOK_IP6_SELECT_SRC_ADDR_NONE=y +# default: +# CONFIG_LWIP_HOOK_IP6_SELECT_SRC_ADDR_DEFAULT is not set +# default: +# CONFIG_LWIP_HOOK_IP6_SELECT_SRC_ADDR_CUSTOM is not set +# default: +CONFIG_LWIP_HOOK_DHCP_EXTRA_OPTION_NONE=y +# default: +# CONFIG_LWIP_HOOK_DHCP_EXTRA_OPTION_DEFAULT is not set +# default: +# CONFIG_LWIP_HOOK_DHCP_EXTRA_OPTION_CUSTOM is not set +# default: +CONFIG_LWIP_HOOK_NETCONN_EXT_RESOLVE_NONE=y +# default: +# CONFIG_LWIP_HOOK_NETCONN_EXT_RESOLVE_DEFAULT is not set +# default: +# CONFIG_LWIP_HOOK_NETCONN_EXT_RESOLVE_CUSTOM is not set +# default: +CONFIG_LWIP_HOOK_DNS_EXT_RESOLVE_NONE=y +# default: +# CONFIG_LWIP_HOOK_DNS_EXT_RESOLVE_CUSTOM is not set +# default: +# CONFIG_LWIP_HOOK_IP6_INPUT_NONE is not set +# default: +CONFIG_LWIP_HOOK_IP6_INPUT_DEFAULT=y +# default: +# CONFIG_LWIP_HOOK_IP6_INPUT_CUSTOM is not set +# end of Hooks + +# default: +# CONFIG_LWIP_DEBUG is not set +# end of LWIP + +# +# mbedTLS +# + +# +# Core Configuration +# +# default: +CONFIG_MBEDTLS_VER_4_X_SUPPORT=y +# default: +# CONFIG_MBEDTLS_COMPILER_OPTIMIZATION_NONE is not set +# default: +CONFIG_MBEDTLS_COMPILER_OPTIMIZATION_SIZE=y +# default: +# CONFIG_MBEDTLS_COMPILER_OPTIMIZATION_PERF is not set +# default: +CONFIG_MBEDTLS_FS_IO=y +# default: +# CONFIG_MBEDTLS_PSA_ITS_CUSTOM_STORAGE_BACKEND is not set +# default: +CONFIG_MBEDTLS_THREADING_C=y +# default: +# CONFIG_MBEDTLS_THREADING_ALT is not set +# default: +CONFIG_MBEDTLS_THREADING_PTHREAD=y +# default: +CONFIG_MBEDTLS_ERROR_STRINGS=y +# default: +CONFIG_MBEDTLS_VERSION_C=y +# default: +CONFIG_MBEDTLS_HAVE_TIME=y +# default: +# CONFIG_MBEDTLS_PLATFORM_TIME_ALT is not set +# default: +# CONFIG_MBEDTLS_HAVE_TIME_DATE is not set +# default: +CONFIG_MBEDTLS_INTERNAL_MEM_ALLOC=y +# default: +# CONFIG_MBEDTLS_DEFAULT_MEM_ALLOC is not set +# default: +# CONFIG_MBEDTLS_CUSTOM_MEM_ALLOC is not set +# default: +CONFIG_MBEDTLS_ASYMMETRIC_CONTENT_LEN=y +# default: +CONFIG_MBEDTLS_SSL_IN_CONTENT_LEN=16384 +# default: +CONFIG_MBEDTLS_SSL_OUT_CONTENT_LEN=4096 +# default: +# CONFIG_MBEDTLS_DYNAMIC_BUFFER is not set +# default: +# CONFIG_MBEDTLS_VERSION_FEATURES is not set +# default: +# CONFIG_MBEDTLS_DEBUG is not set +# default: +CONFIG_MBEDTLS_SELF_TEST=y +# end of Core Configuration + +# +# Certificates +# +# default: +CONFIG_MBEDTLS_X509_USE_C=y +# default: +CONFIG_MBEDTLS_PEM_PARSE_C=y +# default: +CONFIG_MBEDTLS_PEM_WRITE_C=y +# default: +CONFIG_MBEDTLS_PK_C=y +# default: +CONFIG_MBEDTLS_PK_PARSE_C=y +# default: +CONFIG_MBEDTLS_PK_WRITE_C=y +# default: +# CONFIG_MBEDTLS_X509_REMOVE_INFO is not set +# default: +CONFIG_MBEDTLS_X509_CRL_PARSE_C=y +# default: +CONFIG_MBEDTLS_X509_CRT_PARSE_C=y +# default: +CONFIG_MBEDTLS_X509_CSR_PARSE_C=y +# default: +# CONFIG_MBEDTLS_X509_CREATE_C is not set +# default: +CONFIG_MBEDTLS_X509_RSASSA_PSS_SUPPORT=y +# default: +CONFIG_MBEDTLS_X509_TRUSTED_CERT_CALLBACK=y +# default: +CONFIG_MBEDTLS_ASN1_PARSE_C=y +# default: +CONFIG_MBEDTLS_ASN1_WRITE_C=y +# default: +CONFIG_MBEDTLS_CERTIFICATE_BUNDLE=y + +# +# Certificate Bundle Configuration +# +# default: +CONFIG_MBEDTLS_CERTIFICATE_BUNDLE_DEFAULT_FULL=y +# default: +# CONFIG_MBEDTLS_CERTIFICATE_BUNDLE_DEFAULT_CMN is not set +# default: +# CONFIG_MBEDTLS_CERTIFICATE_BUNDLE_DEFAULT_NONE is not set +# default: +# CONFIG_MBEDTLS_CUSTOM_CERTIFICATE_BUNDLE is not set +# default: +# CONFIG_MBEDTLS_CERTIFICATE_BUNDLE_DEPRECATED_LIST is not set +# default: +CONFIG_MBEDTLS_CERTIFICATE_BUNDLE_MAX_CERTS=200 +# end of Certificate Bundle Configuration + +# default: +# CONFIG_MBEDTLS_ALLOW_WEAK_CERTIFICATE_VERIFICATION is not set +# default: +CONFIG_MBEDTLS_CERTIFICATE_BUNDLE_CROSS_SIGNED_VERIFY=y +# end of Certificates + +# default: +CONFIG_MBEDTLS_TLS_ENABLED=y + +# +# TLS Protocol Configuration +# +# default: +CONFIG_MBEDTLS_SSL_PROTO_TLS1_2=y +# default: +# CONFIG_MBEDTLS_SSL_PROTO_TLS1_3 is not set +# default: +# CONFIG_MBEDTLS_SSL_PROTO_GMTSSL1_1 is not set +# default: +CONFIG_MBEDTLS_TLS_SERVER=y +# default: +CONFIG_MBEDTLS_TLS_CLIENT=y +# default: +CONFIG_MBEDTLS_TLS_SERVER_AND_CLIENT=y +# default: +# CONFIG_MBEDTLS_TLS_SERVER_ONLY is not set +# default: +# CONFIG_MBEDTLS_TLS_CLIENT_ONLY is not set +# default: +# CONFIG_MBEDTLS_SSL_KEEP_PEER_CERTIFICATE is not set +# default: +# CONFIG_MBEDTLS_SSL_CONTEXT_SERIALIZATION is not set +# default: +CONFIG_MBEDTLS_SSL_CACHE_C=y +# default: +CONFIG_MBEDTLS_SSL_ALL_ALERT_MESSAGES=y + +# +# TLS Key Exchange Configuration +# +# default: +# CONFIG_MBEDTLS_PSK_MODES is not set +# default: +CONFIG_MBEDTLS_KEY_EXCHANGE_RSA=y +# default: +CONFIG_MBEDTLS_KEY_EXCHANGE_ELLIPTIC_CURVE=y +# default: +CONFIG_MBEDTLS_KEY_EXCHANGE_ECDHE_RSA=y +# default: +CONFIG_MBEDTLS_KEY_EXCHANGE_ECDHE_ECDSA=y +# end of TLS Key Exchange Configuration + +# default: +CONFIG_MBEDTLS_SSL_SERVER_NAME_INDICATION=y +# default: +CONFIG_MBEDTLS_SSL_ALPN=y +# default: +CONFIG_MBEDTLS_SSL_MAX_FRAGMENT_LENGTH=y +# default: +# CONFIG_MBEDTLS_SSL_VARIABLE_BUFFER_LENGTH is not set +# default: +CONFIG_MBEDTLS_SSL_RENEGOTIATION=y +# default: +CONFIG_MBEDTLS_CLIENT_SSL_SESSION_TICKETS=y +# default: +CONFIG_MBEDTLS_SERVER_SSL_SESSION_TICKETS=y +# default: +# CONFIG_MBEDTLS_SSL_KEYING_MATERIAL_EXPORT is not set +# end of TLS Protocol Configuration + +# default: +# CONFIG_MBEDTLS_SSL_PROTO_DTLS is not set + +# +# Symmetric Ciphers +# +# default: +CONFIG_MBEDTLS_AES_C=y +# default: +# CONFIG_MBEDTLS_CAMELLIA_C is not set +# default: +# CONFIG_MBEDTLS_ARIA_C is not set +# default: +# CONFIG_MBEDTLS_DES_C is not set +# default: +CONFIG_MBEDTLS_CCM_C=y +# default: +CONFIG_MBEDTLS_CIPHER_MODE_CBC=y +# default: +CONFIG_MBEDTLS_CIPHER_MODE_CFB=y +# default: +CONFIG_MBEDTLS_CIPHER_MODE_CTR=y +# default: +CONFIG_MBEDTLS_CIPHER_MODE_OFB=y +# default: +CONFIG_MBEDTLS_CIPHER_MODE_XTS=y +# default: +CONFIG_MBEDTLS_GCM_C=y +# default: +# CONFIG_MBEDTLS_NIST_KW_C is not set +# default: +CONFIG_MBEDTLS_AES_ROM_TABLES=y +# default: +# CONFIG_MBEDTLS_AES_FEWER_TABLES is not set +# default: +# CONFIG_MBEDTLS_AES_ONLY_128_BIT_KEY_LENGTH is not set +# default: +CONFIG_MBEDTLS_CMAC_C=y +# end of Symmetric Ciphers + +# +# Asymmetric Ciphers +# +# default: +CONFIG_MBEDTLS_RSA_C=y +# default: +CONFIG_MBEDTLS_ECP_C=y + +# +# Supported Curves +# +# default: +CONFIG_MBEDTLS_ECP_DP_SECP256R1_ENABLED=y +# default: +CONFIG_MBEDTLS_ECP_DP_SECP384R1_ENABLED=y +# default: +CONFIG_MBEDTLS_ECP_DP_SECP521R1_ENABLED=y +# default: +CONFIG_MBEDTLS_ECP_DP_SECP256K1_ENABLED=y +# default: +CONFIG_MBEDTLS_ECP_DP_BP256R1_ENABLED=y +# default: +CONFIG_MBEDTLS_ECP_DP_BP384R1_ENABLED=y +# default: +CONFIG_MBEDTLS_ECP_DP_BP512R1_ENABLED=y +# default: +CONFIG_MBEDTLS_ECP_DP_CURVE25519_ENABLED=y +# end of Supported Curves + +# +# Elliptic Curve Ciphers Configuration +# +# default: +CONFIG_MBEDTLS_ECP_NIST_OPTIM=y +# default: +# CONFIG_MBEDTLS_ECP_FIXED_POINT_OPTIM is not set +# default: +# CONFIG_MBEDTLS_DHM_C is not set +# default: +CONFIG_MBEDTLS_ECDH_C=y +# default: +# CONFIG_MBEDTLS_ECJPAKE_C is not set +# default: +CONFIG_MBEDTLS_ECDSA_C=y +# default: +CONFIG_MBEDTLS_PK_PARSE_EC_EXTENDED=y +# default: +CONFIG_MBEDTLS_PK_PARSE_EC_COMPRESSED=y +# default: +CONFIG_MBEDTLS_ECDSA_DETERMINISTIC=y +# default: +# CONFIG_MBEDTLS_ECP_RESTARTABLE is not set +# end of Elliptic Curve Ciphers Configuration +# end of Asymmetric Ciphers + +# +# Hash functions +# +# default: +# CONFIG_MBEDTLS_RIPEMD160_C is not set +# default: +CONFIG_MBEDTLS_MD_C=y +# default: +CONFIG_MBEDTLS_MD5_C=y +# default: +CONFIG_MBEDTLS_SHA1_C=y +# default: +# CONFIG_MBEDTLS_SHA224_C is not set +# default: +CONFIG_MBEDTLS_SHA256_C=y +# default: +CONFIG_MBEDTLS_SHA384_C=y +# default: +CONFIG_MBEDTLS_SHA512_C=y +# default: +# CONFIG_MBEDTLS_SHA3_C is not set +# default: +CONFIG_MBEDTLS_ROM_MD5=y +# end of Hash functions + +# +# Hardware Acceleration +# +# default: +CONFIG_MBEDTLS_HARDWARE_SHA=y +# default: +CONFIG_MBEDTLS_HARDWARE_MPI=y +# default: +CONFIG_MBEDTLS_LARGE_KEY_SOFTWARE_MPI=y +# default: +CONFIG_MBEDTLS_MPI_USE_INTERRUPT=y +# default: +CONFIG_MBEDTLS_MPI_INTERRUPT_LEVEL=0 + +# +# Security hardening +# +# default: +# CONFIG_MBEDTLS_CONSTANT_TIME_PRIME_GEN is not set +# end of Security hardening + +# default: +CONFIG_MBEDTLS_HARDWARE_AES=y +# default: +# CONFIG_MBEDTLS_AES_SOFT_FALLBACK is not set +# default: +CONFIG_MBEDTLS_GCM_SUPPORT_NON_AES_CIPHER=y +# default: +CONFIG_MBEDTLS_AES_USE_INTERRUPT=y +# default: +CONFIG_MBEDTLS_AES_INTERRUPT_LEVEL=0 +# default: +CONFIG_MBEDTLS_AES_HW_SMALL_DATA_LEN_OPTIM=y +# default: +# CONFIG_MBEDTLS_SECURE_ELEMENT_DRIVER_ENABLED is not set +# default: +CONFIG_MBEDTLS_HARDWARE_RSA_DS_PERIPHERAL=y +# end of Hardware Acceleration + +# +# Entropy and Random Number Generation +# +# default: +# CONFIG_MBEDTLS_ENTROPY_FORCE_SHA256 is not set +# default: +CONFIG_MBEDTLS_CTR_DRBG_C=y +# default: +CONFIG_MBEDTLS_HMAC_DRBG_C=y +# end of Entropy and Random Number Generation + +# +# Encoding/Decoding +# +# default: +CONFIG_MBEDTLS_BASE64_C=y +# default: +CONFIG_MBEDTLS_PKCS5_C=y +# default: +CONFIG_MBEDTLS_PKCS7_C=y +# default: +CONFIG_MBEDTLS_PKCS1_V15=y +# default: +CONFIG_MBEDTLS_PKCS1_V21=y +# end of Encoding/Decoding + +# +# Stream Cipher +# +# default: +# CONFIG_MBEDTLS_CHACHA20_C is not set +# end of Stream Cipher +# end of mbedTLS + +# +# NVS +# +# default: +# CONFIG_NVS_ENCRYPTION is not set +# default: +# CONFIG_NVS_ASSERT_ERROR_CHECK is not set +# default: +# CONFIG_NVS_LEGACY_DUP_KEYS_COMPATIBILITY is not set +# default: +# CONFIG_NVS_BDL_STACK is not set +# default: +# CONFIG_NVS_FLASH_VERIFY_ERASE is not set +# end of NVS + +# +# OpenThread +# +# default: +# CONFIG_OPENTHREAD_ENABLED is not set + +# +# OpenThread Spinel +# +# default: +# CONFIG_OPENTHREAD_SPINEL_ONLY is not set +# end of OpenThread Spinel + +# default: +# CONFIG_OPENTHREAD_DEBUG is not set +# end of OpenThread + +# +# Protocomm +# +# default: +# CONFIG_ESP_PROTOCOMM_SUPPORT_SECURITY_VERSION_0 is not set +# default: +# CONFIG_ESP_PROTOCOMM_SUPPORT_SECURITY_VERSION_1 is not set +# default: +CONFIG_ESP_PROTOCOMM_SUPPORT_SECURITY_VERSION_2=y +# default: +CONFIG_ESP_PROTOCOMM_SUPPORT_SECURITY_PATCH_VERSION=y +# end of Protocomm + +# +# PThreads +# +# default: +CONFIG_PTHREAD_TASK_PRIO_DEFAULT=5 +# default: +CONFIG_PTHREAD_TASK_STACK_SIZE_DEFAULT=3072 +# default: +CONFIG_PTHREAD_STACK_MIN=768 +# default: +CONFIG_PTHREAD_TASK_CORE_DEFAULT=-1 +# default: +CONFIG_PTHREAD_TASK_NAME_DEFAULT="pthread" +# end of PThreads + +# +# SD Protocol Layer Configuration +# +# default: +CONFIG_SD_ENABLE_SDIO_SUPPORT=y +# end of SD Protocol Layer Configuration + +# +# MMU Config +# +# default: +CONFIG_MMU_PAGE_SIZE_64KB=y +# default: +CONFIG_MMU_PAGE_MODE="64KB" +# default: +CONFIG_MMU_PAGE_SIZE=0x10000 +# end of MMU Config + +# +# Main Flash configuration +# + +# +# SPI Flash behavior when brownout +# +# default: +CONFIG_SPI_FLASH_BROWNOUT_RESET_XMC=y +# default: +CONFIG_SPI_FLASH_BROWNOUT_RESET=y +# end of SPI Flash behavior when brownout + +# +# Optional and Experimental Features (READ DOCS FIRST) +# + +# +# Features here require specific hardware (READ DOCS FIRST!) +# +# default: +# CONFIG_SPI_FLASH_AUTO_SUSPEND is not set +# default: +CONFIG_SPI_FLASH_SUSPEND_TSUS_VAL_US=50 +# default: +# CONFIG_SPI_FLASH_FORCE_ENABLE_XMC_C_SUSPEND is not set +# default: +# CONFIG_SPI_FLASH_FORCE_ENABLE_C6_H2_SUSPEND is not set +# default: +CONFIG_SPI_FLASH_PLACE_FUNCTIONS_IN_IRAM=y +# end of Optional and Experimental Features (READ DOCS FIRST) +# end of Main Flash configuration + +# +# SPI Flash driver +# +# default: +# CONFIG_SPI_FLASH_VERIFY_WRITE is not set +# default: +# CONFIG_SPI_FLASH_ENABLE_COUNTERS is not set +# default: +# CONFIG_SPI_FLASH_ROM_IMPL is not set +# default: +CONFIG_SPI_FLASH_DANGEROUS_WRITE_ABORTS=y +# default: +# CONFIG_SPI_FLASH_DANGEROUS_WRITE_FAILS is not set +# default: +# CONFIG_SPI_FLASH_DANGEROUS_WRITE_ALLOWED is not set +# default: +# CONFIG_SPI_FLASH_BYPASS_BLOCK_ERASE is not set +# default: +CONFIG_SPI_FLASH_YIELD_DURING_ERASE=y +# default: +CONFIG_SPI_FLASH_ERASE_YIELD_DURATION_MS=20 +# default: +CONFIG_SPI_FLASH_ERASE_YIELD_TICKS=1 +# default: +CONFIG_SPI_FLASH_WRITE_CHUNK_SIZE=8192 +# default: +# CONFIG_SPI_FLASH_SIZE_OVERRIDE is not set +# default: +# CONFIG_SPI_FLASH_CHECK_ERASE_TIMEOUT_DISABLED is not set +# default: +# CONFIG_SPI_FLASH_OVERRIDE_CHIP_DRIVER_LIST is not set + +# +# Auto-detect flash chips +# +# default: +CONFIG_SPI_FLASH_VENDOR_XMC_SUPPORT_ENABLED=y +# default: +CONFIG_SPI_FLASH_VENDOR_GD_SUPPORT_ENABLED=y +# default: +CONFIG_SPI_FLASH_VENDOR_ISSI_SUPPORT_ENABLED=y +# default: +CONFIG_SPI_FLASH_VENDOR_MXIC_SUPPORT_ENABLED=y +# default: +CONFIG_SPI_FLASH_VENDOR_WINBOND_SUPPORT_ENABLED=y +# default: +CONFIG_SPI_FLASH_VENDOR_BOYA_SUPPORT_ENABLED=y +# default: +CONFIG_SPI_FLASH_VENDOR_TH_SUPPORT_ENABLED=y +# default: +CONFIG_SPI_FLASH_SUPPORT_ISSI_CHIP=y +# default: +CONFIG_SPI_FLASH_SUPPORT_MXIC_CHIP=y +# default: +CONFIG_SPI_FLASH_SUPPORT_GD_CHIP=y +# default: +CONFIG_SPI_FLASH_SUPPORT_WINBOND_CHIP=y +# default: +CONFIG_SPI_FLASH_SUPPORT_BOYA_CHIP=y +# default: +CONFIG_SPI_FLASH_SUPPORT_TH_CHIP=y +# end of Auto-detect flash chips + +# default: +CONFIG_SPI_FLASH_ENABLE_ENCRYPTED_READ_WRITE=y +# end of SPI Flash driver + +# +# SPIFFS Configuration +# +# default: +CONFIG_SPIFFS_MAX_PARTITIONS=3 + +# +# SPIFFS Cache Configuration +# +# default: +CONFIG_SPIFFS_CACHE=y +# default: +CONFIG_SPIFFS_CACHE_WR=y +# default: +# CONFIG_SPIFFS_CACHE_STATS is not set +# end of SPIFFS Cache Configuration + +# default: +CONFIG_SPIFFS_PAGE_CHECK=y +# default: +CONFIG_SPIFFS_GC_MAX_RUNS=10 +# default: +# CONFIG_SPIFFS_GC_STATS is not set +# default: +CONFIG_SPIFFS_PAGE_SIZE=256 +# default: +CONFIG_SPIFFS_OBJ_NAME_LEN=32 +# default: +# CONFIG_SPIFFS_FOLLOW_SYMLINKS is not set +# default: +CONFIG_SPIFFS_USE_MAGIC=y +# default: +CONFIG_SPIFFS_USE_MAGIC_LENGTH=y +# default: +CONFIG_SPIFFS_META_LENGTH=4 +# default: +CONFIG_SPIFFS_USE_MTIME=y + +# +# Debug Configuration +# +# default: +# CONFIG_SPIFFS_DBG is not set +# default: +# CONFIG_SPIFFS_API_DBG is not set +# default: +# CONFIG_SPIFFS_GC_DBG is not set +# default: +# CONFIG_SPIFFS_CACHE_DBG is not set +# default: +# CONFIG_SPIFFS_CHECK_DBG is not set +# default: +# CONFIG_SPIFFS_TEST_VISUALISATION is not set +# end of Debug Configuration +# end of SPIFFS Configuration + +# +# TCP Transport +# + +# +# Websocket +# +# default: +CONFIG_WS_TRANSPORT=y +# default: +CONFIG_WS_BUFFER_SIZE=1024 +# default: +# CONFIG_WS_DYNAMIC_BUFFER is not set +# end of Websocket +# end of TCP Transport + +# +# Unity unit testing library +# +# default: +CONFIG_UNITY_ENABLE_FLOAT=y +# default: +CONFIG_UNITY_ENABLE_DOUBLE=y +# default: +# CONFIG_UNITY_ENABLE_64BIT is not set +# default: +# CONFIG_UNITY_ENABLE_COLOR is not set +# default: +CONFIG_UNITY_ENABLE_IDF_TEST_RUNNER=y +# default: +# CONFIG_UNITY_ENABLE_FIXTURE is not set +# default: +# CONFIG_UNITY_ENABLE_BACKTRACE_ON_FAIL is not set +# end of Unity unit testing library + +# +# Virtual file system +# +# default: +CONFIG_VFS_SUPPORT_IO=y +# default: +CONFIG_VFS_SUPPORT_DIR=y +# default: +CONFIG_VFS_SUPPORT_SELECT=y +# default: +CONFIG_VFS_SUPPRESS_SELECT_DEBUG_OUTPUT=y +# default: +# CONFIG_VFS_SELECT_IN_RAM is not set +# default: +# CONFIG_VFS_SUPPORT_TERMIOS is not set +# default: +CONFIG_VFS_MAX_COUNT=8 +# default: +# CONFIG_VFS_SUPPRESS_CTX_DEPRECATION is not set + +# +# Host File System I/O (Semihosting) +# +# default: +CONFIG_VFS_SEMIHOSTFS_MAX_MOUNT_POINTS=1 +# end of Host File System I/O (Semihosting) + +# default: +CONFIG_VFS_INITIALIZE_DEV_NULL=y +# end of Virtual file system + +# +# Wear Levelling +# +# default: +# CONFIG_WL_SECTOR_SIZE_512 is not set +# default: +CONFIG_WL_SECTOR_SIZE_4096=y +# default: +CONFIG_WL_SECTOR_SIZE=4096 +# end of Wear Levelling +# end of Component config + +# default: +# CONFIG_IDF_EXPERIMENTAL_FEATURES is not set + +# Deprecated options for backward compatibility +# CONFIG_APP_BUILD_TYPE_ELF_RAM is not set +# CONFIG_NO_BLOBS is not set +# CONFIG_APP_ROLLBACK_ENABLE is not set +# CONFIG_BOOTLOADER_COMPILER_OPTIMIZATION_NONE is not set +# CONFIG_LOG_BOOTLOADER_LEVEL_NONE is not set +# CONFIG_LOG_BOOTLOADER_LEVEL_ERROR is not set +# CONFIG_LOG_BOOTLOADER_LEVEL_WARN is not set +CONFIG_LOG_BOOTLOADER_LEVEL_INFO=y +# CONFIG_LOG_BOOTLOADER_LEVEL_DEBUG is not set +# CONFIG_LOG_BOOTLOADER_LEVEL_VERBOSE is not set +CONFIG_LOG_BOOTLOADER_LEVEL=3 +# CONFIG_FLASH_ENCRYPTION_ENABLED is not set +# CONFIG_FLASHMODE_QIO is not set +# CONFIG_FLASHMODE_QOUT is not set +CONFIG_FLASHMODE_DIO=y +# CONFIG_FLASHMODE_DOUT is not set +CONFIG_MONITOR_BAUD=115200 +CONFIG_OPTIMIZATION_LEVEL_DEBUG=y +CONFIG_COMPILER_OPTIMIZATION_LEVEL_DEBUG=y +CONFIG_COMPILER_OPTIMIZATION_DEFAULT=y +# CONFIG_OPTIMIZATION_LEVEL_RELEASE is not set +# CONFIG_COMPILER_OPTIMIZATION_LEVEL_RELEASE is not set +CONFIG_OPTIMIZATION_ASSERTIONS_ENABLED=y +# CONFIG_OPTIMIZATION_ASSERTIONS_SILENT is not set +# CONFIG_OPTIMIZATION_ASSERTIONS_DISABLED is not set +CONFIG_OPTIMIZATION_ASSERTION_LEVEL=2 +# CONFIG_CXX_EXCEPTIONS is not set +CONFIG_STACK_CHECK_NONE=y +# CONFIG_STACK_CHECK_NORM is not set +# CONFIG_STACK_CHECK_STRONG is not set +# CONFIG_STACK_CHECK_ALL is not set +# CONFIG_WARN_WRITE_STRINGS is not set +# CONFIG_EXTERNAL_COEX_ENABLE is not set +# CONFIG_ESP_WIFI_EXTERNAL_COEXIST_ENABLE is not set +# CONFIG_GPTIMER_ISR_IRAM_SAFE is not set +# CONFIG_EVENT_LOOP_PROFILING is not set +CONFIG_POST_EVENTS_FROM_ISR=y +CONFIG_POST_EVENTS_FROM_IRAM_ISR=y +CONFIG_GDBSTUB_SUPPORT_TASKS=y +CONFIG_GDBSTUB_MAX_TASKS=32 +# CONFIG_OTA_ALLOW_HTTP is not set +# CONFIG_ESP_SYSTEM_PD_FLASH is not set +CONFIG_ESP32C3_LIGHTSLEEP_GPIO_RESET_WORKAROUND=y +CONFIG_ESP32C3_RTC_CLK_SRC_INT_RC=y +# CONFIG_ESP32C3_RTC_CLK_SRC_EXT_CRYS is not set +# CONFIG_ESP32C3_RTC_CLK_SRC_EXT_OSC is not set +# CONFIG_ESP32C3_RTC_CLK_SRC_INT_8MD256 is not set +CONFIG_ESP32C3_RTC_CLK_CAL_CYCLES=1024 +CONFIG_PERIPH_CTRL_FUNC_IN_IRAM=y +CONFIG_BROWNOUT_DET=y +CONFIG_ESP32C3_BROWNOUT_DET=y +CONFIG_BROWNOUT_DET_LVL_SEL_7=y +CONFIG_ESP32C3_BROWNOUT_DET_LVL_SEL_7=y +# CONFIG_BROWNOUT_DET_LVL_SEL_6 is not set +# CONFIG_ESP32C3_BROWNOUT_DET_LVL_SEL_6 is not set +# CONFIG_BROWNOUT_DET_LVL_SEL_5 is not set +# CONFIG_ESP32C3_BROWNOUT_DET_LVL_SEL_5 is not set +# CONFIG_BROWNOUT_DET_LVL_SEL_4 is not set +# CONFIG_ESP32C3_BROWNOUT_DET_LVL_SEL_4 is not set +# CONFIG_BROWNOUT_DET_LVL_SEL_3 is not set +# CONFIG_ESP32C3_BROWNOUT_DET_LVL_SEL_3 is not set +# CONFIG_BROWNOUT_DET_LVL_SEL_2 is not set +# CONFIG_ESP32C3_BROWNOUT_DET_LVL_SEL_2 is not set +CONFIG_BROWNOUT_DET_LVL=7 +CONFIG_ESP32C3_BROWNOUT_DET_LVL=7 +CONFIG_ESP_SYSTEM_BROWNOUT_INTR=y +CONFIG_NEWLIB_STDOUT_LINE_ENDING_CRLF=y +# CONFIG_NEWLIB_STDOUT_LINE_ENDING_LF is not set +# CONFIG_NEWLIB_STDOUT_LINE_ENDING_CR is not set +# CONFIG_NEWLIB_STDIN_LINE_ENDING_CRLF is not set +# CONFIG_NEWLIB_STDIN_LINE_ENDING_LF is not set +CONFIG_NEWLIB_STDIN_LINE_ENDING_CR=y +CONFIG_NEWLIB_TIME_SYSCALL_USE_RTC_HRT=y +CONFIG_ESP32C3_TIME_SYSCALL_USE_RTC_SYSTIMER=y +# CONFIG_NEWLIB_TIME_SYSCALL_USE_RTC is not set +# CONFIG_ESP32C3_TIME_SYSCALL_USE_RTC is not set +# CONFIG_NEWLIB_TIME_SYSCALL_USE_HRT is not set +# CONFIG_ESP32C3_TIME_SYSCALL_USE_SYSTIMER is not set +# CONFIG_NEWLIB_TIME_SYSCALL_USE_NONE is not set +# CONFIG_ESP32C3_TIME_SYSCALL_USE_NONE is not set +CONFIG_ESP32_PHY_CALIBRATION_AND_DATA_STORAGE=y +# CONFIG_ESP32_PHY_INIT_DATA_IN_PARTITION is not set +CONFIG_ESP32_PHY_MAX_WIFI_TX_POWER=20 +CONFIG_ESP32_PHY_MAX_TX_POWER=20 +# CONFIG_REDUCE_PHY_TX_POWER is not set +# CONFIG_ESP32_REDUCE_PHY_TX_POWER is not set +CONFIG_ESP_SYSTEM_PM_POWER_DOWN_CPU=y +# CONFIG_CONSOLE_UART_DEFAULT is not set +# CONFIG_CONSOLE_UART_CUSTOM is not set +# CONFIG_CONSOLE_UART_NONE is not set +# CONFIG_ESP_CONSOLE_UART_NONE is not set +CONFIG_CONSOLE_UART_NUM=-1 +# CONFIG_ESP32C3_DEFAULT_CPU_FREQ_80 is not set +CONFIG_ESP32C3_DEFAULT_CPU_FREQ_160=y +CONFIG_ESP32C3_DEFAULT_CPU_FREQ_MHZ=160 +CONFIG_ESP_SYSTEM_PMP_IDRAM_SPLIT=y +CONFIG_ESP_SYSTEM_MEMPROT_FEATURE_VIA_TEE=y +CONFIG_ESP_SYSTEM_MEMPROT_FEATURE=y +CONFIG_ESP32C3_MEMPROT_FEATURE=y +CONFIG_ESP_SYSTEM_MEMPROT_FEATURE_LOCK=y +CONFIG_ESP32C3_MEMPROT_FEATURE_LOCK=y +CONFIG_SYSTEM_EVENT_QUEUE_SIZE=32 +CONFIG_SYSTEM_EVENT_TASK_STACK_SIZE=2304 +CONFIG_MAIN_TASK_STACK_SIZE=6144 +CONFIG_INT_WDT=y +CONFIG_INT_WDT_TIMEOUT_MS=300 +CONFIG_TASK_WDT=y +CONFIG_ESP_TASK_WDT=y +# CONFIG_TASK_WDT_PANIC is not set +CONFIG_TASK_WDT_TIMEOUT_S=5 +CONFIG_TASK_WDT_CHECK_IDLE_TASK_CPU0=y +# CONFIG_ESP32_DEBUG_STUBS_ENABLE is not set +CONFIG_ESP32C3_DEBUG_OCDAWARE=y +CONFIG_IPC_TASK_STACK_SIZE=1024 +CONFIG_TIMER_TASK_STACK_SIZE=3584 +# CONFIG_ESP32_APPTRACE_ENABLE is not set +CONFIG_ESP32_WIFI_ENABLED=y +CONFIG_ESP32_WIFI_STATIC_RX_BUFFER_NUM=16 +CONFIG_ESP32_WIFI_DYNAMIC_RX_BUFFER_NUM=32 +# CONFIG_ESP32_WIFI_STATIC_TX_BUFFER is not set +CONFIG_ESP32_WIFI_DYNAMIC_TX_BUFFER=y +CONFIG_ESP32_WIFI_TX_BUFFER_TYPE=1 +CONFIG_ESP32_WIFI_DYNAMIC_TX_BUFFER_NUM=32 +# CONFIG_ESP32_WIFI_CSI_ENABLED is not set +CONFIG_ESP32_WIFI_AMPDU_TX_ENABLED=y +CONFIG_ESP32_WIFI_TX_BA_WIN=6 +CONFIG_ESP32_WIFI_AMPDU_RX_ENABLED=y +CONFIG_ESP32_WIFI_RX_BA_WIN=6 +CONFIG_ESP32_WIFI_NVS_ENABLED=y +CONFIG_ESP32_WIFI_SOFTAP_BEACON_MAX_LEN=752 +CONFIG_ESP32_WIFI_MGMT_SBUF_NUM=32 +CONFIG_ESP32_WIFI_IRAM_OPT=y +CONFIG_ESP32_WIFI_RX_IRAM_OPT=y +CONFIG_ESP32_WIFI_ENABLE_WPA3_SAE=y +CONFIG_ESP32_WIFI_ENABLE_WPA3_OWE_STA=y +CONFIG_WPA_MBEDTLS_CRYPTO=y +CONFIG_WPA_MBEDTLS_TLS_CLIENT=y +# CONFIG_WPA_WAPI_PSK is not set +# CONFIG_WPA_SUITE_B_192 is not set +# CONFIG_WPA_11KV_SUPPORT is not set +# CONFIG_WPA_MBO_SUPPORT is not set +# CONFIG_WPA_DPP_SUPPORT is not set +# CONFIG_WPA_11R_SUPPORT is not set +# CONFIG_WPA_WPS_SOFTAP_REGISTRAR is not set +# CONFIG_WPA_WPS_STRICT is not set +# CONFIG_WPA_DEBUG_PRINT is not set +# CONFIG_ESP32_ENABLE_COREDUMP_TO_FLASH is not set +# CONFIG_ESP32_ENABLE_COREDUMP_TO_UART is not set +CONFIG_ESP32_ENABLE_COREDUMP_TO_NONE=y +CONFIG_TIMER_TASK_PRIORITY=1 +CONFIG_TIMER_TASK_STACK_DEPTH=2048 +CONFIG_TIMER_QUEUE_LENGTH=10 +# CONFIG_ENABLE_STATIC_TASK_CLEAN_UP_HOOK is not set +# CONFIG_HAL_ASSERTION_SILIENT is not set +# CONFIG_L2_TO_L3_COPY is not set +CONFIG_ESP_GRATUITOUS_ARP=y +CONFIG_GARP_TMR_INTERVAL=60 +CONFIG_TCPIP_RECVMBOX_SIZE=32 +CONFIG_TCP_MAXRTX=12 +CONFIG_TCP_SYNMAXRTX=12 +CONFIG_TCP_MSS=1440 +CONFIG_TCP_MSL=60000 +CONFIG_TCP_SND_BUF_DEFAULT=5760 +CONFIG_TCP_WND_DEFAULT=5760 +CONFIG_TCP_RECVMBOX_SIZE=6 +CONFIG_TCP_QUEUE_OOSEQ=y +CONFIG_TCP_OVERSIZE_MSS=y +# CONFIG_TCP_OVERSIZE_QUARTER_MSS is not set +# CONFIG_TCP_OVERSIZE_DISABLE is not set +CONFIG_UDP_RECVMBOX_SIZE=6 +CONFIG_TCPIP_TASK_STACK_SIZE=3072 +CONFIG_TCPIP_TASK_AFFINITY_NO_AFFINITY=y +# CONFIG_TCPIP_TASK_AFFINITY_CPU0 is not set +CONFIG_TCPIP_TASK_AFFINITY=0x7FFFFFFF +# CONFIG_PPP_SUPPORT is not set +# CONFIG_MBEDTLS_ATCA_HW_ECDSA_SIGN is not set +# CONFIG_MBEDTLS_ATCA_HW_ECDSA_VERIFY is not set +CONFIG_ESP32_PTHREAD_TASK_PRIO_DEFAULT=5 +CONFIG_ESP32_PTHREAD_TASK_STACK_SIZE_DEFAULT=3072 +CONFIG_ESP32_PTHREAD_STACK_MIN=768 +CONFIG_ESP32_PTHREAD_TASK_CORE_DEFAULT=-1 +CONFIG_ESP32_PTHREAD_TASK_NAME_DEFAULT="pthread" +CONFIG_SPI_FLASH_WRITING_DANGEROUS_REGIONS_ABORTS=y +# CONFIG_SPI_FLASH_WRITING_DANGEROUS_REGIONS_FAILS is not set +# CONFIG_SPI_FLASH_WRITING_DANGEROUS_REGIONS_ALLOWED is not set +CONFIG_SUPPRESS_SELECT_DEBUG_OUTPUT=y +# CONFIG_SUPPORT_TERMIOS is not set +CONFIG_SEMIHOSTFS_MAX_MOUNT_POINTS=1 +# End of deprecated options From c79f9f7ee03d424dc58274ea28e30776f9fcd87b Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 15 Oct 2026 14:57:30 +0000 Subject: [PATCH 19/99] bridge: fix M2.0 echo corruption - console must leave the USB port to the bridge Symptom (host echo test): replies grew cumulatively and never matched, i.e. the 0x00 frame delimiters were being lost. Cause: in bridge mode the native usb_serial_jtag driver AND the ESP console (on USB-Serial/JTAG via sdkconfig.defaults) both owned the same endpoint; the console's echo / line discipline corrupted the binary COBS stream. Fix: sdkconfig.bridge.defaults sets CONFIG_ESP_CONSOLE_NONE=y (plus ESPDROP_BRIDGE_MODE), so only the native driver touches the port. main's CMake REQUIRES is now config-INDEPENDENT (IDF expands requirements before CONFIG_* is set, so a config-conditional REQUIRES silently dropped esp_driver_usb_serial_jtag and broke the build); app_main.c keeps the #if mode split. Build bridge mode in its own dir with the layered defaults. Builds verified: bridge 143 KB (console NONE), console 822 KB. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01XzgaN4Bv1m8Ka9iqo7QVUN --- .gitignore | 3 +++ firmware/spikes/main/CMakeLists.txt | 5 +++++ firmware/spikes/sdkconfig.bridge.defaults | 11 +++++++++++ 3 files changed, 19 insertions(+) create mode 100644 firmware/spikes/sdkconfig.bridge.defaults diff --git a/.gitignore b/.gitignore index 1c78ed6..459de94 100644 --- a/.gitignore +++ b/.gitignore @@ -37,3 +37,6 @@ logs/ # Native host test binary test_awdl_frame test_awdl_cobs + +# Separate build dir for the bridge-mode firmware +build_bridge/ diff --git a/firmware/spikes/main/CMakeLists.txt b/firmware/spikes/main/CMakeLists.txt index 00b6a45..882e7bf 100644 --- a/firmware/spikes/main/CMakeLists.txt +++ b/firmware/spikes/main/CMakeLists.txt @@ -1,3 +1,8 @@ +# All sources compile in both modes; app_main.c selects console vs bridge at the +# C level (#if CONFIG_ESPDROP_BRIDGE_MODE), and the linker drops whatever the +# chosen mode does not call. REQUIRES is intentionally config-INDEPENDENT: +# IDF expands component requirements in an early pass where CONFIG_* is not yet +# set, so a config-conditional REQUIRES silently drops entries. idf_component_register( SRCS "app_main.c" "radio.c" "console.c" "awdl_app.c" "bridge.c" INCLUDE_DIRS "." diff --git a/firmware/spikes/sdkconfig.bridge.defaults b/firmware/spikes/sdkconfig.bridge.defaults new file mode 100644 index 0000000..d188ca5 --- /dev/null +++ b/firmware/spikes/sdkconfig.bridge.defaults @@ -0,0 +1,11 @@ +# EspDrop bridge-mode overrides. Layer this AFTER sdkconfig.defaults: +# idf.py -B build_bridge -DSDKCONFIG=build_bridge/sdkconfig \ +# -DSDKCONFIG_DEFAULTS="sdkconfig.defaults;sdkconfig.bridge.defaults" build +# +# The bridge owns the USB-Serial/JTAG with the native driver, so the interactive +# console must NOT also sit on that endpoint (its echo and CR/LF translation +# corrupt the binary COBS stream). Disable the console entirely; device-side +# visibility goes out as BRIDGE_MSG_LOG frames instead. + +CONFIG_ESPDROP_BRIDGE_MODE=y +CONFIG_ESP_CONSOLE_NONE=y From d64db8730adf7af38527060824846ec25f2ec45d Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 15 Oct 2026 15:15:18 +0000 Subject: [PATCH 20/99] bridge: require the right usb_serial_jtag component per IDF version undefined reference to usb_serial_jtag_driver_install on IDF < 5.3: the driver moved from the legacy 'driver' component (5.1/5.2) to 'esp_driver_usb_serial_jtag' (5.3+). main's REQUIRES now picks the component from IDF_VERSION (available during the early requirements pass, unlike CONFIG_*), so it links on 5.1 through 6.x. Verified on 6.0.3. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01XzgaN4Bv1m8Ka9iqo7QVUN --- firmware/spikes/main/CMakeLists.txt | 20 ++++++++++++++++---- 1 file changed, 16 insertions(+), 4 deletions(-) diff --git a/firmware/spikes/main/CMakeLists.txt b/firmware/spikes/main/CMakeLists.txt index 882e7bf..5fdf039 100644 --- a/firmware/spikes/main/CMakeLists.txt +++ b/firmware/spikes/main/CMakeLists.txt @@ -1,10 +1,22 @@ # All sources compile in both modes; app_main.c selects console vs bridge at the # C level (#if CONFIG_ESPDROP_BRIDGE_MODE), and the linker drops whatever the -# chosen mode does not call. REQUIRES is intentionally config-INDEPENDENT: -# IDF expands component requirements in an early pass where CONFIG_* is not yet -# set, so a config-conditional REQUIRES silently drops entries. +# chosen mode does not call. REQUIRES is config-INDEPENDENT (IDF expands +# requirements in an early pass where CONFIG_* is not yet set), but it may depend +# on IDF_VERSION, which IS set that early. +# +# The USB-Serial/JTAG driver lives in different components across IDF versions: +# - 5.1 / 5.2 : the legacy "driver" component +# - 5.3+ : "esp_driver_usb_serial_jtag" +# Requiring the wrong one links but leaves usb_serial_jtag_driver_install +# undefined (header found via a common require, implementation not linked). +if(IDF_VERSION_MAJOR GREATER 5 OR (IDF_VERSION_MAJOR EQUAL 5 AND IDF_VERSION_MINOR GREATER_EQUAL 3)) + set(ESPDROP_USJ_COMPONENT esp_driver_usb_serial_jtag) +else() + set(ESPDROP_USJ_COMPONENT driver) +endif() + idf_component_register( SRCS "app_main.c" "radio.c" "console.c" "awdl_app.c" "bridge.c" INCLUDE_DIRS "." - REQUIRES awdl esp_wifi esp_event esp_netif nvs_flash console esp_timer esp_driver_usb_serial_jtag + REQUIRES awdl esp_wifi esp_event esp_netif nvs_flash console esp_timer ${ESPDROP_USJ_COMPONENT} ) From 00ffb2c8ffd5d44b34ff31cb0b04cdcc60f66558 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 15 Oct 2026 15:40:42 +0000 Subject: [PATCH 21/99] bridge: default to modern usb_serial_jtag component; print the choice Flip the version test so a missing/empty IDF_VERSION picks esp_driver_usb_serial_jtag (correct on 5.3+) instead of falling through to the legacy 'driver' (which on 5.3+ lacks the symbol -> undefined reference). Only positively-detected 5.1/5.2 uses 'driver'. A configure-time message prints the chosen component and IDF version for diagnosis. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01XzgaN4Bv1m8Ka9iqo7QVUN --- firmware/spikes/main/CMakeLists.txt | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/firmware/spikes/main/CMakeLists.txt b/firmware/spikes/main/CMakeLists.txt index 5fdf039..fd760ed 100644 --- a/firmware/spikes/main/CMakeLists.txt +++ b/firmware/spikes/main/CMakeLists.txt @@ -1,19 +1,21 @@ # All sources compile in both modes; app_main.c selects console vs bridge at the # C level (#if CONFIG_ESPDROP_BRIDGE_MODE), and the linker drops whatever the # chosen mode does not call. REQUIRES is config-INDEPENDENT (IDF expands -# requirements in an early pass where CONFIG_* is not yet set), but it may depend -# on IDF_VERSION, which IS set that early. +# requirements in an early pass where CONFIG_* is not yet set). # # The USB-Serial/JTAG driver lives in different components across IDF versions: # - 5.1 / 5.2 : the legacy "driver" component # - 5.3+ : "esp_driver_usb_serial_jtag" -# Requiring the wrong one links but leaves usb_serial_jtag_driver_install -# undefined (header found via a common require, implementation not linked). -if(IDF_VERSION_MAJOR GREATER 5 OR (IDF_VERSION_MAJOR EQUAL 5 AND IDF_VERSION_MINOR GREATER_EQUAL 3)) - set(ESPDROP_USJ_COMPONENT esp_driver_usb_serial_jtag) -else() +# Default to the modern component: only pick "driver" when the version is +# positively detected as 5.1/5.2. (A missing/empty IDF_VERSION must not fall +# through to "driver", which on 5.3+ lacks the implementation and leaves +# usb_serial_jtag_driver_install undefined at link time.) +if(IDF_VERSION_MAJOR EQUAL 5 AND IDF_VERSION_MINOR LESS 3) set(ESPDROP_USJ_COMPONENT driver) +else() + set(ESPDROP_USJ_COMPONENT esp_driver_usb_serial_jtag) endif() +message(STATUS "EspDrop: USB-Serial/JTAG via '${ESPDROP_USJ_COMPONENT}' (IDF ${IDF_VERSION_MAJOR}.${IDF_VERSION_MINOR})") idf_component_register( SRCS "app_main.c" "radio.c" "console.c" "awdl_app.c" "bridge.c" From 8834cdbb68b6711f306f1b92259da5f7e8780ccf Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 15 Oct 2026 15:48:20 +0000 Subject: [PATCH 22/99] bridge: pin target esp32c3 in bridge defaults (fixes undefined usb_serial_jtag) Root cause of the link error on a fresh build_bridge dir: idf.py defaults a new build to target esp32, which has no USB-Serial/JTAG peripheral, so CONFIG_SOC_USB_SERIAL_JTAG_SUPPORTED is off and the driver source (with usb_serial_jtag_driver_install) is not compiled -> undefined reference, even though the correct component is required. sdkconfig.bridge.defaults now pins CONFIG_IDF_TARGET=esp32c3; the configure message also prints the resolved target. Verified: fresh build resolves to esp32c3 and links. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01XzgaN4Bv1m8Ka9iqo7QVUN --- firmware/spikes/main/CMakeLists.txt | 2 +- firmware/spikes/sdkconfig.bridge.defaults | 5 +++++ 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/firmware/spikes/main/CMakeLists.txt b/firmware/spikes/main/CMakeLists.txt index fd760ed..e6b2c0d 100644 --- a/firmware/spikes/main/CMakeLists.txt +++ b/firmware/spikes/main/CMakeLists.txt @@ -15,7 +15,7 @@ if(IDF_VERSION_MAJOR EQUAL 5 AND IDF_VERSION_MINOR LESS 3) else() set(ESPDROP_USJ_COMPONENT esp_driver_usb_serial_jtag) endif() -message(STATUS "EspDrop: USB-Serial/JTAG via '${ESPDROP_USJ_COMPONENT}' (IDF ${IDF_VERSION_MAJOR}.${IDF_VERSION_MINOR})") +message(STATUS "EspDrop: target '${IDF_TARGET}', USB-Serial/JTAG via '${ESPDROP_USJ_COMPONENT}' (IDF ${IDF_VERSION_MAJOR}.${IDF_VERSION_MINOR})") idf_component_register( SRCS "app_main.c" "radio.c" "console.c" "awdl_app.c" "bridge.c" diff --git a/firmware/spikes/sdkconfig.bridge.defaults b/firmware/spikes/sdkconfig.bridge.defaults index d188ca5..1d7c794 100644 --- a/firmware/spikes/sdkconfig.bridge.defaults +++ b/firmware/spikes/sdkconfig.bridge.defaults @@ -7,5 +7,10 @@ # corrupt the binary COBS stream). Disable the console entirely; device-side # visibility goes out as BRIDGE_MSG_LOG frames instead. +# Pin the chip: a fresh build dir otherwise defaults to esp32, which has no +# USB-Serial/JTAG peripheral, so usb_serial_jtag_driver_install would be +# undefined at link time. +CONFIG_IDF_TARGET="esp32c3" + CONFIG_ESPDROP_BRIDGE_MODE=y CONFIG_ESP_CONSOLE_NONE=y From df714a1b9e7b3df86308a41811030eb8215d22e1 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 15 Oct 2026 16:04:29 +0000 Subject: [PATCH 23/99] fix(host): clear COBS reassembly buffer before yielding each frame The M2.0 echo test reported growing MISMATCHes (decoded reply +1026 bytes per echo) even though the C3 was sending correctly delimited frames. Root cause was entirely host-side, in tools/bridge_echo.py's Reader: feed() reset self.buf *after* the yield. The echo loop breaks right after the first ECHO_REPLY, which suspends and abandons the feed() generator at the yield, so the buffer reset never ran. Each subsequent reply was then appended onto all prior ones and COBS-decoded merged, giving exactly (n+1)*1025 + (n-1) - 1 payload bytes at echo n (2050, 3076, 4102, ...). Fix feed() to consume the whole chunk and return a list of completed frames, resetting the buffer before any frame is handed out. This also removes a second latent bug: abandoning the read_for generator on break dropped any bytes following the delimiter in that chunk. Verified by simulation (500/500 OK, including adversarially misaligned read chunks). Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01XzgaN4Bv1m8Ka9iqo7QVUN --- tools/bridge_echo.py | 23 ++++++++++++++++++----- 1 file changed, 18 insertions(+), 5 deletions(-) diff --git a/tools/bridge_echo.py b/tools/bridge_echo.py index 48c5f7d..4d00249 100755 --- a/tools/bridge_echo.py +++ b/tools/bridge_echo.py @@ -76,23 +76,35 @@ def frame(msg_type: int, payload: bytes) -> bytes: class Reader: - """Accumulates bytes and yields (type, payload) per 0x00-delimited frame.""" + """Accumulates bytes; feed() returns completed (type, payload) frames. + + feed() consumes the whole chunk and returns a list (it must NOT be a + generator): the caller breaks out of its read loop as soon as it sees the + reply it wants, and a suspended generator would (a) never run the buffer + reset that follows a yield, so every later frame would be decoded merged + onto the previous ones, and (b) drop any bytes after the delimiter in the + current chunk. Returning a list keeps self.buf correct no matter when the + caller stops reading. + """ def __init__(self): self.buf = bytearray() def feed(self, data: bytes): + frames = [] for b in data: if b == 0: if self.buf: + frame_bytes = bytes(self.buf) + self.buf = bytearray() # reset BEFORE handing the frame out try: - dec = cobs_decode(bytes(self.buf)) + dec = cobs_decode(frame_bytes) if dec: - yield dec[0], dec[1:] + frames.append((dec[0], dec[1:])) except ValueError: pass - self.buf = bytearray() else: self.buf.append(b) + return frames def open_raw(port: str) -> int: @@ -115,7 +127,8 @@ def read_for(fd: int, reader: Reader, seconds: float): except BlockingIOError: continue if data: - yield from reader.feed(data) + for fr in reader.feed(data): + yield fr def main() -> int: From 51ebd1311385e6fed29e90f49f84fc64e2531229 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 15 Oct 2026 16:50:59 +0000 Subject: [PATCH 24/99] feat(m2.1): host TAP bridge (awdl0) + device CMD/FRAME plumbing Host: - tools/espdrop_link.py: shared COBS codec, fixed Reader, frame(), open_serial_raw, write_all, read_frames. Single source of truth so the device and every host tool agree on the wire format byte-for-byte. - tools/awdl_bridge.py: creates a Linux TAP 'awdl0', sets its MAC to the C3's Wi-Fi MAC (queried via the `mac` command), brings it up, and shuttles Ethernet frames between awdl0 and the device over BRIDGE_MSG_FRAME. Also an stdin console: mac | stats | ping | testframe. - tools/bridge_echo.py: refactored onto espdrop_link (no behaviour change). Device (bridge.c/.h): - handle BRIDGE_MSG_CMD -> CMD_REPLY (mac via esp_read_mac, stats, ping, testframe) and account incoming BRIDGE_MSG_FRAME. No radio yet (M2.1), so host->device frames are counted and dropped; M2.2+ transmits them. - send_test_frame emits one synthetic Ethernet frame (ethertype 0x88b5) so the device->host->awdl0 direction is testable before any radio exists. - CMakeLists: add esp_hw_support for esp_mac.h / esp_read_mac. Validated in-container against a real kernel TAP (create/teardown, frame write to an UP interface) and by codec/echo round-trip simulation (500/500, incl. full-MTU frames and adversarially misaligned reads). Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01XzgaN4Bv1m8Ka9iqo7QVUN --- firmware/spikes/main/CMakeLists.txt | 2 +- firmware/spikes/main/bridge.c | 64 +++++++++- firmware/spikes/main/bridge.h | 6 +- tools/awdl_bridge.py | 188 ++++++++++++++++++++++++++++ tools/bridge_echo.py | 138 +++----------------- tools/espdrop_link.py | 157 +++++++++++++++++++++++ 6 files changed, 427 insertions(+), 128 deletions(-) create mode 100644 tools/awdl_bridge.py create mode 100644 tools/espdrop_link.py diff --git a/firmware/spikes/main/CMakeLists.txt b/firmware/spikes/main/CMakeLists.txt index e6b2c0d..73e1803 100644 --- a/firmware/spikes/main/CMakeLists.txt +++ b/firmware/spikes/main/CMakeLists.txt @@ -20,5 +20,5 @@ message(STATUS "EspDrop: target '${IDF_TARGET}', USB-Serial/JTAG via '${ESPDROP_ idf_component_register( SRCS "app_main.c" "radio.c" "console.c" "awdl_app.c" "bridge.c" INCLUDE_DIRS "." - REQUIRES awdl esp_wifi esp_event esp_netif nvs_flash console esp_timer ${ESPDROP_USJ_COMPONENT} + REQUIRES awdl esp_wifi esp_event esp_netif nvs_flash console esp_timer esp_hw_support ${ESPDROP_USJ_COMPONENT} ) diff --git a/firmware/spikes/main/bridge.c b/firmware/spikes/main/bridge.c index 8b19420..8d980dd 100644 --- a/firmware/spikes/main/bridge.c +++ b/firmware/spikes/main/bridge.c @@ -12,6 +12,7 @@ #include "driver/usb_serial_jtag.h" #include "esp_log.h" +#include "esp_mac.h" #include "freertos/FreeRTOS.h" #include "freertos/task.h" #include "freertos/semphr.h" @@ -26,6 +27,58 @@ static SemaphoreHandle_t s_tx_lock; static bool s_started; +/* Counters touched only from the single RX task (handle_msg), so no locking. */ +static uint32_t s_frames_from_host; +static uint32_t s_bytes_from_host; + +/* Emit one synthetic Ethernet frame to the host so the device->host->awdl0 path + * can be exercised before any radio exists (M2.1). ff:ff:ff:ff:ff:ff <- our MAC, + * ethertype 0x88b5 (IEEE local experimental, inert) + a marker body. */ +static void send_test_frame(void) { + uint8_t mac[6] = {0}; + esp_read_mac(mac, ESP_MAC_WIFI_STA); + static const char body[] = "EspDrop M2.1 testframe"; + uint8_t eth[14 + sizeof(body) - 1]; + memset(eth, 0xff, 6); /* destination: broadcast */ + memcpy(eth + 6, mac, 6); /* source: our Wi-Fi MAC */ + eth[12] = 0x88; + eth[13] = 0xb5; /* ethertype 0x88b5 */ + memcpy(eth + 14, body, sizeof(body) - 1); + bridge_send(BRIDGE_MSG_FRAME, eth, sizeof(eth)); +} + +/* A short text command from the host (BRIDGE_MSG_CMD) -> a CMD_REPLY. */ +static void handle_cmd(const uint8_t *p, size_t n) { + char cmd[64]; + size_t k = n < sizeof(cmd) - 1 ? n : sizeof(cmd) - 1; + memcpy(cmd, p, k); + cmd[k] = '\0'; + while (k && (cmd[k - 1] == '\n' || cmd[k - 1] == '\r' || cmd[k - 1] == ' ')) + cmd[--k] = '\0'; + + char out[96]; + int len; + if (strcmp(cmd, "ping") == 0) { + bridge_send(BRIDGE_MSG_CMD_REPLY, "pong", 4); + } else if (strcmp(cmd, "mac") == 0) { + uint8_t mac[6] = {0}; + esp_read_mac(mac, ESP_MAC_WIFI_STA); + len = snprintf(out, sizeof(out), "mac %02x:%02x:%02x:%02x:%02x:%02x", + mac[0], mac[1], mac[2], mac[3], mac[4], mac[5]); + if (len > 0) bridge_send(BRIDGE_MSG_CMD_REPLY, out, (size_t)len); + } else if (strcmp(cmd, "stats") == 0) { + len = snprintf(out, sizeof(out), "frames_from_host=%u bytes_from_host=%u", + (unsigned)s_frames_from_host, (unsigned)s_bytes_from_host); + if (len > 0) bridge_send(BRIDGE_MSG_CMD_REPLY, out, (size_t)len); + } else if (strcmp(cmd, "testframe") == 0) { + send_test_frame(); + bridge_send(BRIDGE_MSG_CMD_REPLY, "sent testframe", 14); + } else { + len = snprintf(out, sizeof(out), "unknown cmd '%s'", cmd); + if (len > 0) bridge_send(BRIDGE_MSG_CMD_REPLY, out, (size_t)len); + } +} + /* Dispatch a fully decoded message (type byte + payload). */ static void handle_msg(const uint8_t *msg, size_t len) { if (len < 1) return; @@ -37,7 +90,16 @@ static void handle_msg(const uint8_t *msg, size_t len) { case BRIDGE_MSG_ECHO: bridge_send(BRIDGE_MSG_ECHO_REPLY, payload, plen); break; - /* FRAME / CMD / PEER_* are wired up in later M2 stages. */ + case BRIDGE_MSG_FRAME: + /* Host -> device Ethernet frame for awdl0. M2.1 has no radio, so account + * and drop; M2.2+ translates this to an AWDL data frame and transmits. */ + s_frames_from_host++; + s_bytes_from_host += (uint32_t)plen; + break; + case BRIDGE_MSG_CMD: + handle_cmd(payload, plen); + break; + /* PEER_* are device->host only; produced once the radio lands. */ default: break; } diff --git a/firmware/spikes/main/bridge.h b/firmware/spikes/main/bridge.h index ab10757..2923ab1 100644 --- a/firmware/spikes/main/bridge.h +++ b/firmware/spikes/main/bridge.h @@ -6,9 +6,9 @@ * (CONFIG_ESPDROP_BRIDGE_MODE). The platform-independent framing lives in * awdl_cobs.*; this module owns the USB driver, the RX task and the dispatch. * - * M2.0 handles only ECHO (for a host-side transport test) and LOG. The data and - * control message types are declared now so the host protocol is fixed, and are - * wired to AWDL in later M2 stages. + * M2.0 added ECHO (host-side transport test) and LOG. M2.1 adds CMD/CMD_REPLY + * (mac, stats, ping, testframe) and accounts incoming FRAME messages; the radio + * that gives FRAME and PEER_* real meaning is wired in later M2 stages. * * Wire format per message: COBS(type_byte || payload) followed by a 0x00 * delimiter. COBS guarantees the body carries no 0x00, so the delimiter is diff --git a/tools/awdl_bridge.py b/tools/awdl_bridge.py new file mode 100644 index 0000000..2772a6e --- /dev/null +++ b/tools/awdl_bridge.py @@ -0,0 +1,188 @@ +#!/usr/bin/env python3 +# EspDrop - M2.1 host bridge: expose the C3's AWDL link as a Linux TAP (awdl0). +# +# Creates a TAP interface and shuttles Ethernet frames between it and the device +# over the USB bridge link (BRIDGE_MSG_FRAME). The Linux IP stack rides on awdl0; +# the C3 does the Ethernet<->AWDL translation and the radio (wired up in M2.2+). +# The TAP's hardware address is set to the C3's own Wi-Fi MAC so the host and the +# device present one consistent interface to the AWDL peer. +# +# M2.1 scope is the plumbing and the interface - there is no radio yet, so frames +# the host sends on awdl0 are counted by the device and dropped. To exercise the +# device->host->TAP direction without a radio, type `testframe`: the C3 emits one +# synthetic Ethernet frame (ethertype 0x88b5) that shows up on awdl0. +# +# sudo tools/awdl_bridge.py /dev/ttyACM0 +# # in another terminal: +# ip -d link show awdl0 +# sudo tcpdump -i awdl0 -e # then type `testframe` at the bridge prompt +# +# Interactive stdin commands (forwarded to the device): mac | stats | ping | testframe +# +# Needs root: creating a TAP and running `ip link` both require CAP_NET_ADMIN. +# The interface is not persistent - it disappears when this process exits. +# +# SPDX-License-Identifier: GPL-3.0-or-later +# Copyright (C) 2026 EspDrop contributors + +import argparse +import fcntl +import os +import re +import select +import struct +import subprocess +import sys + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +import espdrop_link as link + +# From . TUNSETIFF's encoding is the same across common Linux +# architectures (dir=write, type='T', nr=202, size=4 bytes). +TUNSETIFF = 0x400454CA +IFF_TAP = 0x0002 +IFF_NO_PI = 0x1000 + +MAC_RE = re.compile(r"^([0-9a-fA-F]{2}:){5}[0-9a-fA-F]{2}$") + + +def open_tap(name: str) -> int: + fd = os.open("/dev/net/tun", os.O_RDWR) + ifr = struct.pack("16sH", name.encode(), IFF_TAP | IFF_NO_PI) + fcntl.ioctl(fd, TUNSETIFF, ifr) + return fd + + +def ip(*argv) -> None: + subprocess.run(["ip", *argv], check=True) + + +def mac_str(b: bytes) -> str: + return ":".join(f"{x:02x}" for x in b) + + +def query_mac(usb_fd: int, reader: link.Reader, timeout: float = 2.0): + """Ask the device for its Wi-Fi MAC; print any LOG lines seen meanwhile.""" + link.write_all(usb_fd, link.frame(link.MSG_CMD, b"mac")) + for t, p in link.read_frames(usb_fd, reader, timeout): + text = p.decode("utf-8", "replace").strip() + if t == link.MSG_LOG: + print(f"[device] {text}") + elif t == link.MSG_CMD_REPLY and text.startswith("mac "): + return text[4:].strip() + return None + + +def main() -> int: + ap = argparse.ArgumentParser(description="EspDrop M2.1 TAP bridge for awdl0") + ap.add_argument("port", help="device serial port, e.g. /dev/ttyACM0") + ap.add_argument("--dev", default="awdl0", help="TAP interface name (default awdl0)") + ap.add_argument("--mtu", type=int, default=1484, help="interface MTU (default 1484)") + ap.add_argument("--mac", default=None, + help="interface MAC (default: query the C3)") + ap.add_argument("--no-config", action="store_true", + help="create the TAP but do not run `ip link` (for debugging)") + args = ap.parse_args() + + if os.geteuid() != 0: + print("this tool needs root (TAP creation + `ip link`); re-run with sudo", + file=sys.stderr) + return 1 + + usb_fd = link.open_serial_raw(args.port) + reader = link.Reader() + + # Learn the device MAC (and drain boot chatter) before bringing the link up. + mac = args.mac or query_mac(usb_fd, reader) + if mac and not MAC_RE.match(mac): + print(f"warning: ignoring malformed MAC {mac!r}", file=sys.stderr) + mac = None + + tap_fd = open_tap(args.dev) + os.set_blocking(tap_fd, False) + + if not args.no_config: + if mac: + try: + ip("link", "set", "dev", args.dev, "address", mac) + except subprocess.CalledProcessError as e: + print(f"warning: could not set MAC {mac}: {e}", file=sys.stderr) + ip("link", "set", "dev", args.dev, "mtu", str(args.mtu), "up") + + print(f"{args.dev} up (mtu {args.mtu}, mac {mac or 'kernel-assigned'}); " + f"bridging {args.port}") + print("commands: mac | stats | ping | testframe (Ctrl-C to quit)") + + stdin_fd = sys.stdin.fileno() + stdin_buf = bytearray() + stdin_open = True + to_tap = 0 # device -> host frames written to awdl0 + from_tap = 0 # host -> device frames read from awdl0 + read_cap = args.mtu + 64 # one Ethernet frame plus slack + + try: + while True: + watch = [usb_fd, tap_fd] + if stdin_open: + watch.append(stdin_fd) + r, _, _ = select.select(watch, [], []) + + if usb_fd in r: + try: + data = os.read(usb_fd, 65536) + except BlockingIOError: + data = b"" + for t, p in reader.feed(data): + if t == link.MSG_FRAME: + try: + link.write_all(tap_fd, p) + to_tap += 1 + except (OSError, TimeoutError) as e: + print(f"tap write failed: {e}", file=sys.stderr) + elif t == link.MSG_LOG: + print(f"[device] {p.decode('utf-8', 'replace')}") + elif t == link.MSG_CMD_REPLY: + print(f"[reply] {p.decode('utf-8', 'replace')}") + elif t == link.MSG_PEER_ADD and len(p) == 6: + print(f"[peer+] {mac_str(p)}") + elif t == link.MSG_PEER_REMOVE and len(p) == 6: + print(f"[peer-] {mac_str(p)}") + + if tap_fd in r: + try: + pkt = os.read(tap_fd, read_cap) + except BlockingIOError: + pkt = b"" + if pkt: + try: + link.write_all(usb_fd, link.frame(link.MSG_FRAME, pkt)) + from_tap += 1 + except (OSError, TimeoutError) as e: + print(f"usb write failed: {e}", file=sys.stderr) + + if stdin_open and stdin_fd in r: + try: + chunk = os.read(stdin_fd, 4096) + except BlockingIOError: + chunk = b"" + if not chunk: + stdin_open = False # EOF (Ctrl-D or piped input exhausted) + else: + stdin_buf += chunk + while b"\n" in stdin_buf: + line, _, rest = stdin_buf.partition(b"\n") + stdin_buf = bytearray(rest) + cmd = line.strip() + if cmd: + link.write_all(usb_fd, link.frame(link.MSG_CMD, bytes(cmd))) + except KeyboardInterrupt: + print() + finally: + os.close(tap_fd) # removes the non-persistent interface + os.close(usb_fd) + print(f"stopped. host->device frames: {from_tap}, device->host frames: {to_tap}") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tools/bridge_echo.py b/tools/bridge_echo.py index 4d00249..0ce4b4a 100755 --- a/tools/bridge_echo.py +++ b/tools/bridge_echo.py @@ -1,134 +1,26 @@ #!/usr/bin/env python3 # EspDrop - M2.0 host-side echo test for the USB bridge transport. # -# Flash the firmware in bridge mode (menuconfig -> EspDrop -> Bridge mode, i.e. -# CONFIG_ESPDROP_BRIDGE_MODE=y), then run this against the device's USB-Serial/ -# JTAG port. It sends COBS-framed ECHO messages and checks that the device -# returns them byte-for-byte, which validates the framing and USB transport -# before any AWDL data flows. +# Flash the firmware in bridge mode (CONFIG_ESPDROP_BRIDGE_MODE=y), then run this +# against the device's USB-Serial/JTAG port. It sends COBS-framed ECHO messages +# and checks that the device returns them byte-for-byte, which validates the +# framing and USB transport before any AWDL data flows. # # sudo tools/bridge_echo.py /dev/ttyACM0 # one echo, incl. zeros # sudo tools/bridge_echo.py /dev/ttyACM0 --count 500 --size 1024 # throughput # -# The COBS codec here mirrors firmware/spikes/components/awdl/awdl_cobs.c; a -# mismatch would show up immediately as a failed echo. +# The COBS codec and framing live in espdrop_link.py, shared with the device. # # SPDX-License-Identifier: GPL-3.0-or-later # Copyright (C) 2026 EspDrop contributors import argparse import os -import select import sys -import termios import time -import tty -MSG_ECHO = 1 -MSG_ECHO_REPLY = 2 -MSG_LOG = 3 - - -def cobs_encode(data: bytes) -> bytes: - out = bytearray() - code_i = len(out) - out.append(0) # placeholder for the first code byte - code = 1 - for b in data: - if b == 0: - out[code_i] = code - code_i = len(out) - out.append(0) - code = 1 - else: - out.append(b) - code += 1 - if code == 0xFF: - out[code_i] = code - code_i = len(out) - out.append(0) - code = 1 - out[code_i] = code - return bytes(out) - - -def cobs_decode(data: bytes) -> bytes: - out = bytearray() - i = 0 - n = len(data) - while i < n: - code = data[i] - i += 1 - if code == 0: - raise ValueError("zero byte inside frame") - for _ in range(code - 1): - if i >= n: - raise ValueError("truncated") - out.append(data[i]) - i += 1 - if code != 0xFF and i < n: - out.append(0) - return bytes(out) - - -def frame(msg_type: int, payload: bytes) -> bytes: - return cobs_encode(bytes([msg_type]) + payload) + b"\x00" - - -class Reader: - """Accumulates bytes; feed() returns completed (type, payload) frames. - - feed() consumes the whole chunk and returns a list (it must NOT be a - generator): the caller breaks out of its read loop as soon as it sees the - reply it wants, and a suspended generator would (a) never run the buffer - reset that follows a yield, so every later frame would be decoded merged - onto the previous ones, and (b) drop any bytes after the delimiter in the - current chunk. Returning a list keeps self.buf correct no matter when the - caller stops reading. - """ - def __init__(self): - self.buf = bytearray() - - def feed(self, data: bytes): - frames = [] - for b in data: - if b == 0: - if self.buf: - frame_bytes = bytes(self.buf) - self.buf = bytearray() # reset BEFORE handing the frame out - try: - dec = cobs_decode(frame_bytes) - if dec: - frames.append((dec[0], dec[1:])) - except ValueError: - pass - else: - self.buf.append(b) - return frames - - -def open_raw(port: str) -> int: - fd = os.open(port, os.O_RDWR | os.O_NOCTTY | os.O_NONBLOCK) - try: - tty.setraw(fd) - except termios.error: - pass - return fd - - -def read_for(fd: int, reader: Reader, seconds: float): - end = time.monotonic() + seconds - while time.monotonic() < end: - r, _, _ = select.select([fd], [], [], max(0.0, end - time.monotonic())) - if not r: - continue - try: - data = os.read(fd, 65536) - except BlockingIOError: - continue - if data: - for fr in reader.feed(data): - yield fr +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +import espdrop_link as link def main() -> int: @@ -139,12 +31,12 @@ def main() -> int: ap.add_argument("--timeout", type=float, default=2.0, help="per-echo reply timeout") args = ap.parse_args() - fd = open_raw(args.port) - reader = Reader() + fd = link.open_serial_raw(args.port) + reader = link.Reader() # Drain and print any startup LOG lines first. - for t, p in read_for(fd, reader, 0.3): - if t == MSG_LOG: + for t, p in link.read_frames(fd, reader, 0.3): + if t == link.MSG_LOG: print(f"[device] {p.decode('utf-8', 'replace')}") # A payload that includes 0x00 bytes, to exercise COBS stuffing. @@ -158,12 +50,12 @@ def main() -> int: t0 = time.monotonic() total_bytes = 0 for n in range(args.count): - os.write(fd, frame(MSG_ECHO, payload)) + link.write_all(fd, link.frame(link.MSG_ECHO, payload)) got = False - for t, p in read_for(fd, reader, args.timeout): - if t == MSG_LOG: + for t, p in link.read_frames(fd, reader, args.timeout): + if t == link.MSG_LOG: print(f"[device] {p.decode('utf-8', 'replace')}") - elif t == MSG_ECHO_REPLY: + elif t == link.MSG_ECHO_REPLY: if p == payload: ok += 1 total_bytes += len(p) diff --git a/tools/espdrop_link.py b/tools/espdrop_link.py new file mode 100644 index 0000000..ef63a47 --- /dev/null +++ b/tools/espdrop_link.py @@ -0,0 +1,157 @@ +#!/usr/bin/env python3 +# EspDrop - shared host-side library for the USB bridge link. +# +# One COBS-framed, type-tagged message stream over the device's USB-Serial/JTAG, +# mirroring firmware/spikes/main/bridge.{h,c} and the COBS codec in +# firmware/spikes/components/awdl/awdl_cobs.c. The device and this module must +# agree byte-for-byte, so keep the wire format here in sync with those files. +# +# Wire format per message: COBS(type_byte || payload) followed by a 0x00 +# delimiter. COBS guarantees the body carries no 0x00, so the delimiter is +# unambiguous and the stream resynchronises after any stray bytes. +# +# Stdlib only; no external dependencies. +# +# SPDX-License-Identifier: GPL-3.0-or-later +# Copyright (C) 2026 EspDrop contributors + +import os +import select +import sys +import termios +import time +import tty + +# Message types - must match enum bridge_msg_type in firmware/spikes/main/bridge.h. +MSG_ECHO = 1 # host -> device: payload echoed back +MSG_ECHO_REPLY = 2 # device -> host: the echoed payload +MSG_LOG = 3 # device -> host: UTF-8 log text +MSG_FRAME = 4 # both: one Ethernet frame for awdl0 +MSG_PEER_ADD = 5 # device -> host: 6-byte peer MAC appeared +MSG_PEER_REMOVE = 6 # device -> host: 6-byte peer MAC gone +MSG_CMD = 7 # host -> device: command text +MSG_CMD_REPLY = 8 # device -> host: command output text + + +def cobs_encode(data: bytes) -> bytes: + out = bytearray() + code_i = len(out) + out.append(0) # placeholder for the first code byte + code = 1 + for b in data: + if b == 0: + out[code_i] = code + code_i = len(out) + out.append(0) + code = 1 + else: + out.append(b) + code += 1 + if code == 0xFF: + out[code_i] = code + code_i = len(out) + out.append(0) + code = 1 + out[code_i] = code + return bytes(out) + + +def cobs_decode(data: bytes) -> bytes: + out = bytearray() + i = 0 + n = len(data) + while i < n: + code = data[i] + i += 1 + if code == 0: + raise ValueError("zero byte inside frame") + for _ in range(code - 1): + if i >= n: + raise ValueError("truncated") + out.append(data[i]) + i += 1 + if code != 0xFF and i < n: + out.append(0) + return bytes(out) + + +def frame(msg_type: int, payload: bytes = b"") -> bytes: + """Encode one message to its on-the-wire bytes (COBS body + 0x00 delimiter).""" + return cobs_encode(bytes([msg_type]) + payload) + b"\x00" + + +class Reader: + """Accumulates bytes; feed() returns completed (type, payload) frames. + + feed() consumes the whole chunk and returns a list (it must NOT be a + generator): callers break out of their read loop as soon as they see the + frame they want, and a suspended generator would (a) never run a buffer + reset placed after a yield, so every later frame would decode merged onto + the previous ones, and (b) drop any bytes after the delimiter in the + current chunk. Returning a list keeps self.buf correct no matter when the + caller stops reading. + """ + def __init__(self): + self.buf = bytearray() + + def feed(self, data: bytes): + frames = [] + for b in data: + if b == 0: + if self.buf: + frame_bytes = bytes(self.buf) + self.buf = bytearray() # reset BEFORE handing the frame out + try: + dec = cobs_decode(frame_bytes) + if dec: + frames.append((dec[0], dec[1:])) + except ValueError: + pass + else: + self.buf.append(b) + return frames + + +def open_serial_raw(port: str) -> int: + """Open a serial/USB-CDC port as a raw binary fd (non-blocking).""" + fd = os.open(port, os.O_RDWR | os.O_NOCTTY | os.O_NONBLOCK) + try: + tty.setraw(fd) + except termios.error as e: + # A cooked tty mangles binary (echo, CR/LF translation), so this is not + # silently ignorable - warn loudly rather than corrupt the stream. + print(f"warning: could not set {port} to raw mode ({e}); " + f"binary framing may be corrupted", file=sys.stderr) + return fd + + +def write_all(fd: int, data: bytes, timeout: float = 5.0) -> None: + """Write every byte, looping over short writes and EAGAIN on a non-blocking fd.""" + mv = memoryview(data) + deadline = time.monotonic() + timeout + while mv: + try: + n = os.write(fd, mv) + if n > 0: + mv = mv[n:] + except BlockingIOError: + remaining = deadline - time.monotonic() + if remaining <= 0: + raise TimeoutError(f"write of {len(data)} bytes stalled for {timeout}s") + select.select([], [fd], [], remaining) + + +def read_frames(fd: int, reader: Reader, seconds: float): + """Yield (type, payload) frames seen on fd within the given time window.""" + end = time.monotonic() + seconds + while time.monotonic() < end: + r, _, _ = select.select([fd], [], [], max(0.0, end - time.monotonic())) + if not r: + continue + try: + data = os.read(fd, 65536) + except BlockingIOError: + continue + if data: + for fr in reader.feed(data): + yield fr From b425e6c520e00f9c2fa6427d93f56145153be3ef Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 15 Oct 2026 19:36:15 +0000 Subject: [PATCH 25/99] fix(m2.1): harden bridge disconnect/error handling and CMD replies Review follow-up on the M2.1 bridge: - awdl_bridge.py: the usb read path now treats EOF (empty read) and OSError as a device disconnect and breaks cleanly, instead of busy-spinning at 100% CPU (EOF) or crashing with a traceback (OSError) when the C3 is unplugged or reset. All setup (MAC query, TAP creation, `ip link`) now runs inside the try/finally so teardown and the summary always print. The stdin command send and query_mac's write are wrapped so a stalled/gone link warns instead of crashing. Frame forwarding uses a bounded 1s write timeout so one direction under USB backpressure cannot stall the other for long (a queued design is deferred to the throughput milestones). - bridge.c: route CMD replies through a cmd_reply() helper that derives the length from strlen, so a truncated snprintf can never be reported as longer than its buffer. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01XzgaN4Bv1m8Ka9iqo7QVUN --- firmware/spikes/main/bridge.c | 27 +++++--- tools/awdl_bridge.py | 123 ++++++++++++++++++++-------------- 2 files changed, 87 insertions(+), 63 deletions(-) diff --git a/firmware/spikes/main/bridge.c b/firmware/spikes/main/bridge.c index 8d980dd..46e0c3f 100644 --- a/firmware/spikes/main/bridge.c +++ b/firmware/spikes/main/bridge.c @@ -47,6 +47,12 @@ static void send_test_frame(void) { bridge_send(BRIDGE_MSG_FRAME, eth, sizeof(eth)); } +/* Send a NUL-terminated string as a CMD_REPLY (length from strlen, so a + * truncated snprintf can never be reported as longer than the buffer). */ +static void cmd_reply(const char *s) { + bridge_send(BRIDGE_MSG_CMD_REPLY, s, strlen(s)); +} + /* A short text command from the host (BRIDGE_MSG_CMD) -> a CMD_REPLY. */ static void handle_cmd(const uint8_t *p, size_t n) { char cmd[64]; @@ -57,25 +63,24 @@ static void handle_cmd(const uint8_t *p, size_t n) { cmd[--k] = '\0'; char out[96]; - int len; if (strcmp(cmd, "ping") == 0) { - bridge_send(BRIDGE_MSG_CMD_REPLY, "pong", 4); + cmd_reply("pong"); } else if (strcmp(cmd, "mac") == 0) { uint8_t mac[6] = {0}; esp_read_mac(mac, ESP_MAC_WIFI_STA); - len = snprintf(out, sizeof(out), "mac %02x:%02x:%02x:%02x:%02x:%02x", - mac[0], mac[1], mac[2], mac[3], mac[4], mac[5]); - if (len > 0) bridge_send(BRIDGE_MSG_CMD_REPLY, out, (size_t)len); + snprintf(out, sizeof(out), "mac %02x:%02x:%02x:%02x:%02x:%02x", + mac[0], mac[1], mac[2], mac[3], mac[4], mac[5]); + cmd_reply(out); } else if (strcmp(cmd, "stats") == 0) { - len = snprintf(out, sizeof(out), "frames_from_host=%u bytes_from_host=%u", - (unsigned)s_frames_from_host, (unsigned)s_bytes_from_host); - if (len > 0) bridge_send(BRIDGE_MSG_CMD_REPLY, out, (size_t)len); + snprintf(out, sizeof(out), "frames_from_host=%u bytes_from_host=%u", + (unsigned)s_frames_from_host, (unsigned)s_bytes_from_host); + cmd_reply(out); } else if (strcmp(cmd, "testframe") == 0) { send_test_frame(); - bridge_send(BRIDGE_MSG_CMD_REPLY, "sent testframe", 14); + cmd_reply("sent testframe"); } else { - len = snprintf(out, sizeof(out), "unknown cmd '%s'", cmd); - if (len > 0) bridge_send(BRIDGE_MSG_CMD_REPLY, out, (size_t)len); + snprintf(out, sizeof(out), "unknown cmd '%s'", cmd); + cmd_reply(out); } } diff --git a/tools/awdl_bridge.py b/tools/awdl_bridge.py index 2772a6e..992c721 100644 --- a/tools/awdl_bridge.py +++ b/tools/awdl_bridge.py @@ -63,7 +63,11 @@ def mac_str(b: bytes) -> str: def query_mac(usb_fd: int, reader: link.Reader, timeout: float = 2.0): """Ask the device for its Wi-Fi MAC; print any LOG lines seen meanwhile.""" - link.write_all(usb_fd, link.frame(link.MSG_CMD, b"mac")) + try: + link.write_all(usb_fd, link.frame(link.MSG_CMD, b"mac")) + except (OSError, TimeoutError) as e: + print(f"warning: could not query device MAC ({e})", file=sys.stderr) + return None for t, p in link.read_frames(usb_fd, reader, timeout): text = p.decode("utf-8", "replace").strip() if t == link.MSG_LOG: @@ -89,38 +93,42 @@ def main() -> int: file=sys.stderr) return 1 + from_tap = 0 # host -> device frames read from awdl0 + to_tap = 0 # device -> host frames written to awdl0 usb_fd = link.open_serial_raw(args.port) reader = link.Reader() - - # Learn the device MAC (and drain boot chatter) before bringing the link up. - mac = args.mac or query_mac(usb_fd, reader) - if mac and not MAC_RE.match(mac): - print(f"warning: ignoring malformed MAC {mac!r}", file=sys.stderr) - mac = None - - tap_fd = open_tap(args.dev) - os.set_blocking(tap_fd, False) - - if not args.no_config: - if mac: - try: - ip("link", "set", "dev", args.dev, "address", mac) - except subprocess.CalledProcessError as e: - print(f"warning: could not set MAC {mac}: {e}", file=sys.stderr) - ip("link", "set", "dev", args.dev, "mtu", str(args.mtu), "up") - - print(f"{args.dev} up (mtu {args.mtu}, mac {mac or 'kernel-assigned'}); " - f"bridging {args.port}") - print("commands: mac | stats | ping | testframe (Ctrl-C to quit)") - - stdin_fd = sys.stdin.fileno() - stdin_buf = bytearray() - stdin_open = True - to_tap = 0 # device -> host frames written to awdl0 - from_tap = 0 # host -> device frames read from awdl0 - read_cap = args.mtu + 64 # one Ethernet frame plus slack + tap_fd = None try: + # Learn the device MAC (and drain boot chatter) before bringing the link up. + mac = args.mac or query_mac(usb_fd, reader) + if mac and not MAC_RE.match(mac): + print(f"warning: ignoring malformed MAC {mac!r}", file=sys.stderr) + mac = None + + tap_fd = open_tap(args.dev) + os.set_blocking(tap_fd, False) + + if not args.no_config: + if mac: + try: + ip("link", "set", "dev", args.dev, "address", mac) + except subprocess.CalledProcessError as e: + print(f"warning: could not set MAC {mac}: {e}", file=sys.stderr) + ip("link", "set", "dev", args.dev, "mtu", str(args.mtu), "up") + + print(f"{args.dev} up (mtu {args.mtu}, mac {mac or 'kernel-assigned'}); " + f"bridging {args.port}") + print("commands: mac | stats | ping | testframe (Ctrl-C to quit)") + + stdin_fd = sys.stdin.fileno() + stdin_buf = bytearray() + stdin_open = True + read_cap = args.mtu + 64 # one Ethernet frame plus slack + + # Single-threaded select pump. Writes use a bounded 1s timeout so one + # direction stalling on USB backpressure cannot block the other for long + # (M2.1 traffic is light; a queued non-blocking design is for M2.3+). while True: watch = [usb_fd, tap_fd] if stdin_open: @@ -131,31 +139,38 @@ def main() -> int: try: data = os.read(usb_fd, 65536) except BlockingIOError: - data = b"" - for t, p in reader.feed(data): - if t == link.MSG_FRAME: - try: - link.write_all(tap_fd, p) - to_tap += 1 - except (OSError, TimeoutError) as e: - print(f"tap write failed: {e}", file=sys.stderr) - elif t == link.MSG_LOG: - print(f"[device] {p.decode('utf-8', 'replace')}") - elif t == link.MSG_CMD_REPLY: - print(f"[reply] {p.decode('utf-8', 'replace')}") - elif t == link.MSG_PEER_ADD and len(p) == 6: - print(f"[peer+] {mac_str(p)}") - elif t == link.MSG_PEER_REMOVE and len(p) == 6: - print(f"[peer-] {mac_str(p)}") + data = None # spurious readiness; try again next iteration + except OSError as e: + print(f"device disconnected (usb read: {e})", file=sys.stderr) + break + if data == b"": + print("device disconnected (EOF on usb)", file=sys.stderr) + break + if data: + for t, p in reader.feed(data): + if t == link.MSG_FRAME: + try: + link.write_all(tap_fd, p, timeout=1.0) + to_tap += 1 + except (OSError, TimeoutError) as e: + print(f"tap write failed: {e}", file=sys.stderr) + elif t == link.MSG_LOG: + print(f"[device] {p.decode('utf-8', 'replace')}") + elif t == link.MSG_CMD_REPLY: + print(f"[reply] {p.decode('utf-8', 'replace')}") + elif t == link.MSG_PEER_ADD and len(p) == 6: + print(f"[peer+] {mac_str(p)}") + elif t == link.MSG_PEER_REMOVE and len(p) == 6: + print(f"[peer-] {mac_str(p)}") if tap_fd in r: try: pkt = os.read(tap_fd, read_cap) - except BlockingIOError: + except (BlockingIOError, OSError): pkt = b"" if pkt: try: - link.write_all(usb_fd, link.frame(link.MSG_FRAME, pkt)) + link.write_all(usb_fd, link.frame(link.MSG_FRAME, pkt), timeout=1.0) from_tap += 1 except (OSError, TimeoutError) as e: print(f"usb write failed: {e}", file=sys.stderr) @@ -164,21 +179,25 @@ def main() -> int: try: chunk = os.read(stdin_fd, 4096) except BlockingIOError: - chunk = b"" - if not chunk: + chunk = None + if chunk == b"": stdin_open = False # EOF (Ctrl-D or piped input exhausted) - else: + elif chunk: stdin_buf += chunk while b"\n" in stdin_buf: line, _, rest = stdin_buf.partition(b"\n") stdin_buf = bytearray(rest) cmd = line.strip() if cmd: - link.write_all(usb_fd, link.frame(link.MSG_CMD, bytes(cmd))) + try: + link.write_all(usb_fd, link.frame(link.MSG_CMD, bytes(cmd))) + except (OSError, TimeoutError) as e: + print(f"command send failed: {e}", file=sys.stderr) except KeyboardInterrupt: print() finally: - os.close(tap_fd) # removes the non-persistent interface + if tap_fd is not None: + os.close(tap_fd) # removes the non-persistent interface os.close(usb_fd) print(f"stopped. host->device frames: {from_tap}, device->host frames: {to_tap}") return 0 From 48c20502b8f3b9868446421b755717f0e0c2015e Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 16 Oct 2026 13:29:37 +0000 Subject: [PATCH 26/99] feat(m2.2): forward received AWDL data frames to the host (awdl0 RX path) Bridge mode now brings up the radio and the AWDL node (as M1 validated: sync, election, peers, slot-gated PSF/MIF) and forwards received AWDL data frames to the host as Ethernet, so iPhone traffic on channel 6 shows up on awdl0 in Wireshark/tcpdump. - radio.c/.h: add a data-frame handler (mirror of the action handler). The promiscuous RX callback reconstructs Ethernet from a parsed data frame (dst=addr1, src=addr2, carried ethertype + L3 payload) and hands it to the consumer from Wi-Fi task context. - bridge.c/.h: a drop-on-overflow RX-forward queue + dedicated task. The Wi-Fi callback (bridge_rx_frame) builds the Ethernet frame into a serialized scratch slot and enqueues; the task drains it with bridge_send(FRAME), so the bounded USB write never runs on the Wi-Fi or AWDL-timing tasks. `stats` now also reports frames_to_host and rx_drops. - app_main.c: bridge branch does radio_init + awdl_app_start + bridge_start + promiscuous RX, in that order so Wi-Fi/PHY boot logs precede the COBS stream. - awdl_frame.c: awdl_parse_data now accepts QoS-data frames (subtype 8, +2-byte QoS control) and ignores 802.11 flag bits, so real Apple data frames parse instead of being rejected on a stray flag. Non-QoS behaviour is unchanged. - test: add test_data_qos_and_flags (QoS offset, flag tolerance, subtype rejection). Full native host suite passes under the existing build. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01XzgaN4Bv1m8Ka9iqo7QVUN --- firmware/spikes/components/awdl/awdl_frame.c | 24 ++++-- .../components/awdl/include/awdl_frame.h | 5 +- .../awdl/test/host/test_awdl_frame.c | 46 +++++++++++ firmware/spikes/main/app_main.c | 35 ++++++++- firmware/spikes/main/bridge.c | 76 +++++++++++++++++-- firmware/spikes/main/bridge.h | 17 ++++- firmware/spikes/main/radio.c | 16 +++- firmware/spikes/main/radio.h | 14 ++++ 8 files changed, 212 insertions(+), 21 deletions(-) diff --git a/firmware/spikes/components/awdl/awdl_frame.c b/firmware/spikes/components/awdl/awdl_frame.c index 4b0dacd..e2c4b24 100644 --- a/firmware/spikes/components/awdl/awdl_frame.c +++ b/firmware/spikes/components/awdl/awdl_frame.c @@ -504,13 +504,27 @@ int awdl_parse_action(const uint8_t *buf, size_t len, awdl_peer_view_t *out) { bool awdl_parse_data(const uint8_t *buf, size_t len, size_t *payload_off, uint16_t *ethertype) { - if (!buf || len < IEEE80211_HDR_LEN + 8 + 8) return false; - + if (!buf || len < IEEE80211_HDR_LEN) return false; + + /* Match on frame type/subtype only; ignore the flag bits (retry, power-mgmt, + * protected, order, ...) and version, so real Apple frames are not rejected + * on a stray flag. Accept both plain data (subtype 0) and QoS data (subtype + * 8, which inserts a 2-byte QoS control field after the base header). AWDL is + * ad-hoc (ToDS=FromDS=0), so addr1/2/3 stay at offsets 4/10/16 either way and + * the radio layer's BSSID check remains valid. */ uint16_t fc; - wire_get_le16(buf, len, 0, &fc); - if (fc != FC_DATA) return false; + if (!wire_get_le16(buf, len, 0, &fc)) return false; + uint8_t ftype = (uint8_t)((fc >> 2) & 0x3); /* 2 = data */ + uint8_t fsub = (uint8_t)((fc >> 4) & 0xf); /* 0 = data, 8 = QoS data */ + if (ftype != 2) return false; + size_t hdr; + if (fsub == 0) hdr = IEEE80211_HDR_LEN; /* 24 */ + else if (fsub == 8) hdr = IEEE80211_HDR_LEN + 2; /* + QoS control */ + else return false; + + if (len < hdr + 8 + 8) return false; /* LLC/SNAP (8) + AWDL data header (8) */ - size_t llc = IEEE80211_HDR_LEN; + size_t llc = hdr; uint8_t dsap = 0, ssap = 0, ctrl = 0; wire_get_u8(buf, len, llc + 0, &dsap); wire_get_u8(buf, len, llc + 1, &ssap); diff --git a/firmware/spikes/components/awdl/include/awdl_frame.h b/firmware/spikes/components/awdl/include/awdl_frame.h index b896e36..c0bac90 100644 --- a/firmware/spikes/components/awdl/include/awdl_frame.h +++ b/firmware/spikes/components/awdl/include/awdl_frame.h @@ -235,8 +235,9 @@ int awdl_parse_action(const uint8_t *buf, size_t len, awdl_peer_view_t *out); /* * True if the frame looks like an AWDL data frame (802.11 data, AWDL LLC/SNAP). - * On success, *payload_off is the offset of the IPv6 payload and *ethertype the - * carried ethertype (expected ETHERTYPE_IPV6). + * Accepts both plain data and QoS-data subtypes and ignores the 802.11 flag + * bits, so real Apple frames parse. On success, *payload_off is the offset of + * the L3 payload and *ethertype the carried ethertype (usually ETHERTYPE_IPV6). */ bool awdl_parse_data(const uint8_t *buf, size_t len, size_t *payload_off, uint16_t *ethertype); diff --git a/firmware/spikes/components/awdl/test/host/test_awdl_frame.c b/firmware/spikes/components/awdl/test/host/test_awdl_frame.c index 0d472cb..7011abb 100644 --- a/firmware/spikes/components/awdl/test/host/test_awdl_frame.c +++ b/firmware/spikes/components/awdl/test/host/test_awdl_frame.c @@ -121,6 +121,51 @@ static void test_data_roundtrip(void) { CHECK(memcmp(buf + poff, payload, sizeof(payload)) == 0, "payload mismatch"); } +/* M2.2: the RX parser must accept real Apple data frames, which may be QoS data + * (subtype 8, +2-byte QoS control) and may carry 802.11 flag bits. */ +static void test_data_qos_and_flags(void) { + uint8_t self_mac[6] = {0x02, 0, 0, 0, 0, 0x01}; + uint8_t dst_mac[6] = {0x02, 0, 0, 0, 0, 0x02}; + awdl_self_t self; + awdl_self_init(&self, self_mac, "x", 6); + const uint8_t payload[] = {0x60, 0, 0, 0, 0, 8, 58, 255, 1, 2, 3, 4}; + uint8_t buf[1500]; + int n = awdl_build_data(buf, sizeof(buf), &self, dst_mac, payload, sizeof(payload)); + CHECK(n > 0, "build data failed: %d", n); + + size_t poff0; uint16_t et; + CHECK(awdl_parse_data(buf, (size_t)n, &poff0, &et), "baseline parse failed"); + + /* Flag bits set (retry + power-mgmt) must not cause rejection. */ + uint8_t f[1500]; + memcpy(f, buf, (size_t)n); + f[1] = 0x18; + size_t poff; + CHECK(awdl_parse_data(f, (size_t)n, &poff, &et) && poff == poff0 && et == ETHERTYPE_IPV6, + "flagged frame rejected"); + + /* QoS data: subtype 8, 2-byte QoS control inserted after the 24-byte header. + * Payload offset must shift by exactly 2, everything else unchanged. */ + uint8_t q[1500]; + memcpy(q, buf, IEEE80211_HDR_LEN); + q[0] = 0x88; /* data, QoS-data subtype (8) */ + q[IEEE80211_HDR_LEN] = 0x00; /* QoS control */ + q[IEEE80211_HDR_LEN + 1] = 0x00; + memcpy(q + IEEE80211_HDR_LEN + 2, buf + IEEE80211_HDR_LEN, + (size_t)n - IEEE80211_HDR_LEN); + int qn = n + 2; + CHECK(awdl_parse_data(q, (size_t)qn, &poff, &et), "QoS parse failed"); + CHECK(poff == poff0 + 2, "QoS poff %zu != %zu+2", poff, poff0); + CHECK(et == ETHERTYPE_IPV6, "QoS ethertype %04x", et); + CHECK(memcmp(q + poff, payload, sizeof(payload)) == 0, "QoS payload mismatch"); + + /* A non-data subtype (4 = Null/other) must be rejected. */ + uint8_t bad[1500]; + memcpy(bad, buf, (size_t)n); + bad[0] = 0x48; + CHECK(!awdl_parse_data(bad, (size_t)n, &poff, &et), "subtype 4 not rejected"); +} + static void test_reject_garbage(void) { uint8_t junk[64]; memset(junk, 0xA5, sizeof(junk)); @@ -456,6 +501,7 @@ int main(void) { test_mif_roundtrip(); test_psf_roundtrip(); test_data_roundtrip(); + test_data_qos_and_flags(); test_reject_garbage(); test_sync_election_parse(); test_sync_math(); diff --git a/firmware/spikes/main/app_main.c b/firmware/spikes/main/app_main.c index 31087b0..9b3d68a 100644 --- a/firmware/spikes/main/app_main.c +++ b/firmware/spikes/main/app_main.c @@ -6,8 +6,9 @@ * USB-serial console (tx/rx/sync/peers/...). Used for the M0/M1 workflow; * see console.c and docs/SPIKES.md. * - Bridge mode (CONFIG_ESPDROP_BRIDGE_MODE): starts the COBS-framed USB - * host-link bridge instead of the console, for the M2 data path. M2.0 is a - * transport echo test and does not bring up Wi-Fi yet. + * host-link bridge instead of the console, for the M2 data path. M2.0/M2.1 + * were transport-only; M2.2 brings up Wi-Fi + the AWDL node and forwards + * received AWDL data frames to the host as Ethernet (awdl0 RX path). * * SPDX-License-Identifier: GPL-3.0-or-later * Copyright (C) 2026 EspDrop contributors @@ -22,11 +23,37 @@ #if CONFIG_ESPDROP_BRIDGE_MODE #include "bridge.h" +#include "radio.h" +#include "awdl_app.h" void app_main(void) { - /* M2.0: transport only. Wi-Fi / AWDL data wiring comes in later M2 stages. */ - if (bridge_start() != 0) + /* Bring up the radio and AWDL node FIRST: their one-time init logs (incl. + * non-maskable PHY/ROM prints) go out at boot, before the host opens the + * port. bridge_start() then silences ESP_LOG and takes over the USB endpoint, + * so steady-state traffic is clean COBS. */ + int err = radio_init(DEFAULT_CHANNEL); + if (err != ESP_OK) { + ESP_LOGE("espdrop", "radio_init failed: 0x%x", err); + return; + } + + uint8_t mac[6]; + radio_get_self_mac(mac); + if (awdl_app_start(mac, "espdrop-c3", DEFAULT_CHANNEL) != 0) { + ESP_LOGE("espdrop", "awdl_app_start failed"); + return; + } + + if (bridge_start() != 0) { ESP_LOGE("espdrop", "bridge_start failed"); + return; + } + + /* Forward received AWDL data frames to the host as Ethernet, then start RX. */ + radio_set_data_handler(bridge_rx_frame, NULL); + if (radio_promisc(true) != ESP_OK) + bridge_log("warning: promiscuous RX did not enable"); + bridge_log("awdl rx up on ch %u (M2.2)", DEFAULT_CHANNEL); } #else /* console mode */ diff --git a/firmware/spikes/main/bridge.c b/firmware/spikes/main/bridge.c index 46e0c3f..94f3dff 100644 --- a/firmware/spikes/main/bridge.c +++ b/firmware/spikes/main/bridge.c @@ -15,6 +15,7 @@ #include "esp_mac.h" #include "freertos/FreeRTOS.h" #include "freertos/task.h" +#include "freertos/queue.h" #include "freertos/semphr.h" #include "awdl_cobs.h" @@ -24,12 +25,27 @@ #define USB_RX_BUF 1024 #define USB_TX_BUF 2048 +/* Device->host RX forwarding: one Ethernet frame per queue slot. The Wi-Fi task + * only builds+enqueues (never blocks on USB); a dedicated task drains and sends. */ +#define BRIDGE_RX_PAYLOAD_MAX 1500 /* L3 payload cap (awdl0 MTU) */ +#define BRIDGE_RX_Q_LEN 8 +typedef struct { + uint16_t len; + uint8_t eth[14 + BRIDGE_RX_PAYLOAD_MAX]; +} rx_frame_t; + static SemaphoreHandle_t s_tx_lock; +static QueueHandle_t s_rx_fwd_q; /* rx_frame_t: Wi-Fi task -> fwd task */ static bool s_started; /* Counters touched only from the single RX task (handle_msg), so no locking. */ static uint32_t s_frames_from_host; static uint32_t s_bytes_from_host; +/* Touched across tasks (written by Wi-Fi/fwd tasks, read by the stats command). + * 32-bit aligned loads/stores are atomic on the C3, and a stale stats read is + * harmless, so these are volatile rather than locked. */ +static volatile uint32_t s_frames_to_host; +static volatile uint32_t s_rx_drops; /* Emit one synthetic Ethernet frame to the host so the device->host->awdl0 path * can be exercised before any radio exists (M2.1). ff:ff:ff:ff:ff:ff <- our MAC, @@ -47,6 +63,38 @@ static void send_test_frame(void) { bridge_send(BRIDGE_MSG_FRAME, eth, sizeof(eth)); } +/* Wi-Fi task context: build the Ethernet frame into a scratch slot and enqueue + * it for the forwarding task. rx_cb is serialized (one Wi-Fi task), so a single + * static scratch buffer is safe and keeps this off the Wi-Fi task's stack. */ +void bridge_rx_frame(const uint8_t dst[6], const uint8_t src[6], uint16_t ethertype, + const uint8_t *payload, size_t plen, void *ctx) { + (void)ctx; + static rx_frame_t scratch; + if (!s_rx_fwd_q) return; + if (plen > BRIDGE_RX_PAYLOAD_MAX) { s_rx_drops++; return; } + memcpy(scratch.eth, dst, 6); + memcpy(scratch.eth + 6, src, 6); + scratch.eth[12] = (uint8_t)(ethertype >> 8); + scratch.eth[13] = (uint8_t)(ethertype & 0xff); + if (plen) memcpy(scratch.eth + 14, payload, plen); + scratch.len = (uint16_t)(14 + plen); + if (xQueueSend(s_rx_fwd_q, &scratch, 0) != pdTRUE) + s_rx_drops++; /* host not keeping up: drop rather than stall Wi-Fi */ +} + +/* Drain the RX-forward queue to the host. A dedicated task so the (bounded but + * blocking) USB write never runs on the Wi-Fi task or the AWDL timing task. */ +static void bridge_fwd_task(void *arg) { + (void)arg; + static rx_frame_t fr; + for (;;) { + if (xQueueReceive(s_rx_fwd_q, &fr, portMAX_DELAY) == pdTRUE) { + if (bridge_send(BRIDGE_MSG_FRAME, fr.eth, fr.len) == 0) + s_frames_to_host++; + } + } +} + /* Send a NUL-terminated string as a CMD_REPLY (length from strlen, so a * truncated snprintf can never be reported as longer than the buffer). */ static void cmd_reply(const char *s) { @@ -62,7 +110,7 @@ static void handle_cmd(const uint8_t *p, size_t n) { while (k && (cmd[k - 1] == '\n' || cmd[k - 1] == '\r' || cmd[k - 1] == ' ')) cmd[--k] = '\0'; - char out[96]; + char out[128]; if (strcmp(cmd, "ping") == 0) { cmd_reply("pong"); } else if (strcmp(cmd, "mac") == 0) { @@ -72,8 +120,10 @@ static void handle_cmd(const uint8_t *p, size_t n) { mac[0], mac[1], mac[2], mac[3], mac[4], mac[5]); cmd_reply(out); } else if (strcmp(cmd, "stats") == 0) { - snprintf(out, sizeof(out), "frames_from_host=%u bytes_from_host=%u", - (unsigned)s_frames_from_host, (unsigned)s_bytes_from_host); + snprintf(out, sizeof(out), + "frames_from_host=%u bytes_from_host=%u frames_to_host=%u rx_drops=%u", + (unsigned)s_frames_from_host, (unsigned)s_bytes_from_host, + (unsigned)s_frames_to_host, (unsigned)s_rx_drops); cmd_reply(out); } else if (strcmp(cmd, "testframe") == 0) { send_test_frame(); @@ -96,8 +146,9 @@ static void handle_msg(const uint8_t *msg, size_t len) { bridge_send(BRIDGE_MSG_ECHO_REPLY, payload, plen); break; case BRIDGE_MSG_FRAME: - /* Host -> device Ethernet frame for awdl0. M2.1 has no radio, so account - * and drop; M2.2+ translates this to an AWDL data frame and transmits. */ + /* Host -> device Ethernet frame for awdl0. M2.2 is RX-only (device->host), + * so account and drop; M2.3 translates this to an AWDL data frame and + * transmits it over the radio. */ s_frames_from_host++; s_bytes_from_host += (uint32_t)plen; break; @@ -146,6 +197,13 @@ int bridge_start(void) { return e; } + s_rx_fwd_q = xQueueCreate(BRIDGE_RX_Q_LEN, sizeof(rx_frame_t)); + if (!s_rx_fwd_q) { + vSemaphoreDelete(s_tx_lock); + s_tx_lock = NULL; + return -1; + } + /* Reduce corruption of the binary frame stream by silencing ESP_LOG. This is * not absolute: ROM/early-boot output (before this runs) and the panic * handler (esp_rom_printf, crash only) still reach the same peripheral. Those @@ -159,12 +217,18 @@ int bridge_start(void) { s_started = true; if (xTaskCreate(bridge_rx_task, "bridge_rx", 4096, NULL, 6, NULL) != pdPASS) { s_started = false; + vQueueDelete(s_rx_fwd_q); + s_rx_fwd_q = NULL; vSemaphoreDelete(s_tx_lock); s_tx_lock = NULL; return -1; } + /* The forwarding task is best-effort: if it fails to start, RX frames are + * dropped (queue fills) but the control link stays up, so do not fail. */ + if (xTaskCreate(bridge_fwd_task, "bridge_fwd", 3072, NULL, 5, NULL) != pdPASS) + bridge_log("warning: rx-forward task did not start"); - bridge_log("bridge up (M2.0 echo)"); + bridge_log("bridge up"); return 0; } diff --git a/firmware/spikes/main/bridge.h b/firmware/spikes/main/bridge.h index 2923ab1..cde9386 100644 --- a/firmware/spikes/main/bridge.h +++ b/firmware/spikes/main/bridge.h @@ -6,9 +6,10 @@ * (CONFIG_ESPDROP_BRIDGE_MODE). The platform-independent framing lives in * awdl_cobs.*; this module owns the USB driver, the RX task and the dispatch. * - * M2.0 added ECHO (host-side transport test) and LOG. M2.1 adds CMD/CMD_REPLY - * (mac, stats, ping, testframe) and accounts incoming FRAME messages; the radio - * that gives FRAME and PEER_* real meaning is wired in later M2 stages. + * M2.0 added ECHO (host-side transport test) and LOG. M2.1 added CMD/CMD_REPLY + * (mac, stats, ping, testframe) and FRAME accounting. M2.2 forwards received + * AWDL data frames to the host as Ethernet (device->host FRAME); host->device + * FRAME transmit over the radio and PEER_* come in later M2 stages. * * Wire format per message: COBS(type_byte || payload) followed by a 0x00 * delimiter. COBS guarantees the body carries no 0x00, so the delimiter is @@ -46,6 +47,16 @@ int bridge_start(void); /* Send one framed message. Thread-safe. Returns 0 on success. */ int bridge_send(uint8_t type, const void *payload, size_t len); +/* + * Forward one received AWDL data frame to the host as an Ethernet frame + * (dst || src || ethertype || payload). Matches radio_data_handler_t so it can + * be registered directly with radio_set_data_handler(). Called from Wi-Fi task + * context: it only builds the frame and enqueues it (drop on overflow), so the + * actual USB write happens on the bridge's own forwarding task. + */ +void bridge_rx_frame(const uint8_t dst[6], const uint8_t src[6], uint16_t ethertype, + const uint8_t *payload, size_t plen, void *ctx); + /* printf-style LOG message to the host. */ void bridge_log(const char *fmt, ...) __attribute__((format(printf, 1, 2))); diff --git a/firmware/spikes/main/radio.c b/firmware/spikes/main/radio.c index cbd84fa..6e5fe10 100644 --- a/firmware/spikes/main/radio.c +++ b/firmware/spikes/main/radio.c @@ -27,6 +27,8 @@ static volatile bool s_promisc; static uint8_t s_channel = 6; static radio_action_handler_t s_action_handler; static void *s_action_ctx; +static radio_data_handler_t s_data_handler; +static void *s_data_ctx; static inline void stat_inc(uint64_t *field) { portENTER_CRITICAL(&s_stats_mux); @@ -79,8 +81,15 @@ static void rx_cb(void *buf, wifi_promiscuous_pkt_type_t type) { esp_timer_get_time(), s_action_ctx); } else { /* WIFI_PKT_DATA */ size_t poff; uint16_t et; - if (awdl_parse_data(frame, (size_t)len, &poff, &et)) + if (awdl_parse_data(frame, (size_t)len, &poff, &et)) { stat_inc(&s_stats.rx_data); + /* Reconstruct Ethernet (dst=addr1, src=addr2) and hand it to the + * consumer. addr1/addr2 sit at the fixed offsets 4/10, and the BSSID + * check above already guaranteed len >= IEEE80211_HDR_LEN + 4. */ + if (s_data_handler && poff <= (size_t)len) + s_data_handler(frame + 4, frame + 10, et, + frame + poff, (size_t)len - poff, s_data_ctx); + } } } @@ -191,3 +200,8 @@ void radio_set_action_handler(radio_action_handler_t h, void *ctx) { s_action_ctx = ctx; s_action_handler = h; } + +void radio_set_data_handler(radio_data_handler_t h, void *ctx) { + s_data_ctx = ctx; + s_data_handler = h; +} diff --git a/firmware/spikes/main/radio.h b/firmware/spikes/main/radio.h index 5d95a94..edb316d 100644 --- a/firmware/spikes/main/radio.h +++ b/firmware/spikes/main/radio.h @@ -40,6 +40,17 @@ typedef struct { typedef void (*radio_action_handler_t)(const awdl_peer_view_t *pv, int8_t rssi, uint8_t channel, uint64_t now_us, void *ctx); +/* + * Handler for a received, parsed AWDL *data* frame, reconstructed as Ethernet: + * dst = 802.11 addr1, src = addr2, plus the ethertype and L3 payload the AWDL + * data header carried. Invoked from Wi-Fi task context inside the promiscuous + * callback, so it MUST be quick and non-blocking (copy what it needs into a + * queue and return). All pointers are only valid for the duration of the call. + */ +typedef void (*radio_data_handler_t)(const uint8_t dst[6], const uint8_t src[6], + uint16_t ethertype, const uint8_t *payload, + size_t plen, void *ctx); + /* * Bring up Wi-Fi in station mode without connecting, on the given 2.4 GHz * channel. Safe to call once at startup. Returns an esp_err_t (0 == ESP_OK). @@ -74,6 +85,9 @@ void radio_reset_stats(void); /* Register the consumer for received AWDL action frames (NULL to clear). */ void radio_set_action_handler(radio_action_handler_t h, void *ctx); +/* Register the consumer for received AWDL data frames (NULL to clear). */ +void radio_set_data_handler(radio_data_handler_t h, void *ctx); + #ifdef __cplusplus } #endif From 94235e46c2e8a7c1533f11e01cb40dd9b0111f8c Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 16 Oct 2026 14:01:14 +0000 Subject: [PATCH 27/99] fix(m2.2): include esp_err.h in bridge build; keep bridge_start failure visible Review follow-up: - app_main.c referenced ESP_OK in the CONFIG_ESPDROP_BRIDGE_MODE branch (radio_init / radio_promisc checks) but only esp_log.h was in scope, which does not pull in esp_err.h on IDF 5.x. The console branch compiled only because esp_console.h includes esp_err.h; the bridge build - M2.2's target - would fail with "'ESP_OK' undeclared". Add an explicit esp_err.h include. - bridge_start: the bridge_rx_task-creation failure path returns after ESP_LOG was silenced, which suppressed app_main's "bridge_start failed" error. Restore the log level on that path so the failure is diagnosable (nothing has been transmitted yet, so no COBS stream to corrupt). Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01XzgaN4Bv1m8Ka9iqo7QVUN --- firmware/spikes/main/app_main.c | 1 + firmware/spikes/main/bridge.c | 3 +++ 2 files changed, 4 insertions(+) diff --git a/firmware/spikes/main/app_main.c b/firmware/spikes/main/app_main.c index 9b3d68a..a8614a1 100644 --- a/firmware/spikes/main/app_main.c +++ b/firmware/spikes/main/app_main.c @@ -15,6 +15,7 @@ */ #include +#include "esp_err.h" /* ESP_OK, used by both the bridge and console branches */ #include "esp_log.h" #include "sdkconfig.h" diff --git a/firmware/spikes/main/bridge.c b/firmware/spikes/main/bridge.c index 94f3dff..5a64225 100644 --- a/firmware/spikes/main/bridge.c +++ b/firmware/spikes/main/bridge.c @@ -221,6 +221,9 @@ int bridge_start(void) { s_rx_fwd_q = NULL; vSemaphoreDelete(s_tx_lock); s_tx_lock = NULL; + /* Logging was just silenced; restore it so the caller's error is visible + * (nothing was transmitted yet, so no COBS stream to corrupt). */ + esp_log_level_set("*", ESP_LOG_ERROR); return -1; } /* The forwarding task is best-effort: if it fails to start, RX frames are From 2cd6a05a380ca5ab869ed1a986c1dcfdba82e890 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 17 Oct 2026 14:52:54 +0000 Subject: [PATCH 28/99] feat(m2.3a): host->radio AWDL data TX path (slot-gated) + radio stats cmd First M2.3 increment: frames the host sends on awdl0 are now transmitted over the radio as AWDL data frames, so the device path is bidirectional. - bridge.c/.h: BRIDGE_MSG_FRAME now hands the Ethernet frame to a registered sink (bridge_set_frame_sink) in addition to accounting it. - awdl_app.c/.h: awdl_app_tx_ethernet() queues one host IPv6 frame (ethertype 0x86dd only; awdl0 is IPv6-only). The AWDL task flushes the queue with awdl_build_data()+radio_tx() only during slots we advertise as present (the master's mirrored ch6 slots), so data TX lands when the peer is on ch6 - the same slot-gating used for PSF/MIF. Build+send happen under the node lock that guards self state (seq numbers). - app_main.c: register awdl_app_tx_ethernet as the bridge frame sink. - bridge.c: add a `radio` command reporting the Wi-Fi counters (rx_action, rx_data, tx_ok/err, tx_ack/noack). tx_ack (hardware ACK on a unicast frame) tells us whether our TX actually reached the iPhone - the key M2.3 signal. - tools/awdl_bridge.py: list `radio` in the command hint (no code change). M2.3b will auto-populate the host neighbour table (PEER_ADD/REMOVE) and tune TX reliability; for now a static `ip -6 neigh` entry makes ping6 testable. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01XzgaN4Bv1m8Ka9iqo7QVUN --- firmware/spikes/main/app_main.c | 6 ++-- firmware/spikes/main/awdl_app.c | 51 ++++++++++++++++++++++++++++++++- firmware/spikes/main/awdl_app.h | 10 +++++++ firmware/spikes/main/bridge.c | 31 ++++++++++++++++++-- firmware/spikes/main/bridge.h | 8 ++++++ tools/awdl_bridge.py | 4 +-- 6 files changed, 102 insertions(+), 8 deletions(-) diff --git a/firmware/spikes/main/app_main.c b/firmware/spikes/main/app_main.c index a8614a1..dcdd4ba 100644 --- a/firmware/spikes/main/app_main.c +++ b/firmware/spikes/main/app_main.c @@ -50,11 +50,13 @@ void app_main(void) { return; } - /* Forward received AWDL data frames to the host as Ethernet, then start RX. */ + /* RX: forward received AWDL data frames to the host as Ethernet. + * TX: hand host Ethernet frames to the AWDL data-TX path. Then start RX. */ radio_set_data_handler(bridge_rx_frame, NULL); + bridge_set_frame_sink(awdl_app_tx_ethernet, NULL); if (radio_promisc(true) != ESP_OK) bridge_log("warning: promiscuous RX did not enable"); - bridge_log("awdl rx up on ch %u (M2.2)", DEFAULT_CHANNEL); + bridge_log("awdl rx/tx up on ch %u (M2.3)", DEFAULT_CHANNEL); } #else /* console mode */ diff --git a/firmware/spikes/main/awdl_app.c b/firmware/spikes/main/awdl_app.c index 4c58819..92817c3 100644 --- a/firmware/spikes/main/awdl_app.c +++ b/firmware/spikes/main/awdl_app.c @@ -28,6 +28,12 @@ static const char *TAG = "awdl_app"; #define TICK_INTERVAL_US AWDL_PEER_CLEAN_INTERVAL_US /* 1 s */ #define RX_Q_LEN 16 +/* Host->radio data TX: one IPv6 packet (awdl0 MTU) per slot, queued from the + * bridge RX task and flushed by the AWDL task during advertised-present slots. */ +#define TX_Q_LEN 8 +#define TX_PAYLOAD_MAX 1484u /* awdl0 MTU */ +#define TX_FLUSH_BUDGET 8 /* data frames sent per ready slot visit */ + typedef struct { awdl_peer_view_t pv; int8_t rssi; @@ -35,9 +41,16 @@ typedef struct { uint64_t now_us; } rx_event_t; +typedef struct { + uint8_t dst[6]; + uint16_t len; + uint8_t ip[TX_PAYLOAD_MAX]; +} tx_data_t; + static awdl_node_t s_node; static SemaphoreHandle_t s_lock; /* guards s_node */ static QueueHandle_t s_rx_q; +static QueueHandle_t s_tx_q; /* tx_data_t: bridge RX task -> awdl task */ static TaskHandle_t s_task; static bool s_started; @@ -109,15 +122,51 @@ static void awdl_task(void *arg) { xSemaphoreGive(s_lock); next_tick = now + TICK_INTERVAL_US; } + + /* Flush queued host data frames, but only during a slot we advertise as + * present (= the master's ch6 slots), so our data TX lands when the peer + * is on our channel. Build+send under the same lock that guards self. */ + if (s_tx_q && uxQueueMessagesWaiting(s_tx_q) > 0) { + static uint8_t txbuf[1600]; + tx_data_t td; + now = esp_timer_get_time(); + xSemaphoreTake(s_lock, portMAX_DELAY); + if (awdl_node_tx_slot_ready(&s_node, (uint64_t)now)) { + int budget = TX_FLUSH_BUDGET; + while (budget-- > 0 && xQueueReceive(s_tx_q, &td, 0) == pdTRUE) { + int n = awdl_build_data(txbuf, sizeof(txbuf), &s_node.self, + td.dst, td.ip, td.len); + if (n > 0) radio_tx(txbuf, n); + } + } + xSemaphoreGive(s_lock); + } } } +int awdl_app_tx_ethernet(const uint8_t *eth, size_t len, void *ctx) { + (void)ctx; + /* Called only from the single bridge RX task, so a static staging buffer is + * safe and keeps this large struct off that task's stack. */ + static tx_data_t td; + if (!s_started || !s_tx_q || len < 14) return -1; + uint16_t et = (uint16_t)(((uint16_t)eth[12] << 8) | eth[13]); + if (et != ETHERTYPE_IPV6) return 0; /* awdl0 is IPv6-only; ignore the rest */ + size_t plen = len - 14; + if (plen > TX_PAYLOAD_MAX) return -1; + memcpy(td.dst, eth, 6); + td.len = (uint16_t)plen; + if (plen) memcpy(td.ip, eth + 14, plen); + return (xQueueSend(s_tx_q, &td, 0) == pdTRUE) ? 0 : -1; /* drop on overflow */ +} + int awdl_app_start(const uint8_t self_mac[6], const char *name, uint8_t chan) { if (s_started) return 0; s_lock = xSemaphoreCreateMutex(); s_rx_q = xQueueCreate(RX_Q_LEN, sizeof(rx_event_t)); - if (!s_lock || !s_rx_q) return -1; + s_tx_q = xQueueCreate(TX_Q_LEN, sizeof(tx_data_t)); + if (!s_lock || !s_rx_q || !s_tx_q) return -1; awdl_node_init(&s_node, self_mac, name, chan, (uint64_t)esp_timer_get_time()); diff --git a/firmware/spikes/main/awdl_app.h b/firmware/spikes/main/awdl_app.h index d184db1..b3bbdef 100644 --- a/firmware/spikes/main/awdl_app.h +++ b/firmware/spikes/main/awdl_app.h @@ -42,6 +42,16 @@ int awdl_app_format_peers(char *buf, size_t cap); /* Build and transmit one of our own PSF/MIF frames immediately (console `tx`). */ void awdl_app_tx(uint8_t subtype); +/* + * Queue one host Ethernet frame (dst||src||ethertype||payload) for transmission + * as an AWDL data frame. Only IPv6 (ethertype 0x86dd) is forwarded; other types + * are ignored. The frame is sent from the AWDL task during a slot we advertise + * as present, so it lines up with when the peer is on our channel. Signature + * matches bridge_frame_sink_t so it can be registered with bridge_set_frame_sink. + * Returns 0 if queued (or intentionally ignored), -1 on overflow/bad input. + */ +int awdl_app_tx_ethernet(const uint8_t *eth, size_t len, void *ctx); + #ifdef __cplusplus } #endif diff --git a/firmware/spikes/main/bridge.c b/firmware/spikes/main/bridge.c index 5a64225..5e7a814 100644 --- a/firmware/spikes/main/bridge.c +++ b/firmware/spikes/main/bridge.c @@ -19,6 +19,7 @@ #include "freertos/semphr.h" #include "awdl_cobs.h" +#include "radio.h" /* radio_get_stats, for the `radio` diagnostic command */ #define BRIDGE_MAX_PAYLOAD 1600 #define BRIDGE_MAX_MSG (1 + BRIDGE_MAX_PAYLOAD) /* type + payload */ @@ -47,6 +48,15 @@ static uint32_t s_bytes_from_host; static volatile uint32_t s_frames_to_host; static volatile uint32_t s_rx_drops; +/* Sink for host->device Ethernet frames (set by app_main to the AWDL TX path). */ +static bridge_frame_sink_t s_frame_sink; +static void *s_frame_sink_ctx; + +void bridge_set_frame_sink(bridge_frame_sink_t fn, void *ctx) { + s_frame_sink_ctx = ctx; + s_frame_sink = fn; +} + /* Emit one synthetic Ethernet frame to the host so the device->host->awdl0 path * can be exercised before any radio exists (M2.1). ff:ff:ff:ff:ff:ff <- our MAC, * ethertype 0x88b5 (IEEE local experimental, inert) + a marker body. */ @@ -125,6 +135,20 @@ static void handle_cmd(const uint8_t *p, size_t n) { (unsigned)s_frames_from_host, (unsigned)s_bytes_from_host, (unsigned)s_frames_to_host, (unsigned)s_rx_drops); cmd_reply(out); + } else if (strcmp(cmd, "radio") == 0) { + radio_stats_t st; + radio_get_stats(&st); + char rbuf[200]; + /* tx_ack = unicast frames the peer hardware-ACKed (our TX reached it); + * for broadcast/multicast tx_ack is always success. */ + snprintf(rbuf, sizeof(rbuf), + "rx_action=%llu rx_data=%llu rx_other=%llu rx_perr=%llu " + "tx_ok=%llu tx_err=%llu tx_ack=%llu tx_noack=%llu", + (unsigned long long)st.rx_action, (unsigned long long)st.rx_data, + (unsigned long long)st.rx_other_bssid, (unsigned long long)st.rx_parse_err, + (unsigned long long)st.tx_ok, (unsigned long long)st.tx_err, + (unsigned long long)st.tx_done_ok, (unsigned long long)st.tx_done_fail); + cmd_reply(rbuf); } else if (strcmp(cmd, "testframe") == 0) { send_test_frame(); cmd_reply("sent testframe"); @@ -146,11 +170,12 @@ static void handle_msg(const uint8_t *msg, size_t len) { bridge_send(BRIDGE_MSG_ECHO_REPLY, payload, plen); break; case BRIDGE_MSG_FRAME: - /* Host -> device Ethernet frame for awdl0. M2.2 is RX-only (device->host), - * so account and drop; M2.3 translates this to an AWDL data frame and - * transmits it over the radio. */ + /* Host -> device Ethernet frame for awdl0. Account it, then hand it to the + * TX sink (the AWDL data-TX path) if one is registered. */ s_frames_from_host++; s_bytes_from_host += (uint32_t)plen; + if (s_frame_sink) + s_frame_sink(payload, plen, s_frame_sink_ctx); break; case BRIDGE_MSG_CMD: handle_cmd(payload, plen); diff --git a/firmware/spikes/main/bridge.h b/firmware/spikes/main/bridge.h index cde9386..0272bda 100644 --- a/firmware/spikes/main/bridge.h +++ b/firmware/spikes/main/bridge.h @@ -57,6 +57,14 @@ int bridge_send(uint8_t type, const void *payload, size_t len); void bridge_rx_frame(const uint8_t dst[6], const uint8_t src[6], uint16_t ethertype, const uint8_t *payload, size_t plen, void *ctx); +/* + * Sink for host->device Ethernet frames (BRIDGE_MSG_FRAME): invoked from the + * bridge RX task with the raw Ethernet frame. Returns 0 on accept. Registered by + * app_main to the AWDL data-TX path; if none is set the frame is only accounted. + */ +typedef int (*bridge_frame_sink_t)(const uint8_t *eth, size_t len, void *ctx); +void bridge_set_frame_sink(bridge_frame_sink_t fn, void *ctx); + /* printf-style LOG message to the host. */ void bridge_log(const char *fmt, ...) __attribute__((format(printf, 1, 2))); diff --git a/tools/awdl_bridge.py b/tools/awdl_bridge.py index 992c721..7be2faf 100644 --- a/tools/awdl_bridge.py +++ b/tools/awdl_bridge.py @@ -17,7 +17,7 @@ # ip -d link show awdl0 # sudo tcpdump -i awdl0 -e # then type `testframe` at the bridge prompt # -# Interactive stdin commands (forwarded to the device): mac | stats | ping | testframe +# Interactive stdin commands (forwarded to the device): mac | stats | radio | ping | testframe # # Needs root: creating a TAP and running `ip link` both require CAP_NET_ADMIN. # The interface is not persistent - it disappears when this process exits. @@ -119,7 +119,7 @@ def main() -> int: print(f"{args.dev} up (mtu {args.mtu}, mac {mac or 'kernel-assigned'}); " f"bridging {args.port}") - print("commands: mac | stats | ping | testframe (Ctrl-C to quit)") + print("commands: mac | stats | radio | ping | testframe (Ctrl-C to quit)") stdin_fd = sys.stdin.fileno() stdin_buf = bytearray() From eb5045652ee849e223e1efeed1bbb05605072aec Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 17 Oct 2026 15:20:01 +0000 Subject: [PATCH 29/99] fix(m2.3a): cap TX payload at 1460 so built frames fit the 1500B radio limit Review follow-up (verified finding): - awdl_build_data adds 40 bytes of framing (24 B 802.11 + 8 B LLC/SNAP + 8 B AWDL data header) on top of the IPv6 payload. esp_wifi_80211_tx rejects frames > 1500 bytes, so a full-MTU 1484-byte payload built a 1524-byte frame that the driver dropped (counted only as tx_err). Harmless for ping, but it would silently drop every near-MTU segment of a bulk transfer (M2.4). Lower TX_PAYLOAD_MAX to 1460 (1500 - 40) and the host TAP MTU default to 1460 so the IP stack never generates packets the device cannot transmit. - radio command: print the counters with %u (32-bit casts) like `stats`, instead of %llu, avoiding any newlib-nano long-long printf doubt. Counts fit 32 bits for a test run. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01XzgaN4Bv1m8Ka9iqo7QVUN --- firmware/spikes/main/awdl_app.c | 6 +++++- firmware/spikes/main/bridge.c | 15 ++++++++------- tools/awdl_bridge.py | 4 +++- 3 files changed, 16 insertions(+), 9 deletions(-) diff --git a/firmware/spikes/main/awdl_app.c b/firmware/spikes/main/awdl_app.c index 92817c3..55bd4d5 100644 --- a/firmware/spikes/main/awdl_app.c +++ b/firmware/spikes/main/awdl_app.c @@ -31,7 +31,11 @@ static const char *TAG = "awdl_app"; /* Host->radio data TX: one IPv6 packet (awdl0 MTU) per slot, queued from the * bridge RX task and flushed by the AWDL task during advertised-present slots. */ #define TX_Q_LEN 8 -#define TX_PAYLOAD_MAX 1484u /* awdl0 MTU */ +/* Max IPv6 payload per data frame. esp_wifi_80211_tx caps the 802.11 frame at + * 1500 bytes; awdl_build_data adds 40 bytes of framing (24 B 802.11 + 8 B + * LLC/SNAP + 8 B AWDL data header), so the payload must stay <= 1500 - 40. This + * is the effective awdl0 MTU (the host TAP is configured to match). */ +#define TX_PAYLOAD_MAX 1460u #define TX_FLUSH_BUDGET 8 /* data frames sent per ready slot visit */ typedef struct { diff --git a/firmware/spikes/main/bridge.c b/firmware/spikes/main/bridge.c index 5e7a814..fd5327f 100644 --- a/firmware/spikes/main/bridge.c +++ b/firmware/spikes/main/bridge.c @@ -140,14 +140,15 @@ static void handle_cmd(const uint8_t *p, size_t n) { radio_get_stats(&st); char rbuf[200]; /* tx_ack = unicast frames the peer hardware-ACKed (our TX reached it); - * for broadcast/multicast tx_ack is always success. */ + * for broadcast/multicast tx_ack is always success. Counts are cast to + * 32-bit (ample for a test run) and printed with %u, matching `stats`. */ snprintf(rbuf, sizeof(rbuf), - "rx_action=%llu rx_data=%llu rx_other=%llu rx_perr=%llu " - "tx_ok=%llu tx_err=%llu tx_ack=%llu tx_noack=%llu", - (unsigned long long)st.rx_action, (unsigned long long)st.rx_data, - (unsigned long long)st.rx_other_bssid, (unsigned long long)st.rx_parse_err, - (unsigned long long)st.tx_ok, (unsigned long long)st.tx_err, - (unsigned long long)st.tx_done_ok, (unsigned long long)st.tx_done_fail); + "rx_action=%u rx_data=%u rx_other=%u rx_perr=%u " + "tx_ok=%u tx_err=%u tx_ack=%u tx_noack=%u", + (unsigned)st.rx_action, (unsigned)st.rx_data, + (unsigned)st.rx_other_bssid, (unsigned)st.rx_parse_err, + (unsigned)st.tx_ok, (unsigned)st.tx_err, + (unsigned)st.tx_done_ok, (unsigned)st.tx_done_fail); cmd_reply(rbuf); } else if (strcmp(cmd, "testframe") == 0) { send_test_frame(); diff --git a/tools/awdl_bridge.py b/tools/awdl_bridge.py index 7be2faf..4c638ac 100644 --- a/tools/awdl_bridge.py +++ b/tools/awdl_bridge.py @@ -81,7 +81,9 @@ def main() -> int: ap = argparse.ArgumentParser(description="EspDrop M2.1 TAP bridge for awdl0") ap.add_argument("port", help="device serial port, e.g. /dev/ttyACM0") ap.add_argument("--dev", default="awdl0", help="TAP interface name (default awdl0)") - ap.add_argument("--mtu", type=int, default=1484, help="interface MTU (default 1484)") + ap.add_argument("--mtu", type=int, default=1460, + help="interface MTU (default 1460 = 1500 - 40 B AWDL framing, " + "the most the device can transmit in one data frame)") ap.add_argument("--mac", default=None, help="interface MAC (default: query the C3)") ap.add_argument("--no-config", action="store_true", From 8839fd790e0b5a17e9fa450e6b9e8107e1989ad4 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 17 Oct 2026 15:59:31 +0000 Subject: [PATCH 30/99] feat(m2.3b): auto host neighbour table from AWDL peer events So OpenDrop (M2.4) and ping6 work without manually running `ip -6 neigh`: - awdl_app.c/.h: the AWDL task diffs its peer table each 1 Hz tick against a shadow snapshot and fires a registered callback (awdl_app_set_peer_cb) with added/removed peer MACs, outside the node lock. Registering re-syncs so a late-registered sink still learns existing peers. - app_main.c: map peer events to BRIDGE_MSG_PEER_ADD / PEER_REMOVE (6-byte MAC). - tools/awdl_bridge.py: on PEER_ADD, derive the peer's fe80:: link-local by modified EUI-64 and `ip -6 neigh replace lladdr dev awdl0 nud permanent`; on PEER_REMOVE, delete it. Permanent entries avoid NDP over the lossy link. Suppressed with --no-config. EUI-64 verified against a real device (a2:77:35:18:28:f3 -> fe80::a077:35ff:fe18:28f3). Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01XzgaN4Bv1m8Ka9iqo7QVUN --- firmware/spikes/main/app_main.c | 8 +++++++ firmware/spikes/main/awdl_app.c | 42 +++++++++++++++++++++++++++++++++ firmware/spikes/main/awdl_app.h | 9 +++++++ tools/awdl_bridge.py | 29 ++++++++++++++++++++++- 4 files changed, 87 insertions(+), 1 deletion(-) diff --git a/firmware/spikes/main/app_main.c b/firmware/spikes/main/app_main.c index dcdd4ba..f0e4f3d 100644 --- a/firmware/spikes/main/app_main.c +++ b/firmware/spikes/main/app_main.c @@ -27,6 +27,13 @@ #include "radio.h" #include "awdl_app.h" +/* AWDL peer appeared/left -> tell the host so it can manage the awdl0 neighbour + * table (runs on the AWDL task; bridge_send is thread-safe). */ +static void on_peer_evt(bool added, const uint8_t mac[6], void *ctx) { + (void)ctx; + bridge_send(added ? BRIDGE_MSG_PEER_ADD : BRIDGE_MSG_PEER_REMOVE, mac, 6); +} + void app_main(void) { /* Bring up the radio and AWDL node FIRST: their one-time init logs (incl. * non-maskable PHY/ROM prints) go out at boot, before the host opens the @@ -54,6 +61,7 @@ void app_main(void) { * TX: hand host Ethernet frames to the AWDL data-TX path. Then start RX. */ radio_set_data_handler(bridge_rx_frame, NULL); bridge_set_frame_sink(awdl_app_tx_ethernet, NULL); + awdl_app_set_peer_cb(on_peer_evt, NULL); /* host builds the neighbour table */ if (radio_promisc(true) != ESP_OK) bridge_log("warning: promiscuous RX did not enable"); bridge_log("awdl rx/tx up on ch %u (M2.3)", DEFAULT_CHANNEL); diff --git a/firmware/spikes/main/awdl_app.c b/firmware/spikes/main/awdl_app.c index 55bd4d5..c742689 100644 --- a/firmware/spikes/main/awdl_app.c +++ b/firmware/spikes/main/awdl_app.c @@ -58,6 +58,13 @@ static QueueHandle_t s_tx_q; /* tx_data_t: bridge RX task -> awdl task * static TaskHandle_t s_task; static bool s_started; +/* Peer-change notification. Shadow set is touched only by the AWDL task. */ +static awdl_peer_cb_t s_peer_cb; +static void *s_peer_cb_ctx; +static volatile bool s_peer_resync; /* set by set_peer_cb; re-emit all peers */ +static uint8_t s_peer_shadow[AWDL_MAX_PEERS][6]; +static int s_peer_nshadow; + /* radio handler: Wi-Fi task context, must be quick. Copy into the queue. */ static void on_action(const awdl_peer_view_t *pv, int8_t rssi, uint8_t channel, uint64_t now_us, void *ctx) { @@ -97,6 +104,29 @@ static bool send_frame(uint8_t subtype, bool force) { return sent; } +/* Diff the current peer set against the previous snapshot and fire s_peer_cb for + * each addition/removal. Runs on the AWDL task, outside s_lock (the cb may block + * briefly on a USB write). n is small (<= AWDL_MAX_PEERS), so O(n^2) is fine. */ +static void emit_peer_changes(const uint8_t cur[][6], int ncur) { + if (s_peer_resync) { s_peer_nshadow = 0; s_peer_resync = false; } + if (s_peer_cb) { + for (int i = 0; i < s_peer_nshadow; i++) { + bool found = false; + for (int j = 0; j < ncur; j++) + if (memcmp(s_peer_shadow[i], cur[j], 6) == 0) { found = true; break; } + if (!found) s_peer_cb(false, s_peer_shadow[i], s_peer_cb_ctx); + } + for (int j = 0; j < ncur; j++) { + bool found = false; + for (int i = 0; i < s_peer_nshadow; i++) + if (memcmp(s_peer_shadow[i], cur[j], 6) == 0) { found = true; break; } + if (!found) s_peer_cb(true, cur[j], s_peer_cb_ctx); + } + } + for (int j = 0; j < ncur; j++) memcpy(s_peer_shadow[j], cur[j], 6); + s_peer_nshadow = ncur; +} + static void awdl_task(void *arg) { (void)arg; int64_t now = esp_timer_get_time(); @@ -121,9 +151,15 @@ static void awdl_task(void *arg) { if (now >= next_mif && send_frame(AWDL_SUBTYPE_MIF, false)) next_mif = now + MIF_INTERVAL_US; if (now >= next_tick) { + uint8_t cur[AWDL_MAX_PEERS][6]; + int ncur = 0; xSemaphoreTake(s_lock, portMAX_DELAY); awdl_node_tick(&s_node, (uint64_t)now); + for (int i = 0; i < AWDL_MAX_PEERS; i++) + if (s_node.peers.peers[i].used) + memcpy(cur[ncur++], s_node.peers.peers[i].addr, 6); xSemaphoreGive(s_lock); + emit_peer_changes(cur, ncur); /* notify host of peer add/remove */ next_tick = now + TICK_INTERVAL_US; } @@ -164,6 +200,12 @@ int awdl_app_tx_ethernet(const uint8_t *eth, size_t len, void *ctx) { return (xQueueSend(s_tx_q, &td, 0) == pdTRUE) ? 0 : -1; /* drop on overflow */ } +void awdl_app_set_peer_cb(awdl_peer_cb_t cb, void *ctx) { + s_peer_cb_ctx = ctx; + s_peer_cb = cb; + s_peer_resync = true; /* re-emit all current peers on the next tick */ +} + int awdl_app_start(const uint8_t self_mac[6], const char *name, uint8_t chan) { if (s_started) return 0; diff --git a/firmware/spikes/main/awdl_app.h b/firmware/spikes/main/awdl_app.h index b3bbdef..3fb7b9c 100644 --- a/firmware/spikes/main/awdl_app.h +++ b/firmware/spikes/main/awdl_app.h @@ -52,6 +52,15 @@ void awdl_app_tx(uint8_t subtype); */ int awdl_app_tx_ethernet(const uint8_t *eth, size_t len, void *ctx); +/* + * Register a callback for peer-table changes: added=true when a peer is first + * seen, false when it ages out. Fires from the AWDL task at ~1 Hz. Pass NULL to + * clear. Registering re-syncs: every current peer is re-emitted as added on the + * next tick, so a sink registered after start still learns the existing peers. + */ +typedef void (*awdl_peer_cb_t)(bool added, const uint8_t mac[6], void *ctx); +void awdl_app_set_peer_cb(awdl_peer_cb_t cb, void *ctx); + #ifdef __cplusplus } #endif diff --git a/tools/awdl_bridge.py b/tools/awdl_bridge.py index 4c638ac..37b02b6 100644 --- a/tools/awdl_bridge.py +++ b/tools/awdl_bridge.py @@ -17,6 +17,11 @@ # ip -d link show awdl0 # sudo tcpdump -i awdl0 -e # then type `testframe` at the bridge prompt # +# As the device reports AWDL peers (PEER_ADD/PEER_REMOVE), this tool keeps the +# awdl0 IPv6 neighbour table in sync: it adds a permanent fe80:: entry (derived +# from each peer's MAC by EUI-64) so the host can reach a peer by its link-local +# without relying on NDP over the lossy link. Suppressed with --no-config. +# # Interactive stdin commands (forwarded to the device): mac | stats | radio | ping | testframe # # Needs root: creating a TAP and running `ip link` both require CAP_NET_ADMIN. @@ -30,6 +35,7 @@ import os import re import select +import socket import struct import subprocess import sys @@ -61,6 +67,14 @@ def mac_str(b: bytes) -> str: return ":".join(f"{x:02x}" for x in b) +def link_local_from_mac(mac: bytes) -> str: + """IPv6 link-local (fe80::/64) for a MAC, via the modified EUI-64 rule.""" + b = bytearray(mac) + b[0] ^= 0x02 # flip the universal/local bit + iid = bytes(b[0:3]) + b"\xff\xfe" + bytes(b[3:6]) + return socket.inet_ntop(socket.AF_INET6, b"\xfe\x80\x00\x00\x00\x00\x00\x00" + iid) + + def query_mac(usb_fd: int, reader: link.Reader, timeout: float = 2.0): """Ask the device for its Wi-Fi MAC; print any LOG lines seen meanwhile.""" try: @@ -161,9 +175,22 @@ def main() -> int: elif t == link.MSG_CMD_REPLY: print(f"[reply] {p.decode('utf-8', 'replace')}") elif t == link.MSG_PEER_ADD and len(p) == 6: - print(f"[peer+] {mac_str(p)}") + ll = link_local_from_mac(p) + print(f"[peer+] {mac_str(p)} -> {ll}%{args.dev}") + if not args.no_config: + try: + ip("-6", "neigh", "replace", ll, "lladdr", + mac_str(p), "dev", args.dev, "nud", "permanent") + except subprocess.CalledProcessError as e: + print(f"neigh add failed: {e}", file=sys.stderr) elif t == link.MSG_PEER_REMOVE and len(p) == 6: print(f"[peer-] {mac_str(p)}") + if not args.no_config: + try: + ip("-6", "neigh", "del", link_local_from_mac(p), + "dev", args.dev) + except subprocess.CalledProcessError: + pass # entry may already be gone if tap_fd in r: try: From 2a50ca6f41a275e9047d3abc6efe84ec4d07a33b Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 17 Oct 2026 20:15:29 +0000 Subject: [PATCH 31/99] fix(m2.3b): drop 802.11 retries, keep peer events off the AWDL task, resync Hardware test: ping6 over awdl0 works (22% loss, expected on a single 2.4 GHz channel) but reported "+34 duplicates". Plus two review findings. - New portable awdl_dedup module (components/awdl): IEEE 802.11 duplicate detection. Promiscuous capture sees every retransmission of a unicast frame (Retry bit set, same sequence control), and since M2.2 the parser ignores flag bits, so each retry reached the host as a separate packet. A retry whose seq-ctrl matches the last frame accepted from the same transmitter (and QoS TID) is now dropped in radio.c before forwarding; counted as rx_dup and shown by the `radio` command. Host tests cover retry/new-seq/other-TA/ QoS-TID/truncation cases (passing under ASan/UBSan). - Peer events no longer block the AWDL timing task: on_peer_evt only enqueues (bridge_notify_peer, drop on overflow) and a dedicated bridge task does the USB write, restoring the "timing task never blocks on USB" invariant. - Peer resync per host session: the C3 keeps running across host connects (its USB port does not reset the chip), so peers learned earlier were never re-announced and the neighbour table stayed empty. New `resync` command (awdl_app_resync_peers); awdl_bridge.py sends it on startup. Bridge and console firmware both build cleanly for esp32c3 (checked locally with ESP-IDF 5.4.2). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01XzgaN4Bv1m8Ka9iqo7QVUN --- .../spikes/components/awdl/CMakeLists.txt | 1 + firmware/spikes/components/awdl/awdl_dedup.c | 54 +++++++++++++++ .../components/awdl/include/awdl_dedup.h | 61 +++++++++++++++++ .../spikes/components/awdl/test/host/Makefile | 3 +- .../awdl/test/host/test_awdl_frame.c | 66 +++++++++++++++++++ firmware/spikes/main/app_main.c | 5 +- firmware/spikes/main/awdl_app.c | 4 ++ firmware/spikes/main/awdl_app.h | 4 ++ firmware/spikes/main/bridge.c | 57 ++++++++++++++-- firmware/spikes/main/bridge.h | 8 +++ firmware/spikes/main/radio.c | 8 +++ firmware/spikes/main/radio.h | 4 +- tools/awdl_bridge.py | 13 +++- 13 files changed, 277 insertions(+), 11 deletions(-) create mode 100644 firmware/spikes/components/awdl/awdl_dedup.c create mode 100644 firmware/spikes/components/awdl/include/awdl_dedup.h diff --git a/firmware/spikes/components/awdl/CMakeLists.txt b/firmware/spikes/components/awdl/CMakeLists.txt index 0d2703e..5adfe9e 100644 --- a/firmware/spikes/components/awdl/CMakeLists.txt +++ b/firmware/spikes/components/awdl/CMakeLists.txt @@ -1,4 +1,5 @@ idf_component_register( SRCS "awdl_frame.c" "awdl_sync.c" "awdl_election.c" "awdl_peers.c" "awdl_node.c" "awdl_cobs.c" + "awdl_dedup.c" INCLUDE_DIRS "include" ) diff --git a/firmware/spikes/components/awdl/awdl_dedup.c b/firmware/spikes/components/awdl/awdl_dedup.c new file mode 100644 index 0000000..5eb0377 --- /dev/null +++ b/firmware/spikes/components/awdl/awdl_dedup.c @@ -0,0 +1,54 @@ +/* + * EspDrop - 802.11 duplicate (retransmission) detection. See awdl_dedup.h. + * + * SPDX-License-Identifier: GPL-3.0-or-later + * Copyright (C) 2026 EspDrop contributors + */ +#include "awdl_dedup.h" +#include "awdl_wire.h" + +#include + +#define FC_RETRY 0x0800u /* frame-control flag bit 11 (byte 1, bit 3) */ + +void awdl_dedup_init(awdl_dedup_t *d) { + if (d) memset(d, 0, sizeof(*d)); +} + +bool awdl_dedup_is_dup(awdl_dedup_t *d, const uint8_t *buf, size_t len) { + if (!d || !buf || len < 24) return false; /* no sequence-control field */ + + uint16_t fc = 0, seq = 0; + wire_get_le16(buf, len, 0, &fc); + wire_get_le16(buf, len, 22, &seq); + const uint8_t *ta = buf + 10; + + /* QoS data (type 2, subtype bit 3) keeps a sequence space per TID, carried in + * the low 4 bits of the QoS control field right after the 24-byte header. */ + uint8_t ftype = (uint8_t)((fc >> 2) & 0x3); + uint8_t fsub = (uint8_t)((fc >> 4) & 0xf); + uint8_t tid = AWDL_DEDUP_TID_NONE; + if (ftype == 2 && (fsub & 0x8)) { + uint8_t qos = 0; + if (!wire_get_u8(buf, len, 24, &qos)) return false; /* truncated QoS hdr */ + tid = qos & 0x0f; + } + + for (unsigned i = 0; i < AWDL_DEDUP_LEN; i++) { + awdl_dedup_entry_t *e = &d->e[i]; + if (!e->used || e->tid != tid || memcmp(e->ta, ta, 6) != 0) continue; + if ((fc & FC_RETRY) && e->seq_ctrl == seq) + return true; /* retransmission of a frame we already have */ + e->seq_ctrl = seq; /* new frame from this sender: remember it */ + return false; + } + + /* First frame from this (transmitter, TID): take a free or the oldest slot. */ + awdl_dedup_entry_t *e = &d->e[d->next]; + d->next = (uint8_t)((d->next + 1) % AWDL_DEDUP_LEN); + e->used = true; + memcpy(e->ta, ta, 6); + e->tid = tid; + e->seq_ctrl = seq; + return false; +} diff --git a/firmware/spikes/components/awdl/include/awdl_dedup.h b/firmware/spikes/components/awdl/include/awdl_dedup.h new file mode 100644 index 0000000..d510839 --- /dev/null +++ b/firmware/spikes/components/awdl/include/awdl_dedup.h @@ -0,0 +1,61 @@ +/* + * EspDrop - 802.11 duplicate (retransmission) detection (platform independent). + * + * A sender retransmits a unicast frame it did not get an ACK for, with the + * Retry bit set and the same sequence-control value. A normal 802.11 receiver + * discards those repeats (IEEE 802.11 duplicate detection: a frame with Retry=1 + * whose sequence control matches the last one accepted from the same + * transmitter - and, for QoS data, the same TID - is a duplicate). A promiscuous + * capture sees every retry, so without this each retry would reach the host as + * a separate packet (e.g. ping reporting "+N duplicates"). + * + * Fixed-size, round-robin table; no allocation. Not thread-safe: use from one + * context (the Wi-Fi RX callback on the ESP32). + * + * SPDX-License-Identifier: GPL-3.0-or-later + * Copyright (C) 2026 EspDrop contributors + */ +#ifndef ESPDROP_AWDL_DEDUP_H +#define ESPDROP_AWDL_DEDUP_H + +#include +#include +#include + +#ifdef __cplusplus +extern "C" { +#endif + +#ifndef AWDL_DEDUP_LEN +#define AWDL_DEDUP_LEN 8 /* distinct (transmitter, TID) pairs tracked */ +#endif + +#define AWDL_DEDUP_TID_NONE 0xffu /* non-QoS frames share one sequence space */ + +typedef struct { + bool used; + uint8_t ta[6]; /* transmitter address (addr2) */ + uint8_t tid; /* QoS TID, or AWDL_DEDUP_TID_NONE */ + uint16_t seq_ctrl; /* last accepted sequence control (seq << 4 | frag) */ +} awdl_dedup_entry_t; + +typedef struct { + awdl_dedup_entry_t e[AWDL_DEDUP_LEN]; + uint8_t next; /* round-robin slot to replace when full */ +} awdl_dedup_t; + +void awdl_dedup_init(awdl_dedup_t *d); + +/* + * buf/len: an 802.11 data frame from the MAC header (FCS stripped). Returns true + * if it is a retransmission of the last frame accepted from that transmitter and + * should be dropped. Otherwise records it as the latest frame and returns false. + * Frames too short to carry a sequence-control field are never duplicates. + */ +bool awdl_dedup_is_dup(awdl_dedup_t *d, const uint8_t *buf, size_t len); + +#ifdef __cplusplus +} +#endif + +#endif /* ESPDROP_AWDL_DEDUP_H */ diff --git a/firmware/spikes/components/awdl/test/host/Makefile b/firmware/spikes/components/awdl/test/host/Makefile index f2f0c83..081714a 100644 --- a/firmware/spikes/components/awdl/test/host/Makefile +++ b/firmware/spikes/components/awdl/test/host/Makefile @@ -10,7 +10,8 @@ INCLUDE = -I$(COMP)/include # Platform-independent sources under test. LIB = $(COMP)/awdl_frame.c $(COMP)/awdl_sync.c $(COMP)/awdl_election.c \ - $(COMP)/awdl_peers.c $(COMP)/awdl_node.c $(COMP)/awdl_cobs.c + $(COMP)/awdl_peers.c $(COMP)/awdl_node.c $(COMP)/awdl_cobs.c \ + $(COMP)/awdl_dedup.c HEADERS = $(wildcard $(COMP)/include/*.h) BINS = test_awdl_frame test_awdl_cobs diff --git a/firmware/spikes/components/awdl/test/host/test_awdl_frame.c b/firmware/spikes/components/awdl/test/host/test_awdl_frame.c index 7011abb..c8c50c0 100644 --- a/firmware/spikes/components/awdl/test/host/test_awdl_frame.c +++ b/firmware/spikes/components/awdl/test/host/test_awdl_frame.c @@ -15,6 +15,7 @@ #include "awdl_sync.h" #include "awdl_peers.h" #include "awdl_node.h" +#include "awdl_dedup.h" #include #include @@ -166,6 +167,70 @@ static void test_data_qos_and_flags(void) { CHECK(!awdl_parse_data(bad, (size_t)n, &poff, &et), "subtype 4 not rejected"); } +/* M2.3b: 802.11 retransmissions captured in promiscuous mode must be dropped + * (they showed up as "+N duplicates" in ping), but genuinely new frames not. */ +static void test_dedup(void) { + uint8_t a_mac[6] = {0x02, 0, 0, 0, 0, 0x0a}; + uint8_t b_mac[6] = {0x02, 0, 0, 0, 0, 0x0b}; + uint8_t dst[6] = {0x02, 0, 0, 0, 0, 0x02}; + const uint8_t payload[] = {0x60, 0, 0, 0, 0, 8, 58, 255, 1, 2, 3, 4}; + awdl_self_t a, b; + awdl_self_init(&a, a_mac, "a", 6); + awdl_self_init(&b, b_mac, "b", 6); + + uint8_t f1[200], f2[200], fb[200]; + int n1 = awdl_build_data(f1, sizeof(f1), &a, dst, payload, sizeof(payload)); + int n2 = awdl_build_data(f2, sizeof(f2), &a, dst, payload, sizeof(payload)); /* next seq */ + int nb = awdl_build_data(fb, sizeof(fb), &b, dst, payload, sizeof(payload)); + CHECK(n1 > 0 && n2 > 0 && nb > 0, "build failed"); + /* b's first frame has the same sequence number as a's first frame */ + CHECK(fb[22] == f1[22] && fb[23] == f1[23], "test setup: seq mismatch"); + + awdl_dedup_t d; + awdl_dedup_init(&d); + CHECK(!awdl_dedup_is_dup(&d, f1, (size_t)n1), "first frame flagged dup"); + + uint8_t r1[200]; + memcpy(r1, f1, (size_t)n1); + r1[1] |= 0x08; /* Retry bit */ + CHECK(awdl_dedup_is_dup(&d, r1, (size_t)n1), "retry of same seq not dropped"); + CHECK(awdl_dedup_is_dup(&d, r1, (size_t)n1), "second retry not dropped"); + + /* Same seq without Retry is a new frame per 802.11 (e.g. after seq wrap). */ + CHECK(!awdl_dedup_is_dup(&d, f1, (size_t)n1), "non-retry same seq dropped"); + + /* Next sequence number from a, even with Retry set, is new. */ + uint8_t r2[200]; + memcpy(r2, f2, (size_t)n2); + r2[1] |= 0x08; + CHECK(!awdl_dedup_is_dup(&d, r2, (size_t)n2), "new seq with retry dropped"); + + /* Another sender reusing the same seq is independent. */ + uint8_t rb[200]; + memcpy(rb, fb, (size_t)nb); + rb[1] |= 0x08; + CHECK(!awdl_dedup_is_dup(&d, rb, (size_t)nb), "other sender's frame dropped"); + + /* QoS: the same seq on a different TID is a different sequence space. */ + uint8_t q0[200], q5[200]; + memcpy(q0, f1, IEEE80211_HDR_LEN); + q0[0] = 0x88; q0[IEEE80211_HDR_LEN] = 0x00; q0[IEEE80211_HDR_LEN + 1] = 0x00; + memcpy(q0 + IEEE80211_HDR_LEN + 2, f1 + IEEE80211_HDR_LEN, (size_t)n1 - IEEE80211_HDR_LEN); + memcpy(q5, q0, (size_t)n1 + 2); + q5[IEEE80211_HDR_LEN] = 0x05; + awdl_dedup_init(&d); + CHECK(!awdl_dedup_is_dup(&d, q0, (size_t)n1 + 2), "QoS tid0 first flagged"); + q5[1] |= 0x08; + CHECK(!awdl_dedup_is_dup(&d, q5, (size_t)n1 + 2), "QoS tid5 retry wrongly dropped"); + q0[1] |= 0x08; + CHECK(awdl_dedup_is_dup(&d, q0, (size_t)n1 + 2), "QoS tid0 retry not dropped"); + + /* Too short to carry sequence control: never a duplicate, no crash. */ + CHECK(!awdl_dedup_is_dup(&d, f1, 10), "short frame flagged dup"); + /* QoS frame truncated before the QoS control field. */ + CHECK(!awdl_dedup_is_dup(&d, q0, 24), "truncated QoS flagged dup"); +} + static void test_reject_garbage(void) { uint8_t junk[64]; memset(junk, 0xA5, sizeof(junk)); @@ -502,6 +567,7 @@ int main(void) { test_psf_roundtrip(); test_data_roundtrip(); test_data_qos_and_flags(); + test_dedup(); test_reject_garbage(); test_sync_election_parse(); test_sync_math(); diff --git a/firmware/spikes/main/app_main.c b/firmware/spikes/main/app_main.c index f0e4f3d..bc3fe67 100644 --- a/firmware/spikes/main/app_main.c +++ b/firmware/spikes/main/app_main.c @@ -28,10 +28,11 @@ #include "awdl_app.h" /* AWDL peer appeared/left -> tell the host so it can manage the awdl0 neighbour - * table (runs on the AWDL task; bridge_send is thread-safe). */ + * table. Runs on the AWDL timing task, so it only enqueues (bridge_notify_peer + * never blocks); the USB write happens on a bridge task. */ static void on_peer_evt(bool added, const uint8_t mac[6], void *ctx) { (void)ctx; - bridge_send(added ? BRIDGE_MSG_PEER_ADD : BRIDGE_MSG_PEER_REMOVE, mac, 6); + bridge_notify_peer(added, mac); } void app_main(void) { diff --git a/firmware/spikes/main/awdl_app.c b/firmware/spikes/main/awdl_app.c index c742689..67afcd1 100644 --- a/firmware/spikes/main/awdl_app.c +++ b/firmware/spikes/main/awdl_app.c @@ -206,6 +206,10 @@ void awdl_app_set_peer_cb(awdl_peer_cb_t cb, void *ctx) { s_peer_resync = true; /* re-emit all current peers on the next tick */ } +void awdl_app_resync_peers(void) { + s_peer_resync = true; /* consumed by emit_peer_changes on the AWDL task */ +} + int awdl_app_start(const uint8_t self_mac[6], const char *name, uint8_t chan) { if (s_started) return 0; diff --git a/firmware/spikes/main/awdl_app.h b/firmware/spikes/main/awdl_app.h index 3fb7b9c..59029e1 100644 --- a/firmware/spikes/main/awdl_app.h +++ b/firmware/spikes/main/awdl_app.h @@ -61,6 +61,10 @@ int awdl_app_tx_ethernet(const uint8_t *eth, size_t len, void *ctx); typedef void (*awdl_peer_cb_t)(bool added, const uint8_t mac[6], void *ctx); void awdl_app_set_peer_cb(awdl_peer_cb_t cb, void *ctx); +/* Re-emit every current peer as added on the next tick (e.g. when a new host + * session attaches). Safe to call from any task. */ +void awdl_app_resync_peers(void); + #ifdef __cplusplus } #endif diff --git a/firmware/spikes/main/bridge.c b/firmware/spikes/main/bridge.c index fd5327f..5958d46 100644 --- a/firmware/spikes/main/bridge.c +++ b/firmware/spikes/main/bridge.c @@ -19,7 +19,8 @@ #include "freertos/semphr.h" #include "awdl_cobs.h" -#include "radio.h" /* radio_get_stats, for the `radio` diagnostic command */ +#include "awdl_app.h" /* awdl_app_resync_peers, for the `resync` command */ +#include "radio.h" /* radio_get_stats, for the `radio` diagnostic command */ #define BRIDGE_MAX_PAYLOAD 1600 #define BRIDGE_MAX_MSG (1 + BRIDGE_MAX_PAYLOAD) /* type + payload */ @@ -35,8 +36,17 @@ typedef struct { uint8_t eth[14 + BRIDGE_RX_PAYLOAD_MAX]; } rx_frame_t; +/* Peer add/remove notifications, queued by the AWDL task and sent by their own + * task, so the AWDL timing task never blocks on a USB write. */ +#define BRIDGE_PEER_Q_LEN 16 +typedef struct { + bool added; + uint8_t mac[6]; +} peer_evt_t; + static SemaphoreHandle_t s_tx_lock; static QueueHandle_t s_rx_fwd_q; /* rx_frame_t: Wi-Fi task -> fwd task */ +static QueueHandle_t s_peer_q; /* peer_evt_t: AWDL task -> peer task */ static bool s_started; /* Counters touched only from the single RX task (handle_msg), so no locking. */ @@ -105,6 +115,27 @@ static void bridge_fwd_task(void *arg) { } } +void bridge_notify_peer(bool added, const uint8_t mac[6]) { + if (!s_peer_q) return; + peer_evt_t ev; + ev.added = added; + memcpy(ev.mac, mac, 6); + /* Never block the caller (the AWDL task). On overflow the event is dropped; + * the host recovers the full peer set with `resync`. */ + (void)xQueueSend(s_peer_q, &ev, 0); +} + +/* Drain peer notifications to the host (USB write off the AWDL timing task). */ +static void bridge_peer_task(void *arg) { + (void)arg; + peer_evt_t ev; + for (;;) { + if (xQueueReceive(s_peer_q, &ev, portMAX_DELAY) == pdTRUE) + bridge_send(ev.added ? BRIDGE_MSG_PEER_ADD : BRIDGE_MSG_PEER_REMOVE, + ev.mac, 6); + } +} + /* Send a NUL-terminated string as a CMD_REPLY (length from strlen, so a * truncated snprintf can never be reported as longer than the buffer). */ static void cmd_reply(const char *s) { @@ -143,9 +174,9 @@ static void handle_cmd(const uint8_t *p, size_t n) { * for broadcast/multicast tx_ack is always success. Counts are cast to * 32-bit (ample for a test run) and printed with %u, matching `stats`. */ snprintf(rbuf, sizeof(rbuf), - "rx_action=%u rx_data=%u rx_other=%u rx_perr=%u " + "rx_action=%u rx_data=%u rx_dup=%u rx_other=%u rx_perr=%u " "tx_ok=%u tx_err=%u tx_ack=%u tx_noack=%u", - (unsigned)st.rx_action, (unsigned)st.rx_data, + (unsigned)st.rx_action, (unsigned)st.rx_data, (unsigned)st.rx_dup, (unsigned)st.rx_other_bssid, (unsigned)st.rx_parse_err, (unsigned)st.tx_ok, (unsigned)st.tx_err, (unsigned)st.tx_done_ok, (unsigned)st.tx_done_fail); @@ -153,6 +184,13 @@ static void handle_cmd(const uint8_t *p, size_t n) { } else if (strcmp(cmd, "testframe") == 0) { send_test_frame(); cmd_reply("sent testframe"); + } else if (strcmp(cmd, "resync") == 0) { + /* Re-announce every known peer on the next AWDL tick. The host sends this + * on connect, because the C3 keeps running across host sessions (its USB + * port does not reset the chip) and would otherwise never re-emit peers + * it already knew before the host attached. */ + awdl_app_resync_peers(); + cmd_reply("resync"); } else { snprintf(out, sizeof(out), "unknown cmd '%s'", cmd); cmd_reply(out); @@ -181,7 +219,7 @@ static void handle_msg(const uint8_t *msg, size_t len) { case BRIDGE_MSG_CMD: handle_cmd(payload, plen); break; - /* PEER_* are device->host only; produced once the radio lands. */ + /* PEER_* are device->host only (see bridge_notify_peer). */ default: break; } @@ -224,7 +262,12 @@ int bridge_start(void) { } s_rx_fwd_q = xQueueCreate(BRIDGE_RX_Q_LEN, sizeof(rx_frame_t)); - if (!s_rx_fwd_q) { + s_peer_q = xQueueCreate(BRIDGE_PEER_Q_LEN, sizeof(peer_evt_t)); + if (!s_rx_fwd_q || !s_peer_q) { + if (s_rx_fwd_q) vQueueDelete(s_rx_fwd_q); + if (s_peer_q) vQueueDelete(s_peer_q); + s_rx_fwd_q = NULL; + s_peer_q = NULL; vSemaphoreDelete(s_tx_lock); s_tx_lock = NULL; return -1; @@ -245,6 +288,8 @@ int bridge_start(void) { s_started = false; vQueueDelete(s_rx_fwd_q); s_rx_fwd_q = NULL; + vQueueDelete(s_peer_q); + s_peer_q = NULL; vSemaphoreDelete(s_tx_lock); s_tx_lock = NULL; /* Logging was just silenced; restore it so the caller's error is visible @@ -256,6 +301,8 @@ int bridge_start(void) { * dropped (queue fills) but the control link stays up, so do not fail. */ if (xTaskCreate(bridge_fwd_task, "bridge_fwd", 3072, NULL, 5, NULL) != pdPASS) bridge_log("warning: rx-forward task did not start"); + if (xTaskCreate(bridge_peer_task, "bridge_peer", 2560, NULL, 4, NULL) != pdPASS) + bridge_log("warning: peer-notify task did not start"); bridge_log("bridge up"); return 0; diff --git a/firmware/spikes/main/bridge.h b/firmware/spikes/main/bridge.h index 0272bda..887c101 100644 --- a/firmware/spikes/main/bridge.h +++ b/firmware/spikes/main/bridge.h @@ -21,6 +21,7 @@ #ifndef ESPDROP_BRIDGE_H #define ESPDROP_BRIDGE_H +#include #include #include @@ -65,6 +66,13 @@ void bridge_rx_frame(const uint8_t dst[6], const uint8_t src[6], uint16_t ethert typedef int (*bridge_frame_sink_t)(const uint8_t *eth, size_t len, void *ctx); void bridge_set_frame_sink(bridge_frame_sink_t fn, void *ctx); +/* + * Queue a PEER_ADD (added=true) or PEER_REMOVE notification for the host. Never + * blocks (drop on overflow): safe to call from the AWDL timing task. A dedicated + * bridge task performs the USB write. + */ +void bridge_notify_peer(bool added, const uint8_t mac[6]); + /* printf-style LOG message to the host. */ void bridge_log(const char *fmt, ...) __attribute__((format(printf, 1, 2))); diff --git a/firmware/spikes/main/radio.c b/firmware/spikes/main/radio.c index 6e5fe10..44e2af9 100644 --- a/firmware/spikes/main/radio.c +++ b/firmware/spikes/main/radio.c @@ -29,6 +29,7 @@ static radio_action_handler_t s_action_handler; static void *s_action_ctx; static radio_data_handler_t s_data_handler; static void *s_data_ctx; +static awdl_dedup_t s_dedup; /* touched only by rx_cb (Wi-Fi task) */ static inline void stat_inc(uint64_t *field) { portENTER_CRITICAL(&s_stats_mux); @@ -82,6 +83,12 @@ static void rx_cb(void *buf, wifi_promiscuous_pkt_type_t type) { } else { /* WIFI_PKT_DATA */ size_t poff; uint16_t et; if (awdl_parse_data(frame, (size_t)len, &poff, &et)) { + /* Promiscuous capture sees every 802.11 retry of a frame; a normal + * receiver would discard those, so do the same before forwarding. */ + if (awdl_dedup_is_dup(&s_dedup, frame, (size_t)len)) { + stat_inc(&s_stats.rx_dup); + return; + } stat_inc(&s_stats.rx_data); /* Reconstruct Ethernet (dst=addr1, src=addr2) and hand it to the * consumer. addr1/addr2 sit at the fixed offsets 4/10, and the BSSID @@ -96,6 +103,7 @@ static void rx_cb(void *buf, wifi_promiscuous_pkt_type_t type) { /* ------------------------------------------------------------------------ */ int radio_init(uint8_t channel) { + awdl_dedup_init(&s_dedup); /* before promiscuous RX can run */ esp_err_t err = nvs_flash_init(); if (err == ESP_ERR_NVS_NO_FREE_PAGES || err == ESP_ERR_NVS_NEW_VERSION_FOUND) { nvs_flash_erase(); diff --git a/firmware/spikes/main/radio.h b/firmware/spikes/main/radio.h index edb316d..87d9eba 100644 --- a/firmware/spikes/main/radio.h +++ b/firmware/spikes/main/radio.h @@ -15,6 +15,7 @@ #include #include "awdl_frame.h" +#include "awdl_dedup.h" #ifdef __cplusplus extern "C" { @@ -22,7 +23,8 @@ extern "C" { typedef struct { uint64_t rx_action; /* AWDL action frames received (BSSID match) */ - uint64_t rx_data; /* AWDL data frames received */ + uint64_t rx_data; /* AWDL data frames received (duplicates excluded) */ + uint64_t rx_dup; /* AWDL data frames dropped as 802.11 retransmissions */ uint64_t rx_other_bssid;/* frames seen in promiscuous with a different BSSID */ uint64_t rx_parse_err; /* AWDL-looking frames that failed to parse */ uint64_t tx_ok; /* esp_wifi_80211_tx calls that returned ESP_OK */ diff --git a/tools/awdl_bridge.py b/tools/awdl_bridge.py index 37b02b6..2a6d6db 100644 --- a/tools/awdl_bridge.py +++ b/tools/awdl_bridge.py @@ -22,7 +22,8 @@ # from each peer's MAC by EUI-64) so the host can reach a peer by its link-local # without relying on NDP over the lossy link. Suppressed with --no-config. # -# Interactive stdin commands (forwarded to the device): mac | stats | radio | ping | testframe +# Interactive stdin commands (forwarded to the device): +# mac | stats | radio | ping | testframe | resync # # Needs root: creating a TAP and running `ip link` both require CAP_NET_ADMIN. # The interface is not persistent - it disappears when this process exits. @@ -135,7 +136,15 @@ def main() -> int: print(f"{args.dev} up (mtu {args.mtu}, mac {mac or 'kernel-assigned'}); " f"bridging {args.port}") - print("commands: mac | stats | radio | ping | testframe (Ctrl-C to quit)") + print("commands: mac | stats | radio | ping | testframe | resync (Ctrl-C to quit)") + + # The C3 keeps running across host sessions (its USB port does not reset + # the chip), so peers it learned earlier would never be re-announced. Ask + # it to re-emit its current peer set so the neighbour table is complete. + try: + link.write_all(usb_fd, link.frame(link.MSG_CMD, b"resync")) + except (OSError, TimeoutError) as e: + print(f"warning: could not request peer resync ({e})", file=sys.stderr) stdin_fd = sys.stdin.fileno() stdin_buf = bytearray() From db430f3feba617e38a8ea1a0452da250580b43c9 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 17 Oct 2026 20:56:07 +0000 Subject: [PATCH 32/99] fix(host): learn awdl0 neighbours from traffic instead of guessing EUI-64 ping6 to the address shown in the [peer+] line got no replies at all (no "unreachable" either). The bridge derived each peer's fe80:: address from its AWDL MAC by EUI-64 and installed a permanent neighbour for it. Current iOS uses link-local addresses that are not MAC-derived, so that address belonged to no device: the kernel sent blindly to the right MAC (permanent entry, no NDP), and the iPhone dropped packets addressed to an address it does not own. Learn the (link-local source, source MAC) pair from every received IPv6 frame and install the permanent neighbour for the real address. PEER_ADD/REMOVE are now only logged; entries are no longer deleted when a peer briefly ages out (peers flap on the sparse 2.4 GHz link, and deleting would just force NDP). Host-only change, no reflash needed. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01XzgaN4Bv1m8Ka9iqo7QVUN --- tools/awdl_bridge.py | 66 +++++++++++++++++++++++++++----------------- 1 file changed, 41 insertions(+), 25 deletions(-) diff --git a/tools/awdl_bridge.py b/tools/awdl_bridge.py index 2a6d6db..5c1f31a 100644 --- a/tools/awdl_bridge.py +++ b/tools/awdl_bridge.py @@ -17,10 +17,13 @@ # ip -d link show awdl0 # sudo tcpdump -i awdl0 -e # then type `testframe` at the bridge prompt # -# As the device reports AWDL peers (PEER_ADD/PEER_REMOVE), this tool keeps the -# awdl0 IPv6 neighbour table in sync: it adds a permanent fe80:: entry (derived -# from each peer's MAC by EUI-64) so the host can reach a peer by its link-local -# without relying on NDP over the lossy link. Suppressed with --no-config. +# Neighbour table: every IPv6 packet received from a peer carries its real +# link-local source address and its MAC, so this tool learns that pair from the +# traffic and installs a permanent awdl0 neighbour entry for it. The host can then +# reach the peer without NDP over the lossy link. The address is NOT derived from +# the MAC (EUI-64): current iOS uses non-MAC-based link-local addresses, so a +# guessed address would belong to nobody. Peer add/remove events from the device +# are only logged. Suppressed with --no-config. # # Interactive stdin commands (forwarded to the device): # mac | stats | radio | ping | testframe | resync @@ -68,12 +71,21 @@ def mac_str(b: bytes) -> str: return ":".join(f"{x:02x}" for x in b) -def link_local_from_mac(mac: bytes) -> str: - """IPv6 link-local (fe80::/64) for a MAC, via the modified EUI-64 rule.""" - b = bytearray(mac) - b[0] ^= 0x02 # flip the universal/local bit - iid = bytes(b[0:3]) + b"\xff\xfe" + bytes(b[3:6]) - return socket.inet_ntop(socket.AF_INET6, b"\xfe\x80\x00\x00\x00\x00\x00\x00" + iid) +def learn_neighbor(eth: bytes): + """(link-local source address, source MAC) of a received IPv6 frame, or None. + + Only unicast source MACs and fe80::/10 source addresses qualify; anything else + (multicast sender, global/unspecified source, non-IPv6) teaches us nothing. + """ + if len(eth) < 14 + 40 or eth[12:14] != b"\x86\xdd": + return None + src_mac = eth[6:12] + if src_mac[0] & 0x01: # group address cannot be a neighbour + return None + src_ip = eth[22:38] # IPv6 source: Ethernet hdr (14) + 8 + if src_ip[0] != 0xfe or (src_ip[1] & 0xc0) != 0x80: + return None # not link-local + return socket.inet_ntop(socket.AF_INET6, src_ip), bytes(src_mac) def query_mac(usb_fd: int, reader: link.Reader, timeout: float = 2.0): @@ -140,7 +152,7 @@ def main() -> int: # The C3 keeps running across host sessions (its USB port does not reset # the chip), so peers it learned earlier would never be re-announced. Ask - # it to re-emit its current peer set so the neighbour table is complete. + # it to re-emit its current peer set so the [peer+] log is complete. try: link.write_all(usb_fd, link.frame(link.MSG_CMD, b"resync")) except (OSError, TimeoutError) as e: @@ -150,6 +162,7 @@ def main() -> int: stdin_buf = bytearray() stdin_open = True read_cap = args.mtu + 64 # one Ethernet frame plus slack + learned = {} # link-local address -> MAC, from received traffic # Single-threaded select pump. Writes use a bounded 1s timeout so one # direction stalling on USB backpressure cannot block the other for long @@ -179,27 +192,30 @@ def main() -> int: to_tap += 1 except (OSError, TimeoutError) as e: print(f"tap write failed: {e}", file=sys.stderr) + nb = learn_neighbor(p) + if nb and learned.get(nb[0]) != nb[1]: + ll, nmac = nb + learned[ll] = nmac + print(f"[neigh] {ll}%{args.dev} is {mac_str(nmac)}" + f" (learned from traffic)") + if not args.no_config: + try: + ip("-6", "neigh", "replace", ll, "lladdr", + mac_str(nmac), "dev", args.dev, + "nud", "permanent") + except subprocess.CalledProcessError as e: + print(f"neigh add failed: {e}", file=sys.stderr) elif t == link.MSG_LOG: print(f"[device] {p.decode('utf-8', 'replace')}") elif t == link.MSG_CMD_REPLY: print(f"[reply] {p.decode('utf-8', 'replace')}") elif t == link.MSG_PEER_ADD and len(p) == 6: - ll = link_local_from_mac(p) - print(f"[peer+] {mac_str(p)} -> {ll}%{args.dev}") - if not args.no_config: - try: - ip("-6", "neigh", "replace", ll, "lladdr", - mac_str(p), "dev", args.dev, "nud", "permanent") - except subprocess.CalledProcessError as e: - print(f"neigh add failed: {e}", file=sys.stderr) + print(f"[peer+] {mac_str(p)}") elif t == link.MSG_PEER_REMOVE and len(p) == 6: + # Logged only: peers flap on the sparse 2.4 GHz link, and + # deleting the learned neighbour would just force NDP. + # Stale entries are harmless and vanish with awdl0. print(f"[peer-] {mac_str(p)}") - if not args.no_config: - try: - ip("-6", "neigh", "del", link_local_from_mac(p), - "dev", args.dev) - except subprocess.CalledProcessError: - pass # entry may already be gone if tap_fd in r: try: From f743055642b63322e4a4732690ffb249715c617e Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 17 Oct 2026 21:09:40 +0000 Subject: [PATCH 33/99] feat(diag): unicast data TX counters and a `tx` command ping6 sends echo requests but gets no replies, and the existing tx_ack counter could not say whether the peer received them: it also counts our broadcast PSF/MIF (~9/s), which always report success. - radio.c: in the tx-done callback, separately count our own unicast data frames (frame control 0x08, addr1 without the group bit) as tx_uc_ack / tx_uc_noack - the peer's hardware ACK. - awdl_app.c: count the host->radio data path: queued, q_drop (TX queue full), sent_uc / sent_mc (handed to the radio), fail (build or 80211_tx error). - bridge.c: `tx` command prints both. sent_uc vs uc_ack+uc_noack also cross-checks the tx-done classification. Bridge and console firmware both build cleanly for esp32c3 (ESP-IDF 5.4.2). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01XzgaN4Bv1m8Ka9iqo7QVUN --- firmware/spikes/main/awdl_app.c | 23 +++++++++++++++++++++-- firmware/spikes/main/awdl_app.h | 10 ++++++++++ firmware/spikes/main/bridge.c | 13 +++++++++++++ firmware/spikes/main/radio.c | 15 ++++++++++++--- firmware/spikes/main/radio.h | 2 ++ 5 files changed, 58 insertions(+), 5 deletions(-) diff --git a/firmware/spikes/main/awdl_app.c b/firmware/spikes/main/awdl_app.c index 67afcd1..9f37e78 100644 --- a/firmware/spikes/main/awdl_app.c +++ b/firmware/spikes/main/awdl_app.c @@ -65,6 +65,10 @@ static volatile bool s_peer_resync; /* set by set_peer_cb; re-emit all peer static uint8_t s_peer_shadow[AWDL_MAX_PEERS][6]; static int s_peer_nshadow; +/* Data-TX counters: written by the bridge RX task (queued/q_drop) and the AWDL + * task (sent/fail), read by the `tx` command. Aligned 32-bit, stats only. */ +static volatile uint32_t s_tx_queued, s_tx_q_drop, s_tx_sent_uc, s_tx_sent_mc, s_tx_fail; + /* radio handler: Wi-Fi task context, must be quick. Copy into the queue. */ static void on_action(const awdl_peer_view_t *pv, int8_t rssi, uint8_t channel, uint64_t now_us, void *ctx) { @@ -176,7 +180,12 @@ static void awdl_task(void *arg) { while (budget-- > 0 && xQueueReceive(s_tx_q, &td, 0) == pdTRUE) { int n = awdl_build_data(txbuf, sizeof(txbuf), &s_node.self, td.dst, td.ip, td.len); - if (n > 0) radio_tx(txbuf, n); + if (n > 0 && radio_tx(txbuf, n) == 0) { + if (td.dst[0] & 0x01) s_tx_sent_mc++; + else s_tx_sent_uc++; + } else { + s_tx_fail++; + } } } xSemaphoreGive(s_lock); @@ -197,7 +206,17 @@ int awdl_app_tx_ethernet(const uint8_t *eth, size_t len, void *ctx) { memcpy(td.dst, eth, 6); td.len = (uint16_t)plen; if (plen) memcpy(td.ip, eth + 14, plen); - return (xQueueSend(s_tx_q, &td, 0) == pdTRUE) ? 0 : -1; /* drop on overflow */ + if (xQueueSend(s_tx_q, &td, 0) != pdTRUE) { s_tx_q_drop++; return -1; } /* full */ + s_tx_queued++; + return 0; +} + +void awdl_app_get_tx_counters(awdl_app_tx_counters_t *out) { + out->queued = s_tx_queued; + out->q_drop = s_tx_q_drop; + out->sent_uc = s_tx_sent_uc; + out->sent_mc = s_tx_sent_mc; + out->tx_fail = s_tx_fail; } void awdl_app_set_peer_cb(awdl_peer_cb_t cb, void *ctx) { diff --git a/firmware/spikes/main/awdl_app.h b/firmware/spikes/main/awdl_app.h index 59029e1..f5e015c 100644 --- a/firmware/spikes/main/awdl_app.h +++ b/firmware/spikes/main/awdl_app.h @@ -65,6 +65,16 @@ void awdl_app_set_peer_cb(awdl_peer_cb_t cb, void *ctx); * session attaches). Safe to call from any task. */ void awdl_app_resync_peers(void); +/* Data-TX path counters (host frames -> radio), for the `tx` diagnostic. */ +typedef struct { + uint32_t queued; /* IPv6 frames accepted from the host into the TX queue */ + uint32_t q_drop; /* dropped because the TX queue was full */ + uint32_t sent_uc; /* unicast data frames handed to the radio */ + uint32_t sent_mc; /* multicast/broadcast data frames handed to the radio */ + uint32_t tx_fail; /* build or esp_wifi_80211_tx failures */ +} awdl_app_tx_counters_t; +void awdl_app_get_tx_counters(awdl_app_tx_counters_t *out); + #ifdef __cplusplus } #endif diff --git a/firmware/spikes/main/bridge.c b/firmware/spikes/main/bridge.c index 5958d46..f550459 100644 --- a/firmware/spikes/main/bridge.c +++ b/firmware/spikes/main/bridge.c @@ -181,6 +181,19 @@ static void handle_cmd(const uint8_t *p, size_t n) { (unsigned)st.tx_ok, (unsigned)st.tx_err, (unsigned)st.tx_done_ok, (unsigned)st.tx_done_fail); cmd_reply(rbuf); + } else if (strcmp(cmd, "tx") == 0) { + /* Host->radio data path: did the frame reach the radio, and did the peer + * ACK our unicast data frames (broadcast PSF/MIF excluded)? */ + awdl_app_tx_counters_t c; + awdl_app_get_tx_counters(&c); + radio_stats_t st; + radio_get_stats(&st); + snprintf(out, sizeof(out), + "queued=%u q_drop=%u sent_uc=%u sent_mc=%u fail=%u uc_ack=%u uc_noack=%u", + (unsigned)c.queued, (unsigned)c.q_drop, (unsigned)c.sent_uc, + (unsigned)c.sent_mc, (unsigned)c.tx_fail, + (unsigned)st.tx_uc_ack, (unsigned)st.tx_uc_noack); + cmd_reply(out); } else if (strcmp(cmd, "testframe") == 0) { send_test_frame(); cmd_reply("sent testframe"); diff --git a/firmware/spikes/main/radio.c b/firmware/spikes/main/radio.c index 44e2af9..d4ad8dd 100644 --- a/firmware/spikes/main/radio.c +++ b/firmware/spikes/main/radio.c @@ -45,11 +45,20 @@ static inline void stat_inc(uint64_t *field) { * ESP-IDF 5.1 through 6.x (the public esp_wifi_register_80211_tx_cb only * appeared in 5.5). */ static void tx_done_cb(uint8_t ifidx, uint8_t *data, uint16_t *data_len, bool txStatus) { - (void)ifidx; (void)data; (void)data_len; - if (txStatus) + (void)ifidx; + /* Broadcast PSF/MIF always report success, so the totals above say little + * about whether a peer received our data. Separately count our own unicast + * data frames (frame control 0x08, addr1 without the group bit), whose status + * is the peer's hardware ACK. */ + bool uc_data = data && data_len && *data_len >= 10 && + data[0] == 0x08 && !(data[4] & 0x01); + if (txStatus) { stat_inc(&s_stats.tx_done_ok); - else + if (uc_data) stat_inc(&s_stats.tx_uc_ack); + } else { stat_inc(&s_stats.tx_done_fail); + if (uc_data) stat_inc(&s_stats.tx_uc_noack); + } } /* ---- promiscuous rx callback: filter AWDL BSSID, parse, enqueue ---- */ diff --git a/firmware/spikes/main/radio.h b/firmware/spikes/main/radio.h index 87d9eba..fc4773e 100644 --- a/firmware/spikes/main/radio.h +++ b/firmware/spikes/main/radio.h @@ -31,6 +31,8 @@ typedef struct { uint64_t tx_err; /* esp_wifi_80211_tx calls that returned an error */ uint64_t tx_done_ok; /* 80211 tx-done callbacks with status SUCCESS */ uint64_t tx_done_fail; /* 80211 tx-done callbacks with status FAIL */ + uint64_t tx_uc_ack; /* our unicast DATA frames the peer hardware-ACKed */ + uint64_t tx_uc_noack; /* our unicast DATA frames with no ACK after retries */ } radio_stats_t; /* From ef1dba0831e9f48bcfe48b7c46aeebf205efb4ca Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 18 Oct 2026 10:52:10 +0000 Subject: [PATCH 34/99] feat(m2.4): prepare AirDrop receive via patched OpenDrop over awdl0 Firmware/core (findings from airdrop-mt7921 FINDINGS, iOS 26): - Keep the last mirrored channel sequence when the master briefly ages out or names channel 6 nowhere, instead of falling back to 16x ch6. iOS 26 refuses unicast to constant sequences. The static sequence is used only before the first mirror after boot (awdl_node seq_derived). - A-MSDU receive: awdl_parse_data_msdus() delivers every subframe of an aggregate (DA/SA/len + MSDU, 4-byte padding, as in OWL). Null/QoS-null frames are skipped before the duplicate cache. New counters rx_amsdu, rx_msdu, rx_dperr in `radio`. - Device->host RX queue sized for a whole AWDL window burst (up to 32 frames, chosen by free heap with a 64 KiB reserve for Wi-Fi buffers); `stats` reports the depth and free/minimum heap. - AWDL task polls every 2 ms while host frames wait (FREERTOS_HZ=1000), so a present slot gets ~30 flush chances instead of ~3. - `node` command: master, peers, sync error, whether our sequence is a mirror, our sequence and the master's. Host: - awdl_bridge.py: no DAD / RA / router solicitations on awdl0, a [ready] line once the link-local is usable, and the neighbour is learned before the frame is handed to the kernel. - tools/opendrop_install.sh: PyPI opendrop 0.13.0 + the 17 iOS-26 patches from jedbillyb/airdrop-mt7921 (pinned 27edf28) in a venv outside any git repo, with the upstream marker checks. Verified end to end here. - tools/opendrop_receive.sh: waits for a usable awdl0, preflight hints (avahi, ufw, extra addresses), restarts OpenDrop on exit or when the bridge recreates awdl0, logs to ~/espdrop-opendrop.log. Host tests (incl. new A-MSDU and keep-mirror cases) pass under ASan/UBSan; bridge and console firmware build cleanly for esp32c3 (ESP-IDF 5.4.2). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01XzgaN4Bv1m8Ka9iqo7QVUN --- firmware/spikes/components/awdl/awdl_frame.c | 104 ++++++++--- firmware/spikes/components/awdl/awdl_node.c | 22 ++- .../components/awdl/include/awdl_frame.h | 38 +++- .../components/awdl/include/awdl_node.h | 10 + .../awdl/test/host/test_awdl_frame.c | 176 +++++++++++++++++- firmware/spikes/main/awdl_app.c | 12 +- firmware/spikes/main/bridge.c | 64 ++++++- firmware/spikes/main/radio.c | 35 ++-- firmware/spikes/main/radio.h | 3 + tools/awdl_bridge.py | 57 +++++- tools/opendrop_install.sh | 113 +++++++++++ tools/opendrop_receive.sh | 128 +++++++++++++ 12 files changed, 680 insertions(+), 82 deletions(-) create mode 100755 tools/opendrop_install.sh create mode 100755 tools/opendrop_receive.sh diff --git a/firmware/spikes/components/awdl/awdl_frame.c b/firmware/spikes/components/awdl/awdl_frame.c index e2c4b24..07228cb 100644 --- a/firmware/spikes/components/awdl/awdl_frame.c +++ b/firmware/spikes/components/awdl/awdl_frame.c @@ -104,21 +104,18 @@ void awdl_self_set_chanseq_static(awdl_self_t *self, uint8_t chan_num) { } } -void awdl_self_set_chanseq_intersect(awdl_self_t *self, +bool awdl_self_set_chanseq_intersect(awdl_self_t *self, const uint8_t master_chanseq[AWDL_CHANSEQ_LEN], uint8_t keep_chan) { unsigned present = 0; for (unsigned i = 0; i < AWDL_CHANSEQ_LEN; i++) if (master_chanseq[i] == keep_chan) present++; - /* Never advertise "present nowhere": if the master names keep_chan in no - * slot, fall back to the static sequence. We really are on keep_chan in - * every slot, so claiming all 16 is honest and keeps us discoverable; an - * all-absent sequence would be strictly worse than the old flat one. */ - if (present == 0) { - awdl_self_set_chanseq_static(self, keep_chan); - return; - } + /* Never advertise "present nowhere". If the master names keep_chan in no + * slot, leave our sequence untouched and report it; the caller keeps the + * last good mirror rather than falling back to a flat sequence, which iOS 26 + * rejects (airdrop-mt7921 FINDINGS: constant sequences get no unicast). */ + if (present == 0) return false; uint8_t opclass = opclass_for(keep_chan); for (unsigned i = 0; i < AWDL_CHANSEQ_LEN; i++) { @@ -130,6 +127,7 @@ void awdl_self_set_chanseq_intersect(awdl_self_t *self, self->seq[i][1] = 0; } } + return true; } /* ------------------------------------------------------------------------ */ @@ -502,10 +500,8 @@ int awdl_parse_action(const uint8_t *buf, size_t len, awdl_peer_view_t *out) { return AWDL_OK; } -bool awdl_parse_data(const uint8_t *buf, size_t len, - size_t *payload_off, uint16_t *ethertype) { - if (!buf || len < IEEE80211_HDR_LEN) return false; - +size_t awdl_data_hdr_len(const uint8_t *buf, size_t len, bool *amsdu) { + if (amsdu) *amsdu = false; /* Match on frame type/subtype only; ignore the flag bits (retry, power-mgmt, * protected, order, ...) and version, so real Apple frames are not rejected * on a stray flag. Accept both plain data (subtype 0) and QoS data (subtype @@ -513,35 +509,83 @@ bool awdl_parse_data(const uint8_t *buf, size_t len, * ad-hoc (ToDS=FromDS=0), so addr1/2/3 stay at offsets 4/10/16 either way and * the radio layer's BSSID check remains valid. */ uint16_t fc; - if (!wire_get_le16(buf, len, 0, &fc)) return false; + if (!buf || !wire_get_le16(buf, len, 0, &fc)) return 0; uint8_t ftype = (uint8_t)((fc >> 2) & 0x3); /* 2 = data */ uint8_t fsub = (uint8_t)((fc >> 4) & 0xf); /* 0 = data, 8 = QoS data */ - if (ftype != 2) return false; - size_t hdr; - if (fsub == 0) hdr = IEEE80211_HDR_LEN; /* 24 */ - else if (fsub == 8) hdr = IEEE80211_HDR_LEN + 2; /* + QoS control */ - else return false; - - if (len < hdr + 8 + 8) return false; /* LLC/SNAP (8) + AWDL data header (8) */ + if (ftype != 2) return 0; + if (fsub == 0) return (len >= IEEE80211_HDR_LEN) ? IEEE80211_HDR_LEN : 0; + if (fsub == 8) { + uint16_t qos; + if (!wire_get_le16(buf, len, IEEE80211_HDR_LEN, &qos)) return 0; + if (amsdu) *amsdu = (qos & 0x0080u) != 0; /* QoS control: A-MSDU present */ + return IEEE80211_HDR_LEN + 2; + } + return 0; /* null data, QoS null, CF-*: no body to deliver */ +} - size_t llc = hdr; +/* Parse "LLC/SNAP (8) + AWDL data header (8)" starting at off, with end as the + * exclusive bound of the MSDU (the whole frame, or one A-MSDU subframe). */ +static bool parse_awdl_msdu_hdr(const uint8_t *buf, size_t end, size_t off, + size_t *payload_off, uint16_t *ethertype) { + if (end < off + 8 + 8) return false; uint8_t dsap = 0, ssap = 0, ctrl = 0; - wire_get_u8(buf, len, llc + 0, &dsap); - wire_get_u8(buf, len, llc + 1, &ssap); - wire_get_u8(buf, len, llc + 2, &ctrl); + wire_get_u8(buf, end, off + 0, &dsap); + wire_get_u8(buf, end, off + 1, &ssap); + wire_get_u8(buf, end, off + 2, &ctrl); if (dsap != 0xaa || ssap != 0xaa || ctrl != 0x03) return false; - if (memcmp(buf + llc + 3, AWDL_OUI, 3) != 0) return false; + if (memcmp(buf + off + 3, AWDL_OUI, 3) != 0) return false; uint16_t pid = 0, head = 0, et = 0; - wire_get_be16(buf, len, llc + 6, &pid); + wire_get_be16(buf, end, off + 6, &pid); if (pid != AWDL_LLC_PID) return false; - size_t adata = llc + 8; - wire_get_le16(buf, len, adata + 0, &head); + size_t adata = off + 8; + wire_get_le16(buf, end, adata + 0, &head); if (head != AWDL_DATA_HEAD) return false; - wire_get_be16(buf, len, adata + 6, &et); + wire_get_be16(buf, end, adata + 6, &et); if (ethertype) *ethertype = et; if (payload_off) *payload_off = adata + 8; return true; } + +bool awdl_parse_data(const uint8_t *buf, size_t len, + size_t *payload_off, uint16_t *ethertype) { + size_t hdr = awdl_data_hdr_len(buf, len, NULL); + if (!hdr) return false; + return parse_awdl_msdu_hdr(buf, len, hdr, payload_off, ethertype); +} + +int awdl_parse_data_msdus(const uint8_t *buf, size_t len, awdl_msdu_cb_t cb, void *ctx) { + bool amsdu; + size_t hdr = awdl_data_hdr_len(buf, len, &amsdu); + if (!hdr) return 0; + + size_t poff; + uint16_t et; + if (!amsdu) { + if (!parse_awdl_msdu_hdr(buf, len, hdr, &poff, &et)) return AWDL_ERR_NOT_AWDL; + if (cb) cb(buf + 4, buf + 10, et, buf + poff, len - poff, ctx); + return 1; + } + + /* A-MSDU (as handled by OWL's awdl_rx_data_amsdu): a run of subframes, each + * DA(6) SA(6) length(2, BE) + MSDU, padded to a 4-byte boundary except after + * the last one. Each MSDU is a normal AWDL data body (LLC/SNAP + header). */ + int n = 0; + size_t off = hdr; + while (off < len) { + if (len - off < 14) return AWDL_ERR_SHORT; + uint16_t sl = 0; + wire_get_be16(buf, len, off + 12, &sl); + size_t body = off + 14; + if (sl > len - body) return AWDL_ERR_SHORT; + size_t end = body + sl; + if (!parse_awdl_msdu_hdr(buf, end, body, &poff, &et)) return AWDL_ERR_NOT_AWDL; + if (cb) cb(buf + off, buf + off + 6, et, buf + poff, end - poff, ctx); + n++; + off = end; + if (off < len) off += (4 - ((14u + sl) % 4u)) % 4u; + } + return n; +} diff --git a/firmware/spikes/components/awdl/awdl_node.c b/firmware/spikes/components/awdl/awdl_node.c index 9d7948b..f90107c 100644 --- a/firmware/spikes/components/awdl/awdl_node.c +++ b/firmware/spikes/components/awdl/awdl_node.c @@ -48,17 +48,21 @@ static void peer_apply_view(awdl_peer_t *p, const awdl_peer_view_t *pv) { } /* Re-derive the channel sequence we advertise. When we track a master that has - * advertised a channel sequence, mirror its slots on our own channel; otherwise - * fall back to the lone static sequence. */ + * advertised a channel sequence naming our channel, mirror its slots on our own + * channel. Otherwise keep the last mirror we derived; only before the very + * first mirror (just after boot) do we advertise the lone static sequence. */ static void awdl_node_update_chanseq(awdl_node_t *n) { if (n->have_master) { awdl_peer_t *m = awdl_peers_get(&n->peers, n->election.sync_addr); - if (m && m->has_chanseq) { - awdl_self_set_chanseq_intersect(&n->self, m->chanseq, n->self.master_chan); + if (m && m->has_chanseq && + awdl_self_set_chanseq_intersect(&n->self, m->chanseq, n->self.master_chan)) { + n->seq_derived = true; return; } } - awdl_self_set_chanseq_static(&n->self, n->self.master_chan); + if (!n->seq_derived) + awdl_self_set_chanseq_static(&n->self, n->self.master_chan); + /* else: keep the last good mirror (see seq_derived in awdl_node.h) */ } void awdl_node_on_rx_action(awdl_node_t *n, const awdl_peer_view_t *pv, @@ -125,4 +129,12 @@ void awdl_node_status(const awdl_node_t *n, awdl_node_status_t *out) { out->height = n->election.height; out->last_sync_err_tu = n->last_sync_err_tu; out->peer_count = awdl_peers_count(&n->peers); + out->seq_derived = n->seq_derived; + for (unsigned i = 0; i < AWDL_CHANSEQ_LEN; i++) + out->self_chanseq[i] = n->self.seq[i][0]; + const awdl_peer_t *m = n->have_master + ? awdl_peers_get((awdl_peer_table_t *)&n->peers, n->election.sync_addr) : NULL; + out->master_has_chanseq = m && m->has_chanseq; + for (unsigned i = 0; i < AWDL_CHANSEQ_LEN; i++) + out->master_chanseq[i] = out->master_has_chanseq ? m->chanseq[i] : 0; } diff --git a/firmware/spikes/components/awdl/include/awdl_frame.h b/firmware/spikes/components/awdl/include/awdl_frame.h index c0bac90..50189af 100644 --- a/firmware/spikes/components/awdl/include/awdl_frame.h +++ b/firmware/spikes/components/awdl/include/awdl_frame.h @@ -148,15 +148,11 @@ void awdl_self_set_chanseq_static(awdl_self_t *self, uint8_t chan_num); * and far closer to the sparse shape Apple emits than a flat all-one-channel * sequence, which iOS rejects (airdrop-mt7921 findings; docs/UEBERBLICK.md). * - * If the master names keep_chan in NO slot, this falls back to the static - * all-keep_chan sequence rather than advertising presence nowhere. - * - * NOT yet confirmed on hardware: whether iOS accepts a sequence that carries 0 - * in slot 0 (a real Apple device puts its infra channel there, which we have - * none of) or that contains only keep_chan. Treat acceptance as an open - * question to validate against a real iPhone (see M2). + * Returns false and leaves our sequence unchanged if the master names keep_chan + * in NO slot (we would otherwise advertise presence nowhere). The caller decides + * what to advertise then; awdl_node keeps the last good mirror. */ -void awdl_self_set_chanseq_intersect(awdl_self_t *self, +bool awdl_self_set_chanseq_intersect(awdl_self_t *self, const uint8_t master_chanseq[AWDL_CHANSEQ_LEN], uint8_t keep_chan); @@ -242,6 +238,32 @@ int awdl_parse_action(const uint8_t *buf, size_t len, awdl_peer_view_t *out); bool awdl_parse_data(const uint8_t *buf, size_t len, size_t *payload_off, uint16_t *ethertype); +/* + * 802.11 header length of a data frame that carries a body we handle (plain + * data 24 B, QoS data 26 B), or 0 for anything else (management, null data, + * QoS null, ...). *amsdu (optional) is set when the QoS "A-MSDU present" bit is + * set, i.e. the body is a sequence of subframes rather than one MSDU. + */ +size_t awdl_data_hdr_len(const uint8_t *buf, size_t len, bool *amsdu); + +/* + * Called once per MSDU found in a data frame, reconstructed as Ethernet: dst/src + * are addr1/addr2 for a plain frame or the subframe DA/SA for an A-MSDU; payload + * is the L3 packet carried after the AWDL data header. Pointers are only valid + * during the call. + */ +typedef void (*awdl_msdu_cb_t)(const uint8_t dst[6], const uint8_t src[6], + uint16_t ethertype, const uint8_t *payload, + size_t plen, void *ctx); + +/* + * Parse an AWDL data frame, plain or A-MSDU, and call cb (may be NULL) for each + * MSDU. Returns the number of MSDUs delivered; 0 if the frame carries no body we + * handle (see awdl_data_hdr_len); or a negative enum awdl_result if it is + * malformed (subframes before the bad one have already been delivered). + */ +int awdl_parse_data_msdus(const uint8_t *buf, size_t len, awdl_msdu_cb_t cb, void *ctx); + /* Map an operating-class-encoded or legacy channel pair to a channel number. */ uint8_t awdl_chan_number(const uint8_t enc_bytes[2], enum awdl_chan_enc enc); diff --git a/firmware/spikes/components/awdl/include/awdl_node.h b/firmware/spikes/components/awdl/include/awdl_node.h index 331731e..2bd1deb 100644 --- a/firmware/spikes/components/awdl/include/awdl_node.h +++ b/firmware/spikes/components/awdl/include/awdl_node.h @@ -42,6 +42,12 @@ typedef struct { bool have_master; /* we sync to a peer, not to ourselves */ bool sync_locked; /* we have adopted the current master's timing */ uint8_t synced_master[6]; /* the master we are currently tracking */ + + /* Our advertised sequence has been mirrored from a master at least once. + * From then on we keep the last good mirror whenever the master briefly + * ages out or names our channel nowhere, instead of falling back to the flat + * all-one-channel sequence (which iOS 26 refuses to send unicast to). */ + bool seq_derived; } awdl_node_t; /* now_us: a monotonic microsecond timestamp (esp_timer_get_time() on hardware). */ @@ -75,6 +81,10 @@ typedef struct { uint32_t height; int64_t last_sync_err_tu; int peer_count; + bool seq_derived; /* see awdl_node_t */ + uint8_t self_chanseq[AWDL_CHANSEQ_LEN]; /* what we advertise (channel numbers) */ + bool master_has_chanseq; + uint8_t master_chanseq[AWDL_CHANSEQ_LEN]; /* the tracked master's, if known */ } awdl_node_status_t; void awdl_node_status(const awdl_node_t *n, awdl_node_status_t *out); diff --git a/firmware/spikes/components/awdl/test/host/test_awdl_frame.c b/firmware/spikes/components/awdl/test/host/test_awdl_frame.c index c8c50c0..08ab834 100644 --- a/firmware/spikes/components/awdl/test/host/test_awdl_frame.c +++ b/firmware/spikes/components/awdl/test/host/test_awdl_frame.c @@ -231,6 +231,114 @@ static void test_dedup(void) { CHECK(!awdl_dedup_is_dup(&d, q0, 24), "truncated QoS flagged dup"); } +/* Collects MSDUs delivered by awdl_parse_data_msdus. */ +typedef struct { + int n; + uint8_t dst[4][6], src[4][6]; + uint16_t et[4]; + uint8_t pl[4][64]; + size_t plen[4]; +} msdu_sink_t; + +static void msdu_collect(const uint8_t dst[6], const uint8_t src[6], uint16_t et, + const uint8_t *payload, size_t plen, void *ctx) { + msdu_sink_t *k = ctx; + if (k->n >= 4 || plen > sizeof(k->pl[0])) { k->n = 99; return; } + memcpy(k->dst[k->n], dst, 6); + memcpy(k->src[k->n], src, 6); + k->et[k->n] = et; + memcpy(k->pl[k->n], payload, plen); + k->plen[k->n] = plen; + k->n++; +} + +/* M2.4: plain and A-MSDU data frames through awdl_parse_data_msdus. An A-MSDU + * body is subframes DA(6) SA(6) len(2, BE) + MSDU, padded to 4 bytes except + * after the last (as in OWL's awdl_rx_data_amsdu). */ +static void test_data_amsdu(void) { + uint8_t a_mac[6] = {0x02, 0, 0, 0, 0, 0x0a}; + uint8_t dst[6] = {0x02, 0, 0, 0, 0, 0x02}; + uint8_t mc[6] = {0x33, 0x33, 0, 0, 0, 0xfb}; + awdl_self_t a; + awdl_self_init(&a, a_mac, "a", 6); + const uint8_t p1[] = {0x60, 1, 2, 3, 4}; /* 5 B: forces 1 B padding */ + const uint8_t p2[] = {0x60, 9, 8, 7, 6, 5, 4, 3}; /* 8 B */ + + /* Plain frame: one MSDU, dst/src from addr1/addr2. */ + uint8_t f[300]; + int n = awdl_build_data(f, sizeof(f), &a, dst, p1, sizeof(p1)); + CHECK(n > 0, "build plain"); + msdu_sink_t k; + memset(&k, 0, sizeof(k)); + CHECK(awdl_parse_data_msdus(f, (size_t)n, msdu_collect, &k) == 1 && k.n == 1, + "plain: msdus=%d", k.n); + CHECK(memcmp(k.dst[0], dst, 6) == 0 && memcmp(k.src[0], a_mac, 6) == 0, "plain addrs"); + CHECK(k.et[0] == ETHERTYPE_IPV6 && k.plen[0] == sizeof(p1) && + memcmp(k.pl[0], p1, sizeof(p1)) == 0, "plain payload"); + + /* MSDU bodies (LLC/SNAP + AWDL data header + payload) taken from built + * frames: everything after the 24-byte non-QoS header. */ + uint8_t b1[300], b2[300]; + int n1 = awdl_build_data(b1, sizeof(b1), &a, dst, p1, sizeof(p1)); + int n2 = awdl_build_data(b2, sizeof(b2), &a, mc, p2, sizeof(p2)); + size_t m1 = (size_t)n1 - IEEE80211_HDR_LEN, m2 = (size_t)n2 - IEEE80211_HDR_LEN; + + uint8_t q[600]; + size_t off = 0; + memcpy(q, b1, IEEE80211_HDR_LEN); /* addr1/2/3, seq */ + q[0] = 0x88; /* QoS data */ + off = IEEE80211_HDR_LEN; + q[off++] = 0x80; q[off++] = 0x00; /* QoS ctl: A-MSDU present */ + /* subframe 1 */ + memcpy(q + off, dst, 6); memcpy(q + off + 6, a_mac, 6); + q[off + 12] = (uint8_t)(m1 >> 8); q[off + 13] = (uint8_t)m1; + memcpy(q + off + 14, b1 + IEEE80211_HDR_LEN, m1); + off += 14 + m1; + /* pad so that (14 + m1) rounds up to a multiple of 4 */ + size_t pad = (4 - ((14 + m1) % 4)) % 4; + off = IEEE80211_HDR_LEN + 2 + 14 + m1 + pad; + memset(q + IEEE80211_HDR_LEN + 2 + 14 + m1, 0, pad); + /* subframe 2 (multicast DA), last: no padding */ + memcpy(q + off, mc, 6); memcpy(q + off + 6, a_mac, 6); + q[off + 12] = (uint8_t)(m2 >> 8); q[off + 13] = (uint8_t)m2; + memcpy(q + off + 14, b2 + IEEE80211_HDR_LEN, m2); + off += 14 + m2; + + bool amsdu = false; + CHECK(awdl_data_hdr_len(q, off, &amsdu) == IEEE80211_HDR_LEN + 2 && amsdu, + "A-MSDU not detected"); + CHECK(!awdl_parse_data(q, off, NULL, NULL), "legacy parser must not misread A-MSDU"); + memset(&k, 0, sizeof(k)); + int r = awdl_parse_data_msdus(q, off, msdu_collect, &k); + CHECK(r == 2 && k.n == 2, "A-MSDU: r=%d msdus=%d", r, k.n); + CHECK(memcmp(k.dst[0], dst, 6) == 0 && memcmp(k.dst[1], mc, 6) == 0, "A-MSDU dsts"); + CHECK(memcmp(k.src[1], a_mac, 6) == 0, "A-MSDU src"); + CHECK(k.plen[0] == sizeof(p1) && memcmp(k.pl[0], p1, sizeof(p1)) == 0, "A-MSDU p1"); + CHECK(k.plen[1] == sizeof(p2) && memcmp(k.pl[1], p2, sizeof(p2)) == 0, "A-MSDU p2"); + + /* Truncated second subframe: first is delivered, then an error. */ + memset(&k, 0, sizeof(k)); + r = awdl_parse_data_msdus(q, off - 3, msdu_collect, &k); + CHECK(r < 0 && k.n == 1, "truncated A-MSDU: r=%d msdus=%d", r, k.n); + /* Subframe length larger than the frame. */ + uint8_t bad[600]; + memcpy(bad, q, off); + bad[IEEE80211_HDR_LEN + 2 + 12] = 0x7f; + memset(&k, 0, sizeof(k)); + CHECK(awdl_parse_data_msdus(bad, off, msdu_collect, &k) < 0 && k.n == 0, + "oversized subframe length accepted"); + /* Null data (subtype 4): no body, not an error. */ + uint8_t nul[24]; + memcpy(nul, f, 24); + nul[0] = 0x48; + CHECK(awdl_data_hdr_len(nul, sizeof(nul), NULL) == 0 && + awdl_parse_data_msdus(nul, sizeof(nul), msdu_collect, &k) == 0, "null data"); + /* QoS data too short for its QoS control field. */ + uint8_t qs[25]; + memcpy(qs, q, 25); + CHECK(awdl_data_hdr_len(qs, sizeof(qs), NULL) == 0, "short QoS header accepted"); +} + static void test_reject_garbage(void) { uint8_t junk[64]; memset(junk, 0xA5, sizeof(junk)); @@ -497,26 +605,78 @@ static void test_node_chanseq_mirror(void) { i, ours.chanseq[i], expect); } - /* After the master ages out, fall back to the lone static all-6 sequence. */ + /* After the master ages out, KEEP the last mirror: falling back to a flat + * all-6 sequence is the shape iOS 26 refuses to send unicast to. */ awdl_node_tick(&n, 100000 + 3000000); + CHECK(!n.have_master, "master should have aged out"); len = awdl_node_build_action(&n, buf, sizeof(buf), AWDL_SUBTYPE_MIF, 3200000); CHECK(awdl_parse_action(buf, (size_t)len, &ours) == AWDL_OK, "parse MIF 2"); + for (int i = 0; i < (int)AWDL_CHANSEQ_LEN; i++) { + uint8_t expect = (i == 0 || i == 8) ? 6 : 0; + CHECK(ours.chanseq[i] == expect, "kept chanseq[%d]=%u expected %u", + i, ours.chanseq[i], expect); + } + awdl_node_status_t st; + awdl_node_status(&n, &st); + CHECK(st.seq_derived && !st.have_master && !st.master_has_chanseq, + "status after age-out: derived=%d master=%d mseq=%d", + st.seq_derived, st.have_master, st.master_has_chanseq); + CHECK(st.self_chanseq[8] == 6 && st.self_chanseq[2] == 0, "status self seq"); +} + +/* Before any master: static sequence. A master that names ch6 nowhere must not + * replace a good mirror, and must not produce a flat sequence either. */ +static void test_node_chanseq_keep_on_no_ch6(void) { + uint8_t me[6] = {0x02, 0, 0, 0, 0, 0x01}; + uint8_t ip[6] = {0xca, 0x2a, 0xd6, 0x10, 0x8e, 0xae}; + awdl_node_t n; + awdl_node_init(&n, me, "espdrop-c3", 6, 0); + CHECK(!n.seq_derived, "fresh node must not be derived"); for (int i = 0; i < (int)AWDL_CHANSEQ_LEN; i++) - CHECK(ours.chanseq[i] == 6, "fallback chanseq[%d]=%u expected 6", - i, ours.chanseq[i]); + CHECK(n.self.seq[i][0] == 6, "boot static slot %d = %u", i, n.self.seq[i][0]); + + awdl_sync_t iph; + awdl_sync_init(&iph, 0); + awdl_peer_view_t pv; + iphone_announce(&iph, 100000, ip, &pv); /* 6 at slots 0 and 8 */ + awdl_node_on_rx_action(&n, &pv, -40, 100000); + CHECK(n.seq_derived, "mirror not derived"); + + /* The same master now advertises no channel-6 slot at all. */ + iphone_announce(&iph, 200000, ip, &pv); + memset(pv.chanseq, 0, sizeof(pv.chanseq)); + pv.chanseq[2] = 44; pv.chanseq[10] = 149; + awdl_node_on_rx_action(&n, &pv, -40, 200000); + for (int i = 0; i < (int)AWDL_CHANSEQ_LEN; i++) { + uint8_t expect = (i == 0 || i == 8) ? 6 : 0; + CHECK(n.self.seq[i][0] == expect, "no-ch6 master: slot %d = %u expected %u", + i, n.self.seq[i][0], expect); + } + + /* And when it comes back with a different ch6 slot, follow it. */ + iphone_announce(&iph, 300000, ip, &pv); + memset(pv.chanseq, 0, sizeof(pv.chanseq)); + pv.chanseq[4] = 6; pv.chanseq[12] = 149; + awdl_node_on_rx_action(&n, &pv, -40, 300000); + for (int i = 0; i < (int)AWDL_CHANSEQ_LEN; i++) { + uint8_t expect = (i == 4) ? 6 : 0; + CHECK(n.self.seq[i][0] == expect, "re-mirror: slot %d = %u expected %u", + i, n.self.seq[i][0], expect); + } } static void test_chanseq_empty_intersection(void) { uint8_t me[6] = {0x02, 0, 0, 0, 0, 0x01}; awdl_self_t self; awdl_self_init(&self, me, "x", 6); - /* master with no channel-6 slot at all: must fall back to static all-6, - * never advertise presence nowhere. */ + /* master with no channel-6 slot at all: report it and leave our sequence + * untouched (here the static all-6 from init), never "present nowhere". */ uint8_t mseq[AWDL_CHANSEQ_LEN] = {0}; mseq[2] = 44; mseq[10] = 149; - awdl_self_set_chanseq_intersect(&self, mseq, 6); + CHECK(!awdl_self_set_chanseq_intersect(&self, mseq, 6), + "empty intersection must return false"); for (int i = 0; i < (int)AWDL_CHANSEQ_LEN; i++) - CHECK(self.seq[i][0] == 6, "empty-intersection fallback slot %d = %u", + CHECK(self.seq[i][0] == 6, "empty-intersection left slot %d = %u", i, self.seq[i][0]); } @@ -568,6 +728,7 @@ int main(void) { test_data_roundtrip(); test_data_qos_and_flags(); test_dedup(); + test_data_amsdu(); test_reject_garbage(); test_sync_election_parse(); test_sync_math(); @@ -577,6 +738,7 @@ int main(void) { test_node_sync_hold(); test_node_chanseq_mirror(); test_chanseq_empty_intersection(); + test_node_chanseq_keep_on_no_ch6(); test_chanseq_realistic_slot0(); test_tx_slot_gating(); diff --git a/firmware/spikes/main/awdl_app.c b/firmware/spikes/main/awdl_app.c index 9f37e78..e5253a3 100644 --- a/firmware/spikes/main/awdl_app.c +++ b/firmware/spikes/main/awdl_app.c @@ -30,7 +30,10 @@ static const char *TAG = "awdl_app"; /* Host->radio data TX: one IPv6 packet (awdl0 MTU) per slot, queued from the * bridge RX task and flushed by the AWDL task during advertised-present slots. */ -#define TX_Q_LEN 8 +/* TX queue depth. Kept at 8: outbound traffic while receiving is mostly small + * TCP ACKs (q_drop stayed 0 in the ping tests), and every entry reserves a full + * MTU, so the heap is better spent on the device->host RX queue (bridge.c). */ +#define TX_Q_LEN 8 /* Max IPv6 payload per data frame. esp_wifi_80211_tx caps the 802.11 frame at * 1500 bytes; awdl_build_data adds 40 bytes of framing (24 B 802.11 + 8 B * LLC/SNAP + 8 B AWDL data header), so the payload must stay <= 1500 - 40. This @@ -140,7 +143,12 @@ static void awdl_task(void *arg) { for (;;) { rx_event_t ev; - if (xQueueReceive(s_rx_q, &ev, pdMS_TO_TICKS(20)) == pdTRUE) { + /* With host frames waiting, wake every 2 ms (FREERTOS_HZ=1000) so a + * ~65 ms present slot gets ~30 flush chances instead of ~3. */ + TickType_t wait = (s_tx_q && uxQueueMessagesWaiting(s_tx_q) > 0) + ? pdMS_TO_TICKS(2) : pdMS_TO_TICKS(20); + if (wait == 0) wait = 1; + if (xQueueReceive(s_rx_q, &ev, wait) == pdTRUE) { xSemaphoreTake(s_lock, portMAX_DELAY); awdl_node_on_rx_action(&s_node, &ev.pv, ev.rssi, ev.now_us); xSemaphoreGive(s_lock); diff --git a/firmware/spikes/main/bridge.c b/firmware/spikes/main/bridge.c index f550459..b995e04 100644 --- a/firmware/spikes/main/bridge.c +++ b/firmware/spikes/main/bridge.c @@ -13,6 +13,7 @@ #include "driver/usb_serial_jtag.h" #include "esp_log.h" #include "esp_mac.h" +#include "esp_system.h" /* esp_get_free_heap_size */ #include "freertos/FreeRTOS.h" #include "freertos/task.h" #include "freertos/queue.h" @@ -30,7 +31,13 @@ /* Device->host RX forwarding: one Ethernet frame per queue slot. The Wi-Fi task * only builds+enqueues (never blocks on USB); a dedicated task drains and sends. */ #define BRIDGE_RX_PAYLOAD_MAX 1500 /* L3 payload cap (awdl0 MTU) */ -#define BRIDGE_RX_Q_LEN 8 +/* One AWDL window delivers ~15 frames in ~50 ms (airdrop-mt7921 FINDINGS), much + * faster than USB drains them, so the queue must absorb a whole burst. Try the + * big depth first and fall back if the heap is short. */ +static const uint8_t k_rx_q_depths[] = {32, 24, 16, 8}; +/* Wi-Fi allocates its RX/TX buffers dynamically from the same heap, so never + * take the last of it: a larger depth is used only if this much stays free. */ +#define BRIDGE_HEAP_RESERVE (64u * 1024u) typedef struct { uint16_t len; uint8_t eth[14 + BRIDGE_RX_PAYLOAD_MAX]; @@ -47,6 +54,7 @@ typedef struct { static SemaphoreHandle_t s_tx_lock; static QueueHandle_t s_rx_fwd_q; /* rx_frame_t: Wi-Fi task -> fwd task */ static QueueHandle_t s_peer_q; /* peer_evt_t: AWDL task -> peer task */ +static uint8_t s_rx_q_depth; /* depth actually allocated */ static bool s_started; /* Counters touched only from the single RX task (handle_msg), so no locking. */ @@ -151,7 +159,7 @@ static void handle_cmd(const uint8_t *p, size_t n) { while (k && (cmd[k - 1] == '\n' || cmd[k - 1] == '\r' || cmd[k - 1] == ' ')) cmd[--k] = '\0'; - char out[128]; + char out[192]; if (strcmp(cmd, "ping") == 0) { cmd_reply("pong"); } else if (strcmp(cmd, "mac") == 0) { @@ -162,25 +170,58 @@ static void handle_cmd(const uint8_t *p, size_t n) { cmd_reply(out); } else if (strcmp(cmd, "stats") == 0) { snprintf(out, sizeof(out), - "frames_from_host=%u bytes_from_host=%u frames_to_host=%u rx_drops=%u", + "frames_from_host=%u bytes_from_host=%u frames_to_host=%u rx_drops=%u " + "rxq=%u heap=%u heap_min=%u", (unsigned)s_frames_from_host, (unsigned)s_bytes_from_host, - (unsigned)s_frames_to_host, (unsigned)s_rx_drops); + (unsigned)s_frames_to_host, (unsigned)s_rx_drops, + (unsigned)s_rx_q_depth, (unsigned)esp_get_free_heap_size(), + (unsigned)esp_get_minimum_free_heap_size()); cmd_reply(out); } else if (strcmp(cmd, "radio") == 0) { radio_stats_t st; radio_get_stats(&st); - char rbuf[200]; + char rbuf[256]; /* tx_ack = unicast frames the peer hardware-ACKed (our TX reached it); * for broadcast/multicast tx_ack is always success. Counts are cast to * 32-bit (ample for a test run) and printed with %u, matching `stats`. */ snprintf(rbuf, sizeof(rbuf), - "rx_action=%u rx_data=%u rx_dup=%u rx_other=%u rx_perr=%u " - "tx_ok=%u tx_err=%u tx_ack=%u tx_noack=%u", + "rx_action=%u rx_data=%u rx_dup=%u rx_amsdu=%u rx_msdu=%u rx_dperr=%u " + "rx_other=%u rx_perr=%u tx_ok=%u tx_err=%u tx_ack=%u tx_noack=%u", (unsigned)st.rx_action, (unsigned)st.rx_data, (unsigned)st.rx_dup, + (unsigned)st.rx_amsdu, (unsigned)st.rx_msdu, (unsigned)st.rx_data_perr, (unsigned)st.rx_other_bssid, (unsigned)st.rx_parse_err, (unsigned)st.tx_ok, (unsigned)st.tx_err, (unsigned)st.tx_done_ok, (unsigned)st.tx_done_fail); cmd_reply(rbuf); + } else if (strcmp(cmd, "node") == 0) { + /* AWDL node state: who we sync to and which slots we advertise. If iOS + * stops sending to us, check here that the master is the iPhone and that + * our sequence is sparse (mirrored), not 16x the same channel. */ + awdl_node_status_t ns; + char nbuf[320]; + if (!awdl_app_status(&ns)) { + cmd_reply("node: awdl not started"); + } else { + int o = snprintf(nbuf, sizeof(nbuf), + "master=%02x:%02x:%02x:%02x:%02x:%02x%s peers=%d height=%u " + "sync_err=%d derived=%s seq=", + ns.master_addr[0], ns.master_addr[1], ns.master_addr[2], + ns.master_addr[3], ns.master_addr[4], ns.master_addr[5], + ns.have_master ? "" : "(self)", ns.peer_count, + (unsigned)ns.height, (int)ns.last_sync_err_tu, + ns.seq_derived ? "yes" : "no"); + for (unsigned i = 0; i < AWDL_CHANSEQ_LEN && o > 0 && o < (int)sizeof(nbuf); i++) + o += snprintf(nbuf + o, sizeof(nbuf) - (size_t)o, "%s%u", + i ? "," : "", ns.self_chanseq[i]); + if (o > 0 && o < (int)sizeof(nbuf)) + o += snprintf(nbuf + o, sizeof(nbuf) - (size_t)o, " mseq="); + for (unsigned i = 0; i < AWDL_CHANSEQ_LEN && o > 0 && o < (int)sizeof(nbuf); i++) + o += ns.master_has_chanseq + ? snprintf(nbuf + o, sizeof(nbuf) - (size_t)o, "%s%u", + i ? "," : "", ns.master_chanseq[i]) + : (i ? 0 : snprintf(nbuf + o, sizeof(nbuf) - (size_t)o, "-")); + cmd_reply(nbuf); + } } else if (strcmp(cmd, "tx") == 0) { /* Host->radio data path: did the frame reach the radio, and did the peer * ACK our unicast data frames (broadcast PSF/MIF excluded)? */ @@ -274,7 +315,14 @@ int bridge_start(void) { return e; } - s_rx_fwd_q = xQueueCreate(BRIDGE_RX_Q_LEN, sizeof(rx_frame_t)); + for (size_t i = 0; i < sizeof(k_rx_q_depths) && !s_rx_fwd_q; i++) { + size_t need = (size_t)k_rx_q_depths[i] * sizeof(rx_frame_t); + bool last = (i + 1 == sizeof(k_rx_q_depths)); + if (!last && esp_get_free_heap_size() < need + BRIDGE_HEAP_RESERVE) + continue; /* keep headroom for Wi-Fi; the smallest depth is always tried */ + s_rx_fwd_q = xQueueCreate(k_rx_q_depths[i], sizeof(rx_frame_t)); + if (s_rx_fwd_q) s_rx_q_depth = k_rx_q_depths[i]; + } s_peer_q = xQueueCreate(BRIDGE_PEER_Q_LEN, sizeof(peer_evt_t)); if (!s_rx_fwd_q || !s_peer_q) { if (s_rx_fwd_q) vQueueDelete(s_rx_fwd_q); diff --git a/firmware/spikes/main/radio.c b/firmware/spikes/main/radio.c index d4ad8dd..0fbf7a1 100644 --- a/firmware/spikes/main/radio.c +++ b/firmware/spikes/main/radio.c @@ -90,21 +90,28 @@ static void rx_cb(void *buf, wifi_promiscuous_pkt_type_t type) { s_action_handler(&pv, pkt->rx_ctrl.rssi, pkt->rx_ctrl.channel, esp_timer_get_time(), s_action_ctx); } else { /* WIFI_PKT_DATA */ - size_t poff; uint16_t et; - if (awdl_parse_data(frame, (size_t)len, &poff, &et)) { - /* Promiscuous capture sees every 802.11 retry of a frame; a normal - * receiver would discard those, so do the same before forwarding. */ - if (awdl_dedup_is_dup(&s_dedup, frame, (size_t)len)) { - stat_inc(&s_stats.rx_dup); - return; - } + /* Only data frames that carry a body (plain/QoS data); null frames etc. + * are ignored before they can touch the duplicate cache. */ + bool amsdu; + if (!awdl_data_hdr_len(frame, (size_t)len, &amsdu)) return; + /* Promiscuous capture sees every 802.11 retry of a frame; a normal + * receiver would discard those, so do the same before forwarding. */ + if (awdl_dedup_is_dup(&s_dedup, frame, (size_t)len)) { + stat_inc(&s_stats.rx_dup); + return; + } + /* Reconstruct Ethernet per MSDU (one for a plain frame, several for an + * A-MSDU) and hand each to the consumer; radio_data_handler_t has the + * same shape as awdl_msdu_cb_t. */ + int n = awdl_parse_data_msdus(frame, (size_t)len, s_data_handler, s_data_ctx); + if (n > 0) { stat_inc(&s_stats.rx_data); - /* Reconstruct Ethernet (dst=addr1, src=addr2) and hand it to the - * consumer. addr1/addr2 sit at the fixed offsets 4/10, and the BSSID - * check above already guaranteed len >= IEEE80211_HDR_LEN + 4. */ - if (s_data_handler && poff <= (size_t)len) - s_data_handler(frame + 4, frame + 10, et, - frame + poff, (size_t)len - poff, s_data_ctx); + if (amsdu) stat_inc(&s_stats.rx_amsdu); + portENTER_CRITICAL(&s_stats_mux); + s_stats.rx_msdu += (uint64_t)n; + portEXIT_CRITICAL(&s_stats_mux); + } else if (n < 0) { + stat_inc(&s_stats.rx_data_perr); } } } diff --git a/firmware/spikes/main/radio.h b/firmware/spikes/main/radio.h index fc4773e..2606e9d 100644 --- a/firmware/spikes/main/radio.h +++ b/firmware/spikes/main/radio.h @@ -25,6 +25,9 @@ typedef struct { uint64_t rx_action; /* AWDL action frames received (BSSID match) */ uint64_t rx_data; /* AWDL data frames received (duplicates excluded) */ uint64_t rx_dup; /* AWDL data frames dropped as 802.11 retransmissions */ + uint64_t rx_amsdu; /* of rx_data: frames that were A-MSDU aggregates */ + uint64_t rx_msdu; /* MSDUs (L3 packets) handed to the data handler */ + uint64_t rx_data_perr; /* body-carrying AWDL data frames that failed to parse */ uint64_t rx_other_bssid;/* frames seen in promiscuous with a different BSSID */ uint64_t rx_parse_err; /* AWDL-looking frames that failed to parse */ uint64_t tx_ok; /* esp_wifi_80211_tx calls that returned ESP_OK */ diff --git a/tools/awdl_bridge.py b/tools/awdl_bridge.py index 5c1f31a..cc643e1 100644 --- a/tools/awdl_bridge.py +++ b/tools/awdl_bridge.py @@ -26,7 +26,7 @@ # are only logged. Suppressed with --no-config. # # Interactive stdin commands (forwarded to the device): -# mac | stats | radio | ping | testframe | resync +# mac | stats | radio | tx | node | ping | testframe | resync # # Needs root: creating a TAP and running `ip link` both require CAP_NET_ADMIN. # The interface is not persistent - it disappears when this process exits. @@ -88,6 +88,30 @@ def learn_neighbor(eth: bytes): return socket.inet_ntop(socket.AF_INET6, src_ip), bytes(src_mac) +def ipv6_sysctl(dev: str, key: str, value: str) -> None: + """Set net.ipv6.conf..; warn instead of failing if unavailable.""" + path = f"/proc/sys/net/ipv6/conf/{dev}/{key}" + try: + with open(path, "w") as f: + f.write(value) + except OSError as e: + print(f"warning: could not set {path} = {value} ({e})", file=sys.stderr) + + +def usable_link_local(dev: str): + """The interface's link-local address once it is usable (not tentative), else None.""" + try: + out = subprocess.run(["ip", "-6", "-o", "addr", "show", "dev", dev, "scope", "link"], + capture_output=True, text=True, check=True).stdout + except (OSError, subprocess.CalledProcessError): + return None + for line in out.splitlines(): + parts = line.split() + if "inet6" in parts and "tentative" not in parts: + return parts[parts.index("inet6") + 1].split("/")[0] + return None + + def query_mac(usb_fd: int, reader: link.Reader, timeout: float = 2.0): """Ask the device for its Wi-Fi MAC; print any LOG lines seen meanwhile.""" try: @@ -144,11 +168,19 @@ def main() -> int: ip("link", "set", "dev", args.dev, "address", mac) except subprocess.CalledProcessError as e: print(f"warning: could not set MAC {mac}: {e}", file=sys.stderr) + # Before bringing the link up: no DAD (only we live on this link, and + # an address still "tentative" makes OpenDrop's zeroconf announce into + # nothing), and no router solicitations/RA (no routers on AWDL; every + # frame we send costs scarce channel-6 airtime). + ipv6_sysctl(args.dev, "accept_dad", "0") + ipv6_sysctl(args.dev, "accept_ra", "0") + ipv6_sysctl(args.dev, "router_solicitations", "0") ip("link", "set", "dev", args.dev, "mtu", str(args.mtu), "up") print(f"{args.dev} up (mtu {args.mtu}, mac {mac or 'kernel-assigned'}); " f"bridging {args.port}") - print("commands: mac | stats | radio | ping | testframe | resync (Ctrl-C to quit)") + print("commands: mac | stats | radio | tx | node | ping | testframe | resync" + " (Ctrl-C to quit)") # The C3 keeps running across host sessions (its USB port does not reset # the chip), so peers it learned earlier would never be re-announced. Ask @@ -163,6 +195,7 @@ def main() -> int: stdin_open = True read_cap = args.mtu + 64 # one Ethernet frame plus slack learned = {} # link-local address -> MAC, from received traffic + ready_ll = None # our usable link-local once announced # Single-threaded select pump. Writes use a bounded 1s timeout so one # direction stalling on USB backpressure cannot block the other for long @@ -171,7 +204,12 @@ def main() -> int: watch = [usb_fd, tap_fd] if stdin_open: watch.append(stdin_fd) - r, _, _ = select.select(watch, [], []) + r, _, _ = select.select(watch, [], [], None if ready_ll else 0.25) + if not ready_ll and not args.no_config: + ready_ll = usable_link_local(args.dev) + if ready_ll: + print(f"[ready] {args.dev} has {ready_ll}%{args.dev} - " + f"start OpenDrop now (tools/opendrop_receive.sh)") if usb_fd in r: try: @@ -187,11 +225,9 @@ def main() -> int: if data: for t, p in reader.feed(data): if t == link.MSG_FRAME: - try: - link.write_all(tap_fd, p, timeout=1.0) - to_tap += 1 - except (OSError, TimeoutError) as e: - print(f"tap write failed: {e}", file=sys.stderr) + # Learn the sender before handing the frame to the + # kernel, so a reply (e.g. a SYN-ACK) already finds + # its permanent neighbour entry instead of NDP. nb = learn_neighbor(p) if nb and learned.get(nb[0]) != nb[1]: ll, nmac = nb @@ -205,6 +241,11 @@ def main() -> int: "nud", "permanent") except subprocess.CalledProcessError as e: print(f"neigh add failed: {e}", file=sys.stderr) + try: + link.write_all(tap_fd, p, timeout=1.0) + to_tap += 1 + except (OSError, TimeoutError) as e: + print(f"tap write failed: {e}", file=sys.stderr) elif t == link.MSG_LOG: print(f"[device] {p.decode('utf-8', 'replace')}") elif t == link.MSG_CMD_REPLY: diff --git a/tools/opendrop_install.sh b/tools/opendrop_install.sh new file mode 100755 index 0000000..b701356 --- /dev/null +++ b/tools/opendrop_install.sh @@ -0,0 +1,113 @@ +#!/usr/bin/env bash +# EspDrop - install the iOS-26-compatible patched OpenDrop into a virtualenv. +# +# Upstream OpenDrop (seemoo-lab) no longer works with iOS 26 on its own. The +# patch series from jedbillyb/airdrop-mt7921 (GPLv3) makes it work and is the +# reference we follow. This script reproduces only that project's "OpenDrop +# venv" step: PyPI opendrop 0.13.0 plus all 17 patches, in their fixed order. +# Nothing MT7921- or OWL-specific is done; EspDrop provides awdl0 instead. +# +# tools/opendrop_install.sh # as your normal user, not root +# +# Settings (environment variables): +# OPENDROP_VENV target venv (default: ~/opendrop-venv) +# AIRDROP_MT7921_DIR where the patches live (default: ~/.cache/espdrop/airdrop-mt7921) +# AIRDROP_MT7921_REV pinned patch revision (default: 27edf28, the one we reviewed) +# +# System requirements (package names vary by distro): python3 (>= 3.8) with the +# venv module, git (patches are applied with `git apply`), openssl (OpenDrop +# makes its TLS certificate with it on first start) and libarchive. +# +# SPDX-License-Identifier: GPL-3.0-or-later +# Copyright (C) 2026 EspDrop contributors + +set -euo pipefail + +VENV="${OPENDROP_VENV:-$HOME/opendrop-venv}" +SRC="${AIRDROP_MT7921_DIR:-$HOME/.cache/espdrop/airdrop-mt7921}" +REV="${AIRDROP_MT7921_REV:-27edf28}" +OPENDROP_VERSION=0.13.0 +# Fixed series order from airdrop-mt7921 install.sh / patches/README.md +# ("a series, not a menu": each patch assumes all earlier ones are applied). +PATCHES=(ios26-airdrop recv-window py314-send mdns-repeat find-report tls-keylog + upload-arms ask-confirm mdns-reannounce threaded-server url-items + zeroconf-update-service salvage-truncated salvage-trim + send-multifile send-status send-stall) + +die() { echo "error: $*" >&2; exit 1; } +step() { printf '\n==> %s\n' "$*"; } + +[ "$(id -u)" -eq 0 ] && die "run this as your normal user (OpenDrop keeps its state in ~/.opendrop)" + +step "checking requirements" +command -v python3 >/dev/null || die "python3 not found" +python3 -c 'import sys; sys.exit(0 if sys.version_info >= (3, 8) else 1)' \ + || die "python3 >= 3.8 required, found $(python3 -V 2>&1)" +python3 -c 'import venv, ensurepip' 2>/dev/null \ + || die "python3 venv module missing (e.g. Debian/Ubuntu: apt install python3-venv)" +command -v git >/dev/null || die "git not found (needed for git apply)" +command -v openssl >/dev/null || die "openssl not found (OpenDrop creates its certificate with it)" +if command -v ldconfig >/dev/null && ! ldconfig -p 2>/dev/null | grep -q 'libarchive\.so'; then + echo "warning: libarchive not found by ldconfig; OpenDrop needs it to unpack transfers" >&2 +fi +echo "python: $(python3 -V 2>&1)" + +# `git apply` run inside a git work tree applies paths relative to that repo +# and silently skips everything else (airdrop-mt7921 install.sh). Keep the venv +# outside any repository; GIT_DIR below guards against it as well. +venv_parent="$(dirname "$VENV")" +mkdir -p "$venv_parent" +if git -C "$venv_parent" rev-parse --is-inside-work-tree >/dev/null 2>&1; then + die "$VENV is inside a git repository; choose another OPENDROP_VENV (e.g. ~/opendrop-venv)" +fi + +if [ -d "$VENV" ]; then + SITE=$(echo "$VENV"/lib/python*/site-packages) + if grep -q 'trailing CRLF of the final chunk' "$SITE/opendrop/server.py" 2>/dev/null; then + echo "already installed: $VENV (delete it to reinstall)" + exit 0 + fi + die "$VENV exists but is not a patched OpenDrop; delete it and run again" +fi + +step "fetching the patch series ($REV)" +if [ -d "$SRC/.git" ]; then + git -C "$SRC" fetch -q origin +else + mkdir -p "$(dirname "$SRC")" + git clone -q https://github.com/jedbillyb/airdrop-mt7921.git "$SRC" +fi +git -C "$SRC" -c advice.detachedHead=false checkout -q "$REV" +for p in "${PATCHES[@]}"; do + [ -f "$SRC/patches/opendrop-$p.patch" ] || die "patch $p missing in $SRC at $REV" +done + +step "creating $VENV with opendrop==$OPENDROP_VERSION" +python3 -m venv "$VENV" +# setuptools<81: OpenDrop imports pkg_resources, which setuptools 81 removed. +"$VENV/bin/pip" install -q "opendrop==$OPENDROP_VERSION" 'setuptools<81' \ + || die "pip install failed" +SITE=$(echo "$VENV"/lib/python*/site-packages) + +step "applying ${#PATCHES[@]} patches" +for p in "${PATCHES[@]}"; do + ( cd "$SITE" && GIT_DIR=/nonexistent-espdrop-install git apply \ + "$SRC/patches/opendrop-$p.patch" ) || die "patch $p did not apply" + echo " ok $p" +done + +step "verifying" +# Check that the patches really landed instead of trusting git apply's exit code. +grep -q 'trailing CRLF of the final chunk' "$SITE/opendrop/server.py" \ + || die "patches did not land in $SITE (ios26-airdrop missing)" +grep -q 'application/x-dvzip' "$SITE/opendrop/server.py" \ + || die "patches did not land in $SITE (dvzip support missing)" +"$VENV/bin/python" -m py_compile "$SITE"/opendrop/*.py \ + || die "patched OpenDrop does not compile" +# Dependencies are unpinned upstream; record what we got for bug reports. +"$VENV/bin/pip" freeze > "$VENV/espdrop-freeze.txt" + +echo +echo "done: $VENV/bin/opendrop (patched, rev $REV)" +echo "dependency versions recorded in $VENV/espdrop-freeze.txt" +echo "next: start the bridge (sudo tools/awdl_bridge.py /dev/ttyACM0), then tools/opendrop_receive.sh" diff --git a/tools/opendrop_receive.sh b/tools/opendrop_receive.sh new file mode 100755 index 0000000..2f0d19f --- /dev/null +++ b/tools/opendrop_receive.sh @@ -0,0 +1,128 @@ +#!/usr/bin/env bash +# EspDrop - receive AirDrop files with the patched OpenDrop over awdl0. +# +# Run tools/awdl_bridge.py first (it creates awdl0), then, as your normal user: +# +# tools/opendrop_receive.sh # files land in ~/Downloads/airdrop +# tools/opendrop_receive.sh -n MyPC -d /tmp/in +# +# On the iPhone: AirDrop "Everyone for 10 Minutes", unlock, open the share sheet +# and keep it open, pick the receiver name. Every transfer is accepted +# automatically while this runs (anyone in range can send you files). +# +# The script waits until awdl0 has a usable link-local address (OpenDrop binds +# its mDNS socket once at startup and never retries), and restarts OpenDrop when +# it exits or when the bridge recreates awdl0. +# +# Options: -n NAME receiver name shown on the iPhone (default: EspDrop) +# -i IFACE interface (default: awdl0) +# -d DIR download directory (default: ~/Downloads/airdrop) +# -l FILE log file (default: ~/espdrop-opendrop.log) +# Environment: OPENDROP_VENV (default ~/opendrop-venv), OPENDROP_ANNOUNCE_INTERVAL +# (default 2 s), ESPDROP_TLS_KEYLOG=FILE to write TLS keys for Wireshark. +# +# SPDX-License-Identifier: GPL-3.0-or-later +# Copyright (C) 2026 EspDrop contributors + +set -uo pipefail + +NAME=EspDrop +IFACE=awdl0 +DIR="$HOME/Downloads/airdrop" +LOG="$HOME/espdrop-opendrop.log" +VENV="${OPENDROP_VENV:-$HOME/opendrop-venv}" + +while getopts "n:i:d:l:h" opt; do + case "$opt" in + n) NAME="$OPTARG" ;; + i) IFACE="$OPTARG" ;; + d) DIR="$OPTARG" ;; + l) LOG="$OPTARG" ;; + *) sed -n '2,24p' "$0"; exit 1 ;; + esac +done + +die() { echo "error: $*" >&2; exit 1; } + +[ "$(id -u)" -eq 0 ] && die "run as your normal user, not root (state lives in ~/.opendrop)" +[ -x "$VENV/bin/opendrop" ] || die "no OpenDrop in $VENV - run tools/opendrop_install.sh first" +case "$NAME" in *"'"*|*'"'*) die "the name must not contain quotes (it goes into the TLS certificate)" ;; esac +command -v ip >/dev/null || die "the 'ip' command (iproute2) is required" + +# Our link-local on IFACE once usable (exists, not tentative), else empty. +usable_ll() { + ip -6 -o addr show dev "$IFACE" scope link 2>/dev/null \ + | awk '!/tentative/ { for (i = 1; i <= NF; i++) if ($i == "inet6") { split($(i+1), a, "/"); print a[1]; exit } }' +} + +wait_for_iface() { + local said=0 ll + while :; do + ll=$(usable_ll) + if [ -n "$ll" ]; then + echo "$IFACE ready: $ll%$IFACE" + return 0 + fi + if [ $said -eq 0 ]; then + echo "waiting for $IFACE (is 'sudo tools/awdl_bridge.py /dev/ttyACM0' running?) ..." + said=1 + fi + sleep 0.5 + done +} + +preflight() { + # OpenDrop advertises the first IPv6 address of the interface; it must be + # the link-local, and no other interface may carry the same fe80:: address. + local n + n=$(ip -6 -o addr show dev "$IFACE" | wc -l) + [ "$n" -gt 1 ] && echo "warning: $IFACE has $n IPv6 addresses; OpenDrop expects only the link-local" >&2 + if command -v systemctl >/dev/null && systemctl is-active --quiet avahi-daemon 2>/dev/null; then + echo "note: avahi-daemon is running and also speaks mDNS on $IFACE, costing scarce" >&2 + echo " channel-6 airtime. For the test: sudo systemctl stop avahi-daemon avahi-daemon.socket" >&2 + fi + if command -v ufw >/dev/null && sudo -n ufw status 2>/dev/null | grep -q '^Status: active'; then + echo "note: ufw is active; allow tcp 8771-8772 and udp 5353 on $IFACE, e.g." >&2 + echo " sudo ufw allow in on $IFACE to any port 8771:8772 proto tcp" >&2 + echo " sudo ufw allow in on $IFACE to any port 5353 proto udp" >&2 + fi +} + +mkdir -p "$DIR" || die "cannot create $DIR" +cd "$DIR" || die "cannot enter $DIR" + +pid="" +# Background jobs of a script ignore Ctrl-C, so stop OpenDrop explicitly. +trap 'echo; [ -n "$pid" ] && kill "$pid" 2>/dev/null; echo "stopped."; exit 0' INT TERM + +keylog=() +[ -n "${ESPDROP_TLS_KEYLOG:-}" ] && keylog=(SSLKEYLOGFILE="$ESPDROP_TLS_KEYLOG") + +echo "receiving into $DIR as '$NAME' on $IFACE; log: $LOG" +while :; do + wait_for_iface + preflight + ifindex_before=$(cat "/sys/class/net/$IFACE/ifindex" 2>/dev/null || echo none) + echo "--- $(date '+%F %T') starting opendrop" | tee -a "$LOG" + # Process substitution (not a pipe) so that $! is OpenDrop itself. + env PYTHONUNBUFFERED=1 \ + OPENDROP_ANNOUNCE_INTERVAL="${OPENDROP_ANNOUNCE_INTERVAL:-2}" \ + ${keylog[@]+"${keylog[@]}"} \ + "$VENV/bin/opendrop" -n "$NAME" -d -i "$IFACE" receive \ + > >(tee -a "$LOG") 2>&1 & + pid=$! + # Restart OpenDrop if the bridge recreated the interface: it binds only once. + while kill -0 "$pid" 2>/dev/null; do + sleep 1 + now=$(cat "/sys/class/net/$IFACE/ifindex" 2>/dev/null || echo none) + if [ "$now" != "$ifindex_before" ]; then + echo "--- $IFACE changed (bridge restarted?), restarting opendrop" | tee -a "$LOG" + kill "$pid" 2>/dev/null + break + fi + done + wait "$pid" 2>/dev/null + pid="" + echo "--- $(date '+%F %T') opendrop exited, restarting in 2 s" | tee -a "$LOG" + sleep 2 +done From 3142a5d699ea3e1c1ab95ea01d6041a80a8be0ef Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 18 Oct 2026 16:00:49 +0000 Subject: [PATCH 35/99] fix(m2.4): fold in the review of the AirDrop receive tooling Installer (tools/opendrop_install.sh): - A re-run after a failed or interrupted install was reported as "already installed" (the check looked for one marker of patch 1). The venv now carries a WIP marker while it is built and an OK stamp with a signature of the OpenDrop/Python version and the whole patch series; an incomplete or outdated venv of ours is rebuilt, a foreign one refused. - Each patch must actually change opendrop/*.py, so a silently skipped patch cannot pass as applied. - libarchive check without a pipe (pipefail + SIGPIPE false alarm), re-runs work offline, and a venv inside a git work tree (dotfiles in $HOME) is no longer refused: the GIT_DIR guard already covers that. Receiver (tools/opendrop_receive.sh): - Ctrl-C with stdout on a dead pipe (`| tee` killed by the same Ctrl-C) ended the script before it stopped OpenDrop, leaving it orphaned on awdl0. Stop OpenDrop first, ignore SIGPIPE, trap HUP as well. - ufw check reads /etc/ufw/ufw.conf (no sudo, locale or pipe), plus a firewalld note; the TLS keylog directory is created; -h exits 0; relative paths are made absolute before the cd. Bridge (tools/awdl_bridge.py): - Enable IPv6 on awdl0 even where the host disables it by default, and use the MAC-derived (EUI-64) link-local like Apple's awdl0; warn when no link-local appears; probe on a 0.25 s timer instead of on every wakeup. - Learn neighbours only from frames to us or to a group address, and keep at most 64 (oldest dropped). Firmware: - The AWDL task is woken by a notification when a host frame is queued, so the first frame after an idle queue no longer waits up to 20 ms and misses the end of a present slot. - rx_msdu/rx_data count every delivered MSDU, also from an A-MSDU that fails to parse after its first subframes. - Host tests cover awdl_node_status' master fields and tell "sequence left untouched" apart from a fallback to the static sequence. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01XzgaN4Bv1m8Ka9iqo7QVUN --- .../awdl/test/host/test_awdl_frame.c | 27 ++++-- firmware/spikes/main/awdl_app.c | 22 +++-- firmware/spikes/main/radio.c | 23 +++-- firmware/spikes/main/radio.h | 6 +- tools/awdl_bridge.py | 67 ++++++++++++--- tools/opendrop_install.sh | 85 +++++++++++++------ tools/opendrop_receive.sh | 47 ++++++++-- 7 files changed, 211 insertions(+), 66 deletions(-) diff --git a/firmware/spikes/components/awdl/test/host/test_awdl_frame.c b/firmware/spikes/components/awdl/test/host/test_awdl_frame.c index 08ab834..6307670 100644 --- a/firmware/spikes/components/awdl/test/host/test_awdl_frame.c +++ b/firmware/spikes/components/awdl/test/host/test_awdl_frame.c @@ -590,6 +590,17 @@ static void test_node_chanseq_mirror(void) { iphone_announce(&iph, 100000, ip, &pv); /* chanseq: 6 at 0/8, 44 at 2/10 */ awdl_node_on_rx_action(&n, &pv, -40, 100000); + /* While the master is present, status names it and reports its sequence + * (what the `node` command prints as mseq=). */ + awdl_node_status_t st; + awdl_node_status(&n, &st); + CHECK(st.have_master && memcmp(st.master_addr, ip, 6) == 0, "status: master"); + CHECK(st.master_has_chanseq, "status: master chanseq not reported"); + for (int i = 0; i < (int)AWDL_CHANSEQ_LEN; i++) + CHECK(st.master_chanseq[i] == pv.chanseq[i], "status mseq[%d]=%u expected %u", + i, st.master_chanseq[i], pv.chanseq[i]); + CHECK(st.seq_derived, "status: mirror not derived"); + /* What we now advertise must be channel 6 exactly in the master's channel-6 * slots (0 and 8) and absent (0) everywhere else - never a flat all-6. */ uint8_t buf[1500]; @@ -616,7 +627,6 @@ static void test_node_chanseq_mirror(void) { CHECK(ours.chanseq[i] == expect, "kept chanseq[%d]=%u expected %u", i, ours.chanseq[i], expect); } - awdl_node_status_t st; awdl_node_status(&n, &st); CHECK(st.seq_derived && !st.have_master && !st.master_has_chanseq, "status after age-out: derived=%d master=%d mseq=%d", @@ -669,15 +679,22 @@ static void test_chanseq_empty_intersection(void) { uint8_t me[6] = {0x02, 0, 0, 0, 0, 0x01}; awdl_self_t self; awdl_self_init(&self, me, "x", 6); + /* Start from a mirror rather than the static all-6 of init, so "left + * untouched" cannot be confused with a fallback to the static sequence. */ + uint8_t good[AWDL_CHANSEQ_LEN] = {0}; + good[4] = 6; good[12] = 149; + CHECK(awdl_self_set_chanseq_intersect(&self, good, 6), "mirror must succeed"); /* master with no channel-6 slot at all: report it and leave our sequence - * untouched (here the static all-6 from init), never "present nowhere". */ + * untouched, never "present nowhere". */ uint8_t mseq[AWDL_CHANSEQ_LEN] = {0}; mseq[2] = 44; mseq[10] = 149; CHECK(!awdl_self_set_chanseq_intersect(&self, mseq, 6), "empty intersection must return false"); - for (int i = 0; i < (int)AWDL_CHANSEQ_LEN; i++) - CHECK(self.seq[i][0] == 6, "empty-intersection left slot %d = %u", - i, self.seq[i][0]); + for (int i = 0; i < (int)AWDL_CHANSEQ_LEN; i++) { + uint8_t exp = (i == 4) ? 6 : 0; + CHECK(self.seq[i][0] == exp, "empty-intersection left slot %d = %u expected %u", + i, self.seq[i][0], exp); + } } static void test_chanseq_realistic_slot0(void) { diff --git a/firmware/spikes/main/awdl_app.c b/firmware/spikes/main/awdl_app.c index e5253a3..565f3dd 100644 --- a/firmware/spikes/main/awdl_app.c +++ b/firmware/spikes/main/awdl_app.c @@ -82,7 +82,8 @@ static void on_action(const awdl_peer_view_t *pv, int8_t rssi, uint8_t channel, ev.channel = channel; ev.now_us = now_us; /* drop on overflow rather than block the Wi-Fi task */ - (void)xQueueSend(s_rx_q, &ev, 0); + if (xQueueSend(s_rx_q, &ev, 0) == pdTRUE && s_task) + xTaskNotifyGive(s_task); } /* @@ -142,13 +143,18 @@ static void awdl_task(void *arg) { int64_t next_tick = now + TICK_INTERVAL_US; for (;;) { - rx_event_t ev; - /* With host frames waiting, wake every 2 ms (FREERTOS_HZ=1000) so a - * ~65 ms present slot gets ~30 flush chances instead of ~3. */ + /* Both producers (on_action, awdl_app_tx_ethernet) notify this task, + * so new work is handled at once; a host frame that arrives late in a + * present slot still goes out in that slot. The timeout only paces + * retries: 20 ms for a due PSF/MIF, and 2 ms (FREERTOS_HZ=1000) while + * host frames wait for the next present slot. */ TickType_t wait = (s_tx_q && uxQueueMessagesWaiting(s_tx_q) > 0) ? pdMS_TO_TICKS(2) : pdMS_TO_TICKS(20); if (wait == 0) wait = 1; - if (xQueueReceive(s_rx_q, &ev, wait) == pdTRUE) { + (void)ulTaskNotifyTake(pdTRUE, wait); + + rx_event_t ev; + while (xQueueReceive(s_rx_q, &ev, 0) == pdTRUE) { xSemaphoreTake(s_lock, portMAX_DELAY); awdl_node_on_rx_action(&s_node, &ev.pv, ev.rssi, ev.now_us); xSemaphoreGive(s_lock); @@ -216,6 +222,7 @@ int awdl_app_tx_ethernet(const uint8_t *eth, size_t len, void *ctx) { if (plen) memcpy(td.ip, eth + 14, plen); if (xQueueSend(s_tx_q, &td, 0) != pdTRUE) { s_tx_q_drop++; return -1; } /* full */ s_tx_queued++; + xTaskNotifyGive(s_task); /* flush now if we are inside a present slot */ return 0; } @@ -247,11 +254,12 @@ int awdl_app_start(const uint8_t self_mac[6], const char *name, uint8_t chan) { awdl_node_init(&s_node, self_mac, name, chan, (uint64_t)esp_timer_get_time()); - radio_set_action_handler(on_action, NULL); - + /* Task first: on_action notifies it (and skips that while s_task is unset). */ if (xTaskCreate(awdl_task, "awdl", 6144, NULL, 5, &s_task) != pdPASS) return -1; + radio_set_action_handler(on_action, NULL); + s_started = true; ESP_LOGI(TAG, "started on ch %u", chan); return 0; diff --git a/firmware/spikes/main/radio.c b/firmware/spikes/main/radio.c index 0fbf7a1..c5cdf98 100644 --- a/firmware/spikes/main/radio.c +++ b/firmware/spikes/main/radio.c @@ -61,6 +61,16 @@ static void tx_done_cb(uint8_t ifidx, uint8_t *data, uint16_t *data_len, bool tx } } +/* Forward one reconstructed MSDU to the data handler and count it (ctx: int). + * Counting here instead of from the parser's return value keeps rx_msdu exact + * for an A-MSDU that is cut short: its good leading subframes are delivered + * before the parser reports the error. Wi-Fi task only, like rx_cb. */ +static void deliver_msdu(const uint8_t dst[6], const uint8_t src[6], uint16_t ethertype, + const uint8_t *payload, size_t plen, void *ctx) { + (*(int *)ctx)++; + if (s_data_handler) s_data_handler(dst, src, ethertype, payload, plen, s_data_ctx); +} + /* ---- promiscuous rx callback: filter AWDL BSSID, parse, enqueue ---- */ static void rx_cb(void *buf, wifi_promiscuous_pkt_type_t type) { if (type != WIFI_PKT_MGMT && type != WIFI_PKT_DATA) return; @@ -101,18 +111,17 @@ static void rx_cb(void *buf, wifi_promiscuous_pkt_type_t type) { return; } /* Reconstruct Ethernet per MSDU (one for a plain frame, several for an - * A-MSDU) and hand each to the consumer; radio_data_handler_t has the - * same shape as awdl_msdu_cb_t. */ - int n = awdl_parse_data_msdus(frame, (size_t)len, s_data_handler, s_data_ctx); - if (n > 0) { + * A-MSDU) and hand each to the consumer via deliver_msdu. */ + int delivered = 0; + int r = awdl_parse_data_msdus(frame, (size_t)len, deliver_msdu, &delivered); + if (delivered > 0) { stat_inc(&s_stats.rx_data); if (amsdu) stat_inc(&s_stats.rx_amsdu); portENTER_CRITICAL(&s_stats_mux); - s_stats.rx_msdu += (uint64_t)n; + s_stats.rx_msdu += (uint64_t)delivered; portEXIT_CRITICAL(&s_stats_mux); - } else if (n < 0) { - stat_inc(&s_stats.rx_data_perr); } + if (r < 0) stat_inc(&s_stats.rx_data_perr); } } diff --git a/firmware/spikes/main/radio.h b/firmware/spikes/main/radio.h index 2606e9d..b23e3b0 100644 --- a/firmware/spikes/main/radio.h +++ b/firmware/spikes/main/radio.h @@ -23,11 +23,13 @@ extern "C" { typedef struct { uint64_t rx_action; /* AWDL action frames received (BSSID match) */ - uint64_t rx_data; /* AWDL data frames received (duplicates excluded) */ + uint64_t rx_data; /* AWDL data frames with >= 1 MSDU delivered (no dups) */ uint64_t rx_dup; /* AWDL data frames dropped as 802.11 retransmissions */ uint64_t rx_amsdu; /* of rx_data: frames that were A-MSDU aggregates */ uint64_t rx_msdu; /* MSDUs (L3 packets) handed to the data handler */ - uint64_t rx_data_perr; /* body-carrying AWDL data frames that failed to parse */ + uint64_t rx_data_perr; /* body-carrying AWDL data frames that failed to parse + * (also an A-MSDU cut short after good subframes, + * which then counts in rx_data/rx_msdu as well) */ uint64_t rx_other_bssid;/* frames seen in promiscuous with a different BSSID */ uint64_t rx_parse_err; /* AWDL-looking frames that failed to parse */ uint64_t tx_ok; /* esp_wifi_80211_tx calls that returned ESP_OK */ diff --git a/tools/awdl_bridge.py b/tools/awdl_bridge.py index cc643e1..1245e47 100644 --- a/tools/awdl_bridge.py +++ b/tools/awdl_bridge.py @@ -43,6 +43,7 @@ import struct import subprocess import sys +import time sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) import espdrop_link as link @@ -54,6 +55,7 @@ IFF_NO_PI = 0x1000 MAC_RE = re.compile(r"^([0-9a-fA-F]{2}:){5}[0-9a-fA-F]{2}$") +MAX_LEARNED = 64 # neighbour entries kept; the oldest is dropped beyond that def open_tap(name: str) -> int: @@ -71,14 +73,18 @@ def mac_str(b: bytes) -> str: return ":".join(f"{x:02x}" for x in b) -def learn_neighbor(eth: bytes): +def learn_neighbor(eth: bytes, own_mac=None): """(link-local source address, source MAC) of a received IPv6 frame, or None. Only unicast source MACs and fe80::/10 source addresses qualify; anything else (multicast sender, global/unspecified source, non-IPv6) teaches us nothing. + With own_mac, only frames addressed to us or to a group address count: the + device forwards every AWDL data frame it hears, also those between others. """ if len(eth) < 14 + 40 or eth[12:14] != b"\x86\xdd": return None + if own_mac and not (eth[0] & 0x01) and eth[0:6] != own_mac: + return None # unicast to someone else src_mac = eth[6:12] if src_mac[0] & 0x01: # group address cannot be a neighbour return None @@ -162,19 +168,30 @@ def main() -> int: tap_fd = open_tap(args.dev) os.set_blocking(tap_fd, False) + have_ipv6 = os.path.isdir("/proc/sys/net/ipv6") + if not have_ipv6: + print("warning: IPv6 is disabled in this kernel (ipv6.disable=1 on the " + f"kernel command line?); AirDrop needs it, {args.dev} will carry no IP", + file=sys.stderr) if not args.no_config: if mac: try: ip("link", "set", "dev", args.dev, "address", mac) except subprocess.CalledProcessError as e: print(f"warning: could not set MAC {mac}: {e}", file=sys.stderr) - # Before bringing the link up: no DAD (only we live on this link, and - # an address still "tentative" makes OpenDrop's zeroconf announce into - # nothing), and no router solicitations/RA (no routers on AWDL; every - # frame we send costs scarce channel-6 airtime). - ipv6_sysctl(args.dev, "accept_dad", "0") - ipv6_sysctl(args.dev, "accept_ra", "0") - ipv6_sysctl(args.dev, "router_solicitations", "0") + if have_ipv6: + # Before bringing the link up. IPv6 on even where the host turns it + # off by default (a new interface inherits conf/default), and the + # link-local derived from the MAC (EUI-64) like on Apple's awdl0. + # No DAD (only we live on this link, and an address still + # "tentative" makes OpenDrop's zeroconf announce into nothing), and + # no router solicitations/RA (no routers on AWDL; every frame we + # send costs scarce channel-6 airtime). + ipv6_sysctl(args.dev, "disable_ipv6", "0") + ipv6_sysctl(args.dev, "accept_dad", "0") + ipv6_sysctl(args.dev, "accept_ra", "0") + ipv6_sysctl(args.dev, "router_solicitations", "0") + ipv6_sysctl(args.dev, "addr_gen_mode", "0") ip("link", "set", "dev", args.dev, "mtu", str(args.mtu), "up") print(f"{args.dev} up (mtu {args.mtu}, mac {mac or 'kernel-assigned'}); " @@ -194,8 +211,15 @@ def main() -> int: stdin_buf = bytearray() stdin_open = True read_cap = args.mtu + 64 # one Ethernet frame plus slack - learned = {} # link-local address -> MAC, from received traffic - ready_ll = None # our usable link-local once announced + learned = {} # link-local address -> MAC, oldest first + own_mac = bytes.fromhex(mac.replace(":", "")) if mac else None + # Our usable link-local, polled every 0.25 s until it shows up (or never, + # with --no-config / no IPv6); a hint follows if it takes too long. + probe_ll = have_ipv6 and not args.no_config + ready_ll = None + t_up = time.monotonic() + next_probe = t_up + hinted = False # Single-threaded select pump. Writes use a bounded 1s timeout so one # direction stalling on USB backpressure cannot block the other for long @@ -204,12 +228,19 @@ def main() -> int: watch = [usb_fd, tap_fd] if stdin_open: watch.append(stdin_fd) - r, _, _ = select.select(watch, [], [], None if ready_ll else 0.25) - if not ready_ll and not args.no_config: + r, _, _ = select.select(watch, [], [], 0.25 if probe_ll else None) + if probe_ll and time.monotonic() >= next_probe: + next_probe = time.monotonic() + (1.0 if hinted else 0.25) ready_ll = usable_link_local(args.dev) if ready_ll: + probe_ll = False print(f"[ready] {args.dev} has {ready_ll}%{args.dev} - " f"start OpenDrop now (tools/opendrop_receive.sh)") + elif not hinted and time.monotonic() - t_up > 5: + hinted = True + print(f"warning: {args.dev} still has no usable IPv6 link-local " + f"address; check `ip -6 addr show dev {args.dev}` and " + f"`sysctl net.ipv6.conf.{args.dev}`", file=sys.stderr) if usb_fd in r: try: @@ -228,9 +259,10 @@ def main() -> int: # Learn the sender before handing the frame to the # kernel, so a reply (e.g. a SYN-ACK) already finds # its permanent neighbour entry instead of NDP. - nb = learn_neighbor(p) + nb = learn_neighbor(p, own_mac) if nb and learned.get(nb[0]) != nb[1]: ll, nmac = nb + learned.pop(ll, None) # re-learned: now newest learned[ll] = nmac print(f"[neigh] {ll}%{args.dev} is {mac_str(nmac)}" f" (learned from traffic)") @@ -241,6 +273,15 @@ def main() -> int: "nud", "permanent") except subprocess.CalledProcessError as e: print(f"neigh add failed: {e}", file=sys.stderr) + # Bounded: permanent entries never expire on their + # own (peers rotate their AWDL address over time). + if len(learned) > MAX_LEARNED: + old = next(iter(learned)) + del learned[old] + if not args.no_config: + subprocess.run(["ip", "-6", "neigh", "del", old, + "dev", args.dev], + stderr=subprocess.DEVNULL) try: link.write_all(tap_fd, p, timeout=1.0) to_tap += 1 diff --git a/tools/opendrop_install.sh b/tools/opendrop_install.sh index b701356..adcdc8c 100755 --- a/tools/opendrop_install.sh +++ b/tools/opendrop_install.sh @@ -9,6 +9,10 @@ # # tools/opendrop_install.sh # as your normal user, not root # +# Re-running is safe: an install that is complete and matches the pinned series +# is left alone; one this script left incomplete (an error, Ctrl-C) or built +# from another series/Python version is rebuilt from scratch. +# # Settings (environment variables): # OPENDROP_VENV target venv (default: ~/opendrop-venv) # AIRDROP_MT7921_DIR where the patches live (default: ~/.cache/espdrop/airdrop-mt7921) @@ -33,6 +37,10 @@ PATCHES=(ios26-airdrop recv-window py314-send mdns-repeat find-report tls-keylog upload-arms ask-confirm mdns-reannounce threaded-server url-items zeroconf-update-service salvage-truncated salvage-trim send-multifile send-status send-stall) +# Markers in the venv: WIP while this script builds it, OK (holding the series +# signature) once every patch landed and was verified. +STAMP_WIP=.espdrop-installing +STAMP_OK=.espdrop-installed die() { echo "error: $*" >&2; exit 1; } step() { printf '\n==> %s\n' "$*"; } @@ -47,32 +55,22 @@ python3 -c 'import venv, ensurepip' 2>/dev/null \ || die "python3 venv module missing (e.g. Debian/Ubuntu: apt install python3-venv)" command -v git >/dev/null || die "git not found (needed for git apply)" command -v openssl >/dev/null || die "openssl not found (OpenDrop creates its certificate with it)" -if command -v ldconfig >/dev/null && ! ldconfig -p 2>/dev/null | grep -q 'libarchive\.so'; then - echo "warning: libarchive not found by ldconfig; OpenDrop needs it to unpack transfers" >&2 +if command -v ldconfig >/dev/null; then + # Captured first: `ldconfig -p | grep -q` under pipefail can report a hit as a + # miss when grep exits early and ldconfig dies of SIGPIPE. + libs=$(ldconfig -p 2>/dev/null || true) + case "$libs" in + *libarchive.so*) ;; + *) echo "warning: libarchive not found by ldconfig; OpenDrop needs it to unpack transfers" >&2 ;; + esac fi +pyver=$(python3 -c 'import sys; print("%d.%d" % sys.version_info[:2])') echo "python: $(python3 -V 2>&1)" -# `git apply` run inside a git work tree applies paths relative to that repo -# and silently skips everything else (airdrop-mt7921 install.sh). Keep the venv -# outside any repository; GIT_DIR below guards against it as well. -venv_parent="$(dirname "$VENV")" -mkdir -p "$venv_parent" -if git -C "$venv_parent" rev-parse --is-inside-work-tree >/dev/null 2>&1; then - die "$VENV is inside a git repository; choose another OPENDROP_VENV (e.g. ~/opendrop-venv)" -fi - -if [ -d "$VENV" ]; then - SITE=$(echo "$VENV"/lib/python*/site-packages) - if grep -q 'trailing CRLF of the final chunk' "$SITE/opendrop/server.py" 2>/dev/null; then - echo "already installed: $VENV (delete it to reinstall)" - exit 0 - fi - die "$VENV exists but is not a patched OpenDrop; delete it and run again" -fi - step "fetching the patch series ($REV)" if [ -d "$SRC/.git" ]; then - git -C "$SRC" fetch -q origin + # Fetch only when the pinned revision is not here yet (re-runs work offline). + git -C "$SRC" cat-file -e "$REV^{commit}" 2>/dev/null || git -C "$SRC" fetch -q origin else mkdir -p "$(dirname "$SRC")" git clone -q https://github.com/jedbillyb/airdrop-mt7921.git "$SRC" @@ -81,23 +79,60 @@ git -C "$SRC" -c advice.detachedHead=false checkout -q "$REV" for p in "${PATCHES[@]}"; do [ -f "$SRC/patches/opendrop-$p.patch" ] || die "patch $p missing in $SRC at $REV" done +# What the finished venv must match: OpenDrop and Python version plus the +# names, order and contents of the patches. +sig="opendrop $OPENDROP_VERSION python $pyver series $( + cd "$SRC/patches" && for p in "${PATCHES[@]}"; do sha256sum "opendrop-$p.patch"; done \ + | sha256sum | cut -d' ' -f1)" + +if [ -e "$VENV" ]; then + if [ -f "$VENV/$STAMP_OK" ] && [ "$(cat "$VENV/$STAMP_OK")" = "$sig" ] \ + && [ ! -e "$VENV/$STAMP_WIP" ]; then + echo + echo "already installed and up to date: $VENV" + exit 0 + fi + # Only ever delete a venv this script created: it carries one of our markers, + # which are only written into a directory this script made itself. + if [ -e "$VENV/$STAMP_OK" ] || [ -e "$VENV/$STAMP_WIP" ]; then + step "rebuilding $VENV (previous install incomplete or outdated)" + rm -rf -- "$VENV" + else + die "$VENV exists but was not made by this script; delete it or set OPENDROP_VENV" + fi +fi step "creating $VENV with opendrop==$OPENDROP_VERSION" +mkdir -p "$(dirname "$VENV")" +mkdir "$VENV" # fresh: it did not exist (or was ours and removed) +touch "$VENV/$STAMP_WIP" python3 -m venv "$VENV" # setuptools<81: OpenDrop imports pkg_resources, which setuptools 81 removed. "$VENV/bin/pip" install -q "opendrop==$OPENDROP_VERSION" 'setuptools<81' \ - || die "pip install failed" + || die "pip install failed (run again to retry)" SITE=$(echo "$VENV"/lib/python*/site-packages) +[ -f "$SITE/opendrop/server.py" ] || die "opendrop not found in $SITE" + +# Digest of the package sources; all patches touch only opendrop/*.py. +tree_sum() { + (cd "$SITE" && find opendrop -name '*.py' -type f -print0 | sort -z \ + | xargs -0 sha256sum | sha256sum) +} step "applying ${#PATCHES[@]} patches" for p in "${PATCHES[@]}"; do + before=$(tree_sum) + # GIT_DIR: `git apply` inside a git work tree (e.g. a home directory under + # version control) applies paths relative to that repo and silently skips + # everything else (airdrop-mt7921 install.sh). This makes it a plain patcher. ( cd "$SITE" && GIT_DIR=/nonexistent-espdrop-install git apply \ - "$SRC/patches/opendrop-$p.patch" ) || die "patch $p did not apply" + "$SRC/patches/opendrop-$p.patch" ) || die "patch $p did not apply (run again to retry)" + # Do not trust the exit code alone: the patch must actually have changed code. + [ "$(tree_sum)" != "$before" ] || die "patch $p applied but changed nothing" echo " ok $p" done step "verifying" -# Check that the patches really landed instead of trusting git apply's exit code. grep -q 'trailing CRLF of the final chunk' "$SITE/opendrop/server.py" \ || die "patches did not land in $SITE (ios26-airdrop missing)" grep -q 'application/x-dvzip' "$SITE/opendrop/server.py" \ @@ -106,6 +141,8 @@ grep -q 'application/x-dvzip' "$SITE/opendrop/server.py" \ || die "patched OpenDrop does not compile" # Dependencies are unpinned upstream; record what we got for bug reports. "$VENV/bin/pip" freeze > "$VENV/espdrop-freeze.txt" +printf '%s\n' "$sig" > "$VENV/$STAMP_OK" +rm -f "$VENV/$STAMP_WIP" echo echo "done: $VENV/bin/opendrop (patched, rev $REV)" diff --git a/tools/opendrop_receive.sh b/tools/opendrop_receive.sh index 2f0d19f..5052131 100755 --- a/tools/opendrop_receive.sh +++ b/tools/opendrop_receive.sh @@ -18,6 +18,7 @@ # -i IFACE interface (default: awdl0) # -d DIR download directory (default: ~/Downloads/airdrop) # -l FILE log file (default: ~/espdrop-opendrop.log) +# -h this help # Environment: OPENDROP_VENV (default ~/opendrop-venv), OPENDROP_ANNOUNCE_INTERVAL # (default 2 s), ESPDROP_TLS_KEYLOG=FILE to write TLS keys for Wireshark. # @@ -31,6 +32,10 @@ IFACE=awdl0 DIR="$HOME/Downloads/airdrop" LOG="$HOME/espdrop-opendrop.log" VENV="${OPENDROP_VENV:-$HOME/opendrop-venv}" +KEYLOG="${ESPDROP_TLS_KEYLOG:-}" + +# The comment block above, up to the license line. +usage() { awk 'NR > 1 { if (/^# SPDX/) exit; sub(/^# ?/, ""); print }' "$0"; } while getopts "n:i:d:l:h" opt; do case "$opt" in @@ -38,11 +43,17 @@ while getopts "n:i:d:l:h" opt; do i) IFACE="$OPTARG" ;; d) DIR="$OPTARG" ;; l) LOG="$OPTARG" ;; - *) sed -n '2,24p' "$0"; exit 1 ;; + h) usage; exit 0 ;; + *) usage >&2; exit 2 ;; esac done die() { echo "error: $*" >&2; exit 1; } +# Relative paths would resolve against DIR after the cd below. +abspath() { case "$1" in /*) printf '%s\n' "$1" ;; *) printf '%s\n' "$PWD/$1" ;; esac; } +LOG=$(abspath "$LOG") +VENV=$(abspath "$VENV") +[ -n "$KEYLOG" ] && KEYLOG=$(abspath "$KEYLOG") [ "$(id -u)" -eq 0 ] && die "run as your normal user, not root (state lives in ~/.opendrop)" [ -x "$VENV/bin/opendrop" ] || die "no OpenDrop in $VENV - run tools/opendrop_install.sh first" @@ -81,22 +92,42 @@ preflight() { echo "note: avahi-daemon is running and also speaks mDNS on $IFACE, costing scarce" >&2 echo " channel-6 airtime. For the test: sudo systemctl stop avahi-daemon avahi-daemon.socket" >&2 fi - if command -v ufw >/dev/null && sudo -n ufw status 2>/dev/null | grep -q '^Status: active'; then - echo "note: ufw is active; allow tcp 8771-8772 and udp 5353 on $IFACE, e.g." >&2 + # Firewalls: checked without root and independent of the locale (ufw's own + # config file, firewalld's unit state). + if grep -qs '^ENABLED=yes' /etc/ufw/ufw.conf; then + echo "note: ufw is enabled; allow tcp 8771-8772 and udp 5353 on $IFACE, e.g." >&2 echo " sudo ufw allow in on $IFACE to any port 8771:8772 proto tcp" >&2 echo " sudo ufw allow in on $IFACE to any port 5353 proto udp" >&2 fi + if command -v systemctl >/dev/null && systemctl is-active --quiet firewalld 2>/dev/null; then + echo "note: firewalld is running; allow tcp 8771-8772 and udp 5353 on $IFACE, e.g. for" >&2 + echo " the test: sudo firewall-cmd --zone=trusted --add-interface=$IFACE (until reload)" >&2 + fi } mkdir -p "$DIR" || die "cannot create $DIR" +mkdir -p "$(dirname "$LOG")" || die "cannot create the directory of $LOG" +keylog=() +if [ -n "$KEYLOG" ]; then + # OpenDrop opens it while setting up TLS and would die (and respawn forever) + # if the directory did not exist. + { mkdir -p "$(dirname "$KEYLOG")" && touch "$KEYLOG"; } || die "cannot write $KEYLOG" + keylog=(SSLKEYLOGFILE="$KEYLOG") +fi cd "$DIR" || die "cannot enter $DIR" pid="" -# Background jobs of a script ignore Ctrl-C, so stop OpenDrop explicitly. -trap 'echo; [ -n "$pid" ] && kill "$pid" 2>/dev/null; echo "stopped."; exit 0' INT TERM - -keylog=() -[ -n "${ESPDROP_TLS_KEYLOG:-}" ] && keylog=(SSLKEYLOGFILE="$ESPDROP_TLS_KEYLOG") +# Background jobs of a script ignore Ctrl-C, so stop OpenDrop explicitly, and +# before anything that could fail: with stdout on a pipe that is already gone +# (`| tee` killed by the same Ctrl-C), a write must not end us first. Ignoring +# SIGPIPE turns such writes into plain errors (GNU tee then keeps the log). +stop() { + [ -n "$pid" ] && kill "$pid" 2>/dev/null + echo; echo "stopped." + exit 0 +} +trap '' PIPE +trap stop INT TERM HUP echo "receiving into $DIR as '$NAME' on $IFACE; log: $LOG" while :; do From 22e2925ace0ecf37804184b748c923a7de1f5db5 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 18 Oct 2026 16:38:38 +0000 Subject: [PATCH 36/99] fix(install): offline up-to-date check, keep the old venv until the new one works Follow-up review of the installer: - An up-to-date install was only recognised after fetching the patch repository, so a re-run failed offline once ~/.cache was wiped. With the revision pinned to a commit id the patches cannot change, so the stamp now records version, Python, revision and series names, and is checked before any network or cache access. - A branch or tag given as AIRDROP_MT7921_REV was never fetched again once known locally; such refs are now fetched (tags forced) and resolved on every run. - A venv built by the first version of this script was refused as foreign. It is recognised (espdrop-freeze.txt next to pyvenv.cfg) and rebuilt; the refusal for really foreign directories says what to do. - A rebuild deleted the working venv before the new one existed, leaving nothing when pip failed (PyPI down, interrupted). The old venv is now moved aside and put back on any failure, Ctrl-C included, and after a hard kill on the next run. (Building elsewhere and moving the result is not an option: venvs are not relocatable.) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01XzgaN4Bv1m8Ka9iqo7QVUN --- tools/opendrop_install.sh | 85 +++++++++++++++++++++++++++++---------- 1 file changed, 64 insertions(+), 21 deletions(-) diff --git a/tools/opendrop_install.sh b/tools/opendrop_install.sh index adcdc8c..7067e0b 100755 --- a/tools/opendrop_install.sh +++ b/tools/opendrop_install.sh @@ -10,8 +10,9 @@ # tools/opendrop_install.sh # as your normal user, not root # # Re-running is safe: an install that is complete and matches the pinned series -# is left alone; one this script left incomplete (an error, Ctrl-C) or built -# from another series/Python version is rebuilt from scratch. +# is left alone (no network needed); one this script left incomplete (an error, +# Ctrl-C) is rebuilt from scratch; one built from another series or Python +# version is rebuilt too, and stays in place until the new one has worked. # # Settings (environment variables): # OPENDROP_VENV target venv (default: ~/opendrop-venv) @@ -67,10 +68,37 @@ fi pyver=$(python3 -c 'import sys; print("%d.%d" % sys.version_info[:2])') echo "python: $(python3 -V 2>&1)" +# A venv this script made carries one of our markers (only ever written into a +# directory this script created itself) or, from the first version of the +# script, espdrop-freeze.txt next to pyvenv.cfg. Complete: no WIP marker left. +ours() { [ -e "$1/$STAMP_OK" ] || [ -e "$1/$STAMP_WIP" ] \ + || { [ -f "$1/pyvenv.cfg" ] && [ -f "$1/espdrop-freeze.txt" ]; }; } +complete() { [ ! -e "$1/$STAMP_WIP" ] && { [ -e "$1/$STAMP_OK" ] || [ -f "$1/espdrop-freeze.txt" ]; }; } +# What a finished venv must match: OpenDrop and Python version, the patch +# revision ($1) and the series (names and order). +series_sig() { echo "opendrop $OPENDROP_VERSION python $pyver mt7921 $1 patches ${PATCHES[*]}"; } +up_to_date() { complete "$VENV" && [ -f "$VENV/$STAMP_OK" ] && [ "$(cat "$VENV/$STAMP_OK")" = "$1" ]; } + +# A rebuild that was killed hard leaves the previous install aside; put it back. +OLD="$VENV.espdrop-old" +if [ -e "$OLD" ] && ours "$OLD" && complete "$OLD" \ + && { [ ! -e "$VENV" ] || { ours "$VENV" && ! complete "$VENV"; }; }; then + rm -rf -- "$VENV" + mv -- "$OLD" "$VENV" +fi + +# A commit id pins the patches for good, so an up-to-date install is recognised +# without the network or the patch cache. A branch or tag is fetched each time. +case "$REV" in ''|*[!0-9a-f]*) fixed_rev=0 ;; *) fixed_rev=1 ;; esac +if [ $fixed_rev -eq 1 ] && up_to_date "$(series_sig "$REV")"; then + echo; echo "already installed and up to date: $VENV"; exit 0 +fi + step "fetching the patch series ($REV)" if [ -d "$SRC/.git" ]; then - # Fetch only when the pinned revision is not here yet (re-runs work offline). - git -C "$SRC" cat-file -e "$REV^{commit}" 2>/dev/null || git -C "$SRC" fetch -q origin + if [ $fixed_rev -eq 0 ] || ! git -C "$SRC" cat-file -e "$REV^{commit}" 2>/dev/null; then + git -C "$SRC" fetch -q --tags --force origin + fi else mkdir -p "$(dirname "$SRC")" git clone -q https://github.com/jedbillyb/airdrop-mt7921.git "$SRC" @@ -79,32 +107,45 @@ git -C "$SRC" -c advice.detachedHead=false checkout -q "$REV" for p in "${PATCHES[@]}"; do [ -f "$SRC/patches/opendrop-$p.patch" ] || die "patch $p missing in $SRC at $REV" done -# What the finished venv must match: OpenDrop and Python version plus the -# names, order and contents of the patches. -sig="opendrop $OPENDROP_VERSION python $pyver series $( - cd "$SRC/patches" && for p in "${PATCHES[@]}"; do sha256sum "opendrop-$p.patch"; done \ - | sha256sum | cut -d' ' -f1)" +if [ $fixed_rev -eq 1 ]; then + sig=$(series_sig "$REV") +else + rev_id=$(git -C "$SRC" rev-parse --short=12 HEAD) + echo "$REV is $rev_id" + sig=$(series_sig "$rev_id") + if up_to_date "$sig"; then echo; echo "already installed and up to date: $VENV"; exit 0; fi +fi +# A working but outdated install stays aside until the new one is verified, and +# comes back if anything fails or is interrupted on the way. (Building the new +# one elsewhere and moving it would not work: a venv is not relocatable.) +restore=0 +finish() { + if [ "$restore" -eq 1 ]; then + rm -rf -- "$VENV" && mv -- "$OLD" "$VENV" && echo "kept the previous install in $VENV" >&2 + fi +} +trap finish EXIT if [ -e "$VENV" ]; then - if [ -f "$VENV/$STAMP_OK" ] && [ "$(cat "$VENV/$STAMP_OK")" = "$sig" ] \ - && [ ! -e "$VENV/$STAMP_WIP" ]; then - echo - echo "already installed and up to date: $VENV" - exit 0 + ours "$VENV" || die "$VENV exists but is not an OpenDrop install made by this script;" \ + "delete it to install there, or set OPENDROP_VENV" + if [ -e "$OLD" ]; then + ours "$OLD" || die "$OLD is in the way; move it elsewhere" + rm -rf -- "$OLD" fi - # Only ever delete a venv this script created: it carries one of our markers, - # which are only written into a directory this script made itself. - if [ -e "$VENV/$STAMP_OK" ] || [ -e "$VENV/$STAMP_WIP" ]; then - step "rebuilding $VENV (previous install incomplete or outdated)" - rm -rf -- "$VENV" + if complete "$VENV"; then + step "rebuilding $VENV (outdated; it stays usable until the new one works)" + mv -- "$VENV" "$OLD" + restore=1 else - die "$VENV exists but was not made by this script; delete it or set OPENDROP_VENV" + step "rebuilding $VENV (previous install incomplete)" + rm -rf -- "$VENV" fi fi step "creating $VENV with opendrop==$OPENDROP_VERSION" mkdir -p "$(dirname "$VENV")" -mkdir "$VENV" # fresh: it did not exist (or was ours and removed) +mkdir "$VENV" # fresh: it did not exist (or was ours and set aside) touch "$VENV/$STAMP_WIP" python3 -m venv "$VENV" # setuptools<81: OpenDrop imports pkg_resources, which setuptools 81 removed. @@ -143,6 +184,8 @@ grep -q 'application/x-dvzip' "$SITE/opendrop/server.py" \ "$VENV/bin/pip" freeze > "$VENV/espdrop-freeze.txt" printf '%s\n' "$sig" > "$VENV/$STAMP_OK" rm -f "$VENV/$STAMP_WIP" +restore=0 +if [ -e "$OLD" ] && ours "$OLD"; then rm -rf -- "$OLD"; fi echo echo "done: $VENV/bin/opendrop (patched, rev $REV)" From fe5a567fade7d0a673ac2900eaf38d63785ec1af Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Oct 2026 11:47:08 +0000 Subject: [PATCH 37/99] feat(c5): build the firmware for the ESP32-C5 and make the 5 GHz spikes runnable Step 1 of the C5 port: the same firmware for esp32c3 and esp32c5, still on AWDL channel 6 by default, plus what is needed to measure 5 GHz on the C5 (Waveshare ESP32-C5-Zero: USB-C is the native USB-Serial/JTAG, so the bridge works unchanged). Radio: - One apply_rf() after esp_wifi_start, used by init and the MAC override: power save off, country from Kconfig (DE on 5 GHz chips, driver default on the C3), on dual-band chips band mode AUTO (also overrides a stale NVS value) and 20 MHz via set_bandwidths, tx-done hook, channel and TX power; errors are checked instead of ignored. A failed MAC override no longer leaves promiscuous mode off. - radio_channel_supported(): 1..14, on the C5 also 36..48 and 149..177; DFS channels are refused. TX power ceiling with an 11 dBm cap on 149+ (EU short-range-device band, 25 mW EIRP), lowered before entering it. - rx_cb goes by the frame-control type bits and counts disagreements with the driver's label; action frames get a CRC-32 check of the stripped FCS (fcs_ok/fcs_bad), to confirm the sig_len layout on the new chip. Console and bridge: - info shows chip revision, country, band mode and TX power; chan takes 5 GHz channels; chanlat [n] [c1,c2,...] reports first hop, same-band and cross-band hops (mean/min/p50/p99/max) and failures, with our own AWDL TX paused; txpwr; stats shows the data, unicast-ACK and check counters. - Bridge `chan [n]` moves the AWDL node to another channel at runtime, e.g. 44 or 149 on the C5 with the full data path. - AWDL name, boot channel and country come from Kconfig; the node keeps its name across `mac`/`chan` (it was reset to "espdrop-c3"). Build and docs: sdkconfig.defaults.esp32c5 (no 802.15.4, so no software coexistence in the timing measurements), per-chip build dirs ignored, and SPIKES.md/README with per-chip build commands and the C5 test plan. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01XzgaN4Bv1m8Ka9iqo7QVUN --- .gitignore | 3 +- docs/SPIKES.md | 71 +++++++-- docs/UEBERBLICK.md | 2 +- firmware/README.md | 11 +- firmware/spikes/CMakeLists.txt | 2 +- firmware/spikes/main/Kconfig.projbuild | 29 ++++ firmware/spikes/main/app_main.c | 29 ++-- firmware/spikes/main/awdl_app.c | 19 ++- firmware/spikes/main/awdl_app.h | 9 +- firmware/spikes/main/bridge.c | 21 +++ firmware/spikes/main/console.c | 175 ++++++++++++++++++--- firmware/spikes/main/radio.c | 123 +++++++++++++-- firmware/spikes/main/radio.h | 32 +++- firmware/spikes/sdkconfig.bridge.defaults | 5 +- firmware/spikes/sdkconfig.defaults.esp32c5 | 7 + tools/awdl_bridge.py | 4 +- 16 files changed, 463 insertions(+), 79 deletions(-) create mode 100644 firmware/spikes/sdkconfig.defaults.esp32c5 diff --git a/.gitignore b/.gitignore index 459de94..3f3a71c 100644 --- a/.gitignore +++ b/.gitignore @@ -38,5 +38,6 @@ logs/ test_awdl_frame test_awdl_cobs -# Separate build dir for the bridge-mode firmware +# Separate build dirs for the bridge-mode / per-chip firmware build_bridge/ +firmware/**/build_*/ diff --git a/docs/SPIKES.md b/docs/SPIKES.md index 2210e63..c1f6ceb 100644 --- a/docs/SPIKES.md +++ b/docs/SPIKES.md @@ -1,39 +1,57 @@ -# M0 – Hardware-Spikes auf dem ESP32-C3 +# M0 – Hardware-Spikes auf dem ESP32-C3 (und ESP32-C5) Ziel von M0: herausfinden, ob der ESP32-C3 das kann, was AWDL braucht, **bevor** der ganze Stack portiert wird. Der C3 funkt nur auf 2,4 GHz, also läuft hier -alles auf **Kanal 6** (dem 2,4-GHz-Sozialkanal von AWDL). Für den Alltag wird -später vermutlich ein C5 (5 GHz, Kanäle 44/149) nötig – siehe `UEBERBLICK.md`. +alles auf **Kanal 6** (dem 2,4-GHz-Sozialkanal von AWDL). Der ESP32-C5 kann +zusätzlich 5 GHz (AWDL-Kanäle 44/149); dieselbe Firmware läuft auf beiden, die +C5-spezifischen Tests stehen unten im Abschnitt „ESP32-C5“. Die Firmware dazu liegt in `firmware/spikes/`. Sie bringt WLAN hoch und bietet eine Konsole über den eingebauten USB-Serial/JTAG-Port. ## Bauen und Flashen +Ein eigenes Build-Verzeichnis pro Chip, Zielchip immer mit angeben +(**nicht** `idf.py set-target` verwenden, das verwirft die Build-Einstellungen): + ```sh # ESP-IDF >= 5.5 (getestet mit 6.0.3), einmalig: . $IDF_PATH/export.sh cd firmware/spikes -idf.py set-target esp32c3 -idf.py build -idf.py -p /dev/ttyACM0 flash monitor # C3: USB-Serial/JTAG meldet sich als ttyACM* + +# ESP32-C3 +idf.py -B build_c3 -DSDKCONFIG=build_c3/sdkconfig -DIDF_TARGET=esp32c3 \ + -p /dev/ttyACM0 build flash monitor + +# ESP32-C5 (z. B. Waveshare ESP32-C5-Zero: USB-C = nativer USB-Serial/JTAG). +# Beim ersten Mal mit erase-flash, damit keine alten WLAN-Einstellungen der +# Werks-Firmware im NVS liegen bleiben. +idf.py -B build_c5 -DSDKCONFIG=build_c5/sdkconfig -DIDF_TARGET=esp32c5 \ + -p /dev/ttyACM0 build erase-flash flash monitor ``` +Die Bridge-Firmware (M2) entsprechend mit +`-DSDKCONFIG_DEFAULTS="sdkconfig.defaults;sdkconfig.bridge.defaults"` und einem +eigenen Verzeichnis, z. B. `build_c5_bridge`. + In `monitor` (oder jedem seriellen Terminal) `help` eingeben. Beenden mit `Ctrl-]`. +Beide Chips melden sich als `ttyACM*` mit derselben USB-ID (303a:1001); stecken +beide, die Namen unter `/dev/serial/by-id/` verwenden. ## Konsolenbefehle | Befehl | Zweck | |---|---| -| `info` | eigene MAC, Kanal, Promiscuous-Status | +| `info` | Chip und Revision, eigene MAC, Kanal, Land, Band-Modus (C5), Sendeleistung, Promiscuous-Status | | `mac ` | Stations-MAC setzen (für den ACK-Test) | -| `chan <1..13>` | Kanal wechseln | +| `chan ` | Kanal wechseln: 1..14, auf dem C5 zusätzlich 36..48 und 149..177 | | `rx on` / `rx off` | Promiscuous-Empfang (nur AWDL-BSSID) an/aus | | `tx psf [n]` / `tx mif [n]` | n Action-Frames senden | | `txdata ` | einen AWDL-Datenframe an die AWDL-Broadcast-MAC senden | | `peers` | gesammelte Peer-Ereignisse ausgeben (Name, RSSI, Kanalsequenz, Zeitstempel) | -| `stats` | RX/TX-Zähler | +| `stats` | RX/TX-Zähler, inkl. FCS- und Frame-Typ-Prüfung | | `reset` | Zähler nullen | -| `chanlat [n]` | Latenz von `esp_wifi_set_channel` über n Wechsel messen | +| `chanlat [n] [c1,c2,...]` | Latenz von `esp_wifi_set_channel` über n Wechsel durch die Kanalliste messen (Standard C3: `1,6,11`, C5: `6,44,149`); getrennt nach Wechseln im selben Band und zwischen den Bändern | +| `txpwr [q]` | Obergrenze der Sendeleistung anzeigen/setzen (0,25-dBm-Schritte, 80 = 20 dBm) | | `usbbench [kib]` | Datenstrom für die USB-Durchsatzmessung auf dem Host | Zum Mitschneiden brauchst du **zusätzlich** deine Linux-WLAN-Karte im @@ -106,6 +124,39 @@ der C3 ist nicht offloaded, hoffentlich schneller. **Erfolg:** Deutlich über dem AWDL-Realdurchsatz (50–250 kB/s), damit USB nicht zum Flaschenhals wird. +## ESP32-C5: Tests für 5 GHz + +Gleiche Firmware, gleicher Ablauf wie oben. Der C5 startet auf Kanal 6 und kann +mit `chan` auf 5 GHz wechseln. Die Firmware setzt Land `DE` (menuconfig → +EspDrop) und begrenzt die Sendeleistung ab Kanal 149 auf 11 dBm, weil +5725–5875 MHz in der EU nur 25 mW EIRP erlaubt. Kanal 44 ist nur für Innenräume +zugelassen. Die DFS-Kanäle 52–144 lehnt die Firmware ab (Senden bräuchte +Radarerkennung). + +1. **Chip und USB.** `info` zeigt `chip: esp32c5 rev v1.0` (oder v1.2). ESP-IDF + 6.0.3 unterstützt v1.0 und v1.2; die Vorserien-Revision v0.1 startet nicht. +2. **Spikes 1–3 auf Kanal 6** wie beim C3. Zusätzlich in `stats`: + `fcs_ok` steigt mit `action`, `fcs_bad` und `type_mismatch` bleiben 0. + Falls nicht, stimmt auf dem C5 die Länge im Promiscuous-Puffer nicht – bitte melden. +3. **Welchen 5-GHz-Kanal nutzt dein iPhone?** Geht schon auf Kanal 6: `rx on`, + am iPhone das Teilen-Menü öffnen, `peers`. In der Kanalsequenz steht 44 oder 149. +4. **Empfang auf 5 GHz.** `chan 44` (bzw. 149), `rx on`, Teilen-Menü offen, + `peers` und `stats`: Das iPhone muss auftauchen, `action` steigen. +5. **Senden auf 5 GHz.** `chan 44`, `reset`, `tx mif 20`, `stats` + (`tx ok=20`, `tx_done ok` steigt). Mitschnitt mit einer 5-GHz-fähigen Karte: + `sudo iw reg set DE`, Monitor-Mode wie oben, `sudo iw dev wlan0 set channel 44`. + Wireshark sollte saubere AWDL-Frames mit 6 Mbit/s zeigen. +6. **Kanalwechsel (entscheidend für 5 GHz).** + `chanlat 200 6,6`, `chanlat 200 6,44`, `chanlat 200 6,149`, + `chanlat 200 44,149`. Ein AWDL-Slot dauert 64 TU (≈ 65 ms); ein Wechsel um + 2 ms ist gut, unter 5 ms brauchbar (MT7921: 8,5 ms). Alle Zeilen der Ausgabe + aufschreiben, auch den ersten Wechsel und eventuelle Fehler. +7. **USB-Durchsatz** wie Spike 5. +8. **Optional: Bridge auf 5 GHz.** Bridge-Firmware für den C5 flashen, im + `awdl_bridge.py`-Terminal `chan 44` (bzw. den Kanal aus Test 3) eingeben, + dann wie in M2.4 pingen bzw. AirDrop testen. Der Knoten bleibt dabei auf + einem Kanal und übernimmt die 5-GHz-Slots des Masters, ganz ohne Kanalwechsel. + ## Danach Ergebnisse (auch Fehlschläge) in `docs/UEBERBLICK.md` unter „Offene Fragen" diff --git a/docs/UEBERBLICK.md b/docs/UEBERBLICK.md index dd56360..8a9b0e6 100644 --- a/docs/UEBERBLICK.md +++ b/docs/UEBERBLICK.md @@ -229,7 +229,7 @@ docs/ | Kanalwechsel-Latenz / Timing-Jitter auf FreeRTOS | verpasste Availability Windows | `esp_timer`, Tick 1000 Hz, Wechsel vorziehen | | Wi-Fi/BLE-Koexistenz auf einem Radio | verpasste AWs während BLE aktiv ist | BLE nur zur Discovery | | Apple ändert AirDrop/AWDL (iOS 27 …) | Protokollbruch | App-Logik auf dem Host halten (leicht aktualisierbar) | -| Regulatorik 5 GHz (149 in DE?) | Kanalwahl | 44 als EU-Kanal prüfen | +| Regulatorik 5 GHz: 149 in DE nur als SRD mit 25 mW EIRP, 44 nur in Innenräumen | Kanalwahl, Reichweite | Firmware begrenzt 149+ auf 11 dBm; welchen Kanal deutsche iPhones nutzen, misst der C5-Test (SPIKES.md) | Langfristig ließe sich auch Wi-Fi Aware (NAN) nutzen: ESP-IDF unterstützt NAN, und Apple öffnet es für Apps. Das wäre aber kein AirDrop, sondern bräuchte eine eigene App auf dem diff --git a/firmware/README.md b/firmware/README.md index d55040c..a1e87f7 100644 --- a/firmware/README.md +++ b/firmware/README.md @@ -5,7 +5,7 @@ ESP-IDF firmware for the ESP32 that acts as the AWDL radio for EspDrop. ## Layout ``` -spikes/ M0 hardware spikes (ESP32-C3): prove the radio +spikes/ hardware spikes + AWDL node (ESP32-C3, ESP32-C5) components/awdl/ platform-independent AWDL frame build/parse include/ awdl_frame.h, awdl_wire.h awdl_frame.c @@ -23,11 +23,14 @@ The `main/` code is the only part that calls `esp_wifi`. ```sh . $IDF_PATH/export.sh # ESP-IDF >= 5.5, tested with 6.0.3 cd spikes -idf.py set-target esp32c3 -idf.py build -idf.py -p /dev/ttyACM0 flash monitor +# one build dir per chip, target always given (do not use `idf.py set-target`) +idf.py -B build_c3 -DSDKCONFIG=build_c3/sdkconfig -DIDF_TARGET=esp32c3 -p /dev/ttyACM0 build flash monitor +idf.py -B build_c5 -DSDKCONFIG=build_c5/sdkconfig -DIDF_TARGET=esp32c5 -p /dev/ttyACM0 build flash monitor +# bridge firmware: add -DSDKCONFIG_DEFAULTS="sdkconfig.defaults;sdkconfig.bridge.defaults" ``` +`sdkconfig.defaults.esp32c5` is picked up automatically for the C5. + ## Host unit test ```sh diff --git a/firmware/spikes/CMakeLists.txt b/firmware/spikes/CMakeLists.txt index e247392..2e0da2d 100644 --- a/firmware/spikes/CMakeLists.txt +++ b/firmware/spikes/CMakeLists.txt @@ -1,4 +1,4 @@ -# EspDrop AWDL spikes (ESP32-C3). ESP-IDF >= 5.5 / 6.0. +# EspDrop AWDL spikes (ESP32-C3, ESP32-C5). ESP-IDF >= 5.5 / 6.0. cmake_minimum_required(VERSION 3.16) include($ENV{IDF_PATH}/tools/cmake/project.cmake) project(espdrop_spikes) diff --git a/firmware/spikes/main/Kconfig.projbuild b/firmware/spikes/main/Kconfig.projbuild index 2bf70b2..08dde2c 100644 --- a/firmware/spikes/main/Kconfig.projbuild +++ b/firmware/spikes/main/Kconfig.projbuild @@ -9,4 +9,33 @@ config ESPDROP_BRIDGE_MODE console. Use this build to run the host-side awdl0 bridge and the M2.0 echo test. Leave it off for the M0/M1 console workflow (tx/rx/sync/...). +config ESPDROP_AWDL_NAME + string "AWDL host name we advertise" + default "espdrop-c5" if IDF_TARGET_ESP32C5 + default "espdrop-c3" + help + Name carried in our AWDL frames (ARPA TLV). Shows up in sniffer captures + and in the peer lists of other devices; it is not the AirDrop name. + +config ESPDROP_AWDL_CHANNEL + int "AWDL channel at boot" + range 1 177 + default 6 + help + Channel the AWDL node starts on. 6 is the 2.4 GHz social channel and the + only choice on the ESP32-C3. A 5 GHz chip (ESP32-C5) also accepts 44 or + 149 for single-channel experiments. Both the console and the bridge can + change it at runtime with `chan`. + +config ESPDROP_WIFI_COUNTRY + string "Wi-Fi country code (empty = driver default)" + default "DE" if SOC_WIFI_SUPPORT_5G + default "" + help + Two-letter country code passed to esp_wifi_set_country_code() at start. + Empty keeps the driver default ("01", world-safe). On a 5 GHz chip a real + country fixes which 5 GHz channels and power limits the driver applies. + Independently of this, the firmware caps the TX power on channels 149 + and up (EU short-range-device band, 25 mW EIRP). + endmenu diff --git a/firmware/spikes/main/app_main.c b/firmware/spikes/main/app_main.c index bc3fe67..7a20485 100644 --- a/firmware/spikes/main/app_main.c +++ b/firmware/spikes/main/app_main.c @@ -1,5 +1,5 @@ /* - * EspDrop - AWDL firmware for the ESP32-C3. + * EspDrop - AWDL firmware for the ESP32-C3 and ESP32-C5. * * Two build modes (menuconfig -> EspDrop -> Bridge mode): * - Console mode (default): brings up Wi-Fi + the AWDL node and an interactive @@ -19,12 +19,22 @@ #include "esp_log.h" #include "sdkconfig.h" -#define DEFAULT_CHANNEL 6 /* AWDL 2.4 GHz social channel, the only one on the C3 */ +#include "radio.h" + +/* AWDL channel at boot (menuconfig, default 6 = the 2.4 GHz social channel and + * the only one on the C3); falls back to 6 if this chip cannot use it. */ +static uint8_t boot_channel(void) { + uint8_t ch = (uint8_t)CONFIG_ESPDROP_AWDL_CHANNEL; + if (!radio_channel_supported(ch)) { + ESP_LOGW("espdrop", "channel %u not supported on this chip, using 6", ch); + ch = 6; + } + return ch; +} #if CONFIG_ESPDROP_BRIDGE_MODE #include "bridge.h" -#include "radio.h" #include "awdl_app.h" /* AWDL peer appeared/left -> tell the host so it can manage the awdl0 neighbour @@ -40,7 +50,8 @@ void app_main(void) { * non-maskable PHY/ROM prints) go out at boot, before the host opens the * port. bridge_start() then silences ESP_LOG and takes over the USB endpoint, * so steady-state traffic is clean COBS. */ - int err = radio_init(DEFAULT_CHANNEL); + uint8_t chan = boot_channel(); + int err = radio_init(chan); if (err != ESP_OK) { ESP_LOGE("espdrop", "radio_init failed: 0x%x", err); return; @@ -48,7 +59,7 @@ void app_main(void) { uint8_t mac[6]; radio_get_self_mac(mac); - if (awdl_app_start(mac, "espdrop-c3", DEFAULT_CHANNEL) != 0) { + if (awdl_app_start(mac, CONFIG_ESPDROP_AWDL_NAME, chan) != 0) { ESP_LOGE("espdrop", "awdl_app_start failed"); return; } @@ -65,13 +76,12 @@ void app_main(void) { awdl_app_set_peer_cb(on_peer_evt, NULL); /* host builds the neighbour table */ if (radio_promisc(true) != ESP_OK) bridge_log("warning: promiscuous RX did not enable"); - bridge_log("awdl rx/tx up on ch %u (M2.3)", DEFAULT_CHANNEL); + bridge_log("awdl rx/tx up on %s ch %u", CONFIG_IDF_TARGET, chan); } #else /* console mode */ #include "esp_console.h" -#include "radio.h" #include "awdl_app.h" void console_register_commands(void); @@ -79,7 +89,8 @@ void console_register_commands(void); void app_main(void) { ESP_LOGI("espdrop", "EspDrop AWDL (console mode) starting"); - int err = radio_init(DEFAULT_CHANNEL); + uint8_t chan = boot_channel(); + int err = radio_init(chan); if (err != ESP_OK) { ESP_LOGE("espdrop", "radio_init failed: 0x%x", err); return; @@ -87,7 +98,7 @@ void app_main(void) { uint8_t mac[6]; radio_get_self_mac(mac); - if (awdl_app_start(mac, "espdrop-c3", DEFAULT_CHANNEL) != 0) + if (awdl_app_start(mac, CONFIG_ESPDROP_AWDL_NAME, chan) != 0) ESP_LOGE("espdrop", "awdl_app_start failed"); esp_console_repl_t *repl = NULL; diff --git a/firmware/spikes/main/awdl_app.c b/firmware/spikes/main/awdl_app.c index 565f3dd..c84b5bc 100644 --- a/firmware/spikes/main/awdl_app.c +++ b/firmware/spikes/main/awdl_app.c @@ -60,6 +60,8 @@ static QueueHandle_t s_rx_q; static QueueHandle_t s_tx_q; /* tx_data_t: bridge RX task -> awdl task */ static TaskHandle_t s_task; static bool s_started; +static char s_name[AWDL_NAME_MAX + 1]; /* kept for awdl_app_set_self */ +static volatile bool s_tx_paused; /* awdl_app_pause_tx: no PSF/MIF/data TX */ /* Peer-change notification. Shadow set is touched only by the AWDL task. */ static awdl_peer_cb_t s_peer_cb; @@ -164,9 +166,9 @@ static void awdl_task(void *arg) { /* Only advance the timer once the frame actually went out; if the * current slot is one we advertise as absent, retry on the next loop * (~20 ms) until we reach an advertised-present slot. */ - if (now >= next_psf && send_frame(AWDL_SUBTYPE_PSF, false)) + if (!s_tx_paused && now >= next_psf && send_frame(AWDL_SUBTYPE_PSF, false)) next_psf = now + PSF_INTERVAL_US; - if (now >= next_mif && send_frame(AWDL_SUBTYPE_MIF, false)) + if (!s_tx_paused && now >= next_mif && send_frame(AWDL_SUBTYPE_MIF, false)) next_mif = now + MIF_INTERVAL_US; if (now >= next_tick) { uint8_t cur[AWDL_MAX_PEERS][6]; @@ -184,7 +186,7 @@ static void awdl_task(void *arg) { /* Flush queued host data frames, but only during a slot we advertise as * present (= the master's ch6 slots), so our data TX lands when the peer * is on our channel. Build+send under the same lock that guards self. */ - if (s_tx_q && uxQueueMessagesWaiting(s_tx_q) > 0) { + if (!s_tx_paused && s_tx_q && uxQueueMessagesWaiting(s_tx_q) > 0) { static uint8_t txbuf[1600]; tx_data_t td; now = esp_timer_get_time(); @@ -252,7 +254,8 @@ int awdl_app_start(const uint8_t self_mac[6], const char *name, uint8_t chan) { s_tx_q = xQueueCreate(TX_Q_LEN, sizeof(tx_data_t)); if (!s_lock || !s_rx_q || !s_tx_q) return -1; - awdl_node_init(&s_node, self_mac, name, chan, (uint64_t)esp_timer_get_time()); + if (name) strncpy(s_name, name, sizeof(s_name) - 1); + awdl_node_init(&s_node, self_mac, s_name, chan, (uint64_t)esp_timer_get_time()); /* Task first: on_action notifies it (and skips that while s_task is unset). */ if (xTaskCreate(awdl_task, "awdl", 6144, NULL, 5, &s_task) != pdPASS) @@ -268,8 +271,7 @@ int awdl_app_start(const uint8_t self_mac[6], const char *name, uint8_t chan) { void awdl_app_set_self(const uint8_t self_mac[6], uint8_t chan) { if (!s_started) return; xSemaphoreTake(s_lock, portMAX_DELAY); - awdl_node_init(&s_node, self_mac, "espdrop-c3", chan, - (uint64_t)esp_timer_get_time()); + awdl_node_init(&s_node, self_mac, s_name, chan, (uint64_t)esp_timer_get_time()); xSemaphoreGive(s_lock); } @@ -304,6 +306,11 @@ int awdl_app_format_peers(char *buf, size_t cap) { return off; } +void awdl_app_pause_tx(bool paused) { + s_tx_paused = paused; + if (!paused && s_task) xTaskNotifyGive(s_task); /* catch up on due frames */ +} + void awdl_app_tx(uint8_t subtype) { if (!s_started) return; send_frame(subtype, true); /* console `tx`: send now regardless of slot */ diff --git a/firmware/spikes/main/awdl_app.h b/firmware/spikes/main/awdl_app.h index f5e015c..fb306e8 100644 --- a/firmware/spikes/main/awdl_app.h +++ b/firmware/spikes/main/awdl_app.h @@ -24,8 +24,8 @@ extern "C" { #endif /* - * Start the AWDL task on the given self MAC, name and 2.4 GHz channel. Registers - * the radio action handler. Call once, after radio_init(). Returns 0 on success. + * Start the AWDL task on the given self MAC, name and channel. Registers the + * radio action handler. Call once, after radio_init(). Returns 0 on success. */ int awdl_app_start(const uint8_t self_mac[6], const char *name, uint8_t chan); @@ -42,6 +42,11 @@ int awdl_app_format_peers(char *buf, size_t cap); /* Build and transmit one of our own PSF/MIF frames immediately (console `tx`). */ void awdl_app_tx(uint8_t subtype); +/* Stop (true) / resume (false) all frames the AWDL task sends by itself: PSF, + * MIF and queued host data, which stays queued. For measurements that hop + * channels, so nothing goes out on a channel we only pass through. */ +void awdl_app_pause_tx(bool paused); + /* * Queue one host Ethernet frame (dst||src||ethertype||payload) for transmission * as an AWDL data frame. Only IPv6 (ethertype 0x86dd) is forwarded; other types diff --git a/firmware/spikes/main/bridge.c b/firmware/spikes/main/bridge.c index b995e04..1b72a5b 100644 --- a/firmware/spikes/main/bridge.c +++ b/firmware/spikes/main/bridge.c @@ -8,6 +8,7 @@ #include #include +#include #include #include "driver/usb_serial_jtag.h" @@ -235,6 +236,26 @@ static void handle_cmd(const uint8_t *p, size_t n) { (unsigned)c.sent_mc, (unsigned)c.tx_fail, (unsigned)st.tx_uc_ack, (unsigned)st.tx_uc_noack); cmd_reply(out); + } else if (strcmp(cmd, "chan") == 0 || strncmp(cmd, "chan ", 5) == 0) { + /* `chan` shows the AWDL channel, `chan ` moves the node there (it + * restarts: sync and peers are learned again). On the C5 that allows + * single-channel 5 GHz experiments (44/149) with the full data path. */ + if (cmd[4] == '\0') { + snprintf(out, sizeof(out), "chan %u", radio_get_channel()); + } else { + int ch = atoi(cmd + 5); + int err = (ch >= 1 && ch <= 255 && radio_channel_supported((uint8_t)ch)) + ? radio_set_channel((uint8_t)ch) : ESP_ERR_NOT_SUPPORTED; + if (err != ESP_OK) { + snprintf(out, sizeof(out), "chan %d: %s", ch, esp_err_to_name(err)); + } else { + uint8_t mac[6]; + radio_get_self_mac(mac); + awdl_app_set_self(mac, (uint8_t)ch); + snprintf(out, sizeof(out), "chan %d (AWDL node restarted)", ch); + } + } + cmd_reply(out); } else if (strcmp(cmd, "testframe") == 0) { send_test_frame(); cmd_reply("sent testframe"); diff --git a/firmware/spikes/main/console.c b/firmware/spikes/main/console.c index ae2d1c3..67f2f21 100644 --- a/firmware/spikes/main/console.c +++ b/firmware/spikes/main/console.c @@ -1,17 +1,18 @@ /* - * EspDrop - serial console for the AWDL M0 hardware spikes. + * EspDrop - serial console for the AWDL hardware spikes (ESP32-C3 and -C5). * * Commands (type `help` on the device): - * info show self MAC, channel, promiscuous state + * info chip, MAC, channel, country, band mode, TX power * mac override the station MAC (ACK experiment) - * chan <1..13> set the 2.4 GHz channel + * chan set the channel (2.4 GHz; on the C5 also 36..48, 149..177) * rx on|off enable/disable promiscuous AWDL receive * tx psf|mif [n] inject n (default 1) action frames * txdata inject one AWDL data frame to the broadcast AWDL addr * peers drain and print discovered-peer events * stats print rx/tx counters * reset zero the counters - * chanlat [n] measure esp_wifi_set_channel latency over n hops + * chanlat [n] [c1,c2,..] time esp_wifi_set_channel over n hops through a list + * txpwr [q] show / set the TX power ceiling (0.25 dBm units) * usbbench [kib] stream kib*1024 bytes so the host can time USB CDC * * SPDX-License-Identifier: GPL-3.0-or-later @@ -22,9 +23,13 @@ #include #include +#include "esp_chip_info.h" #include "esp_console.h" +#include "esp_err.h" #include "esp_timer.h" #include "esp_wifi.h" +#include "sdkconfig.h" +#include "soc/soc_caps.h" #include "awdl_frame.h" #include "awdl_app.h" @@ -35,7 +40,7 @@ static awdl_self_t s_self; static void refresh_self(void) { uint8_t mac[6]; radio_get_self_mac(mac); - awdl_self_init(&s_self, mac, "espdrop-c3", radio_get_channel()); + awdl_self_init(&s_self, mac, CONFIG_ESPDROP_AWDL_NAME, radio_get_channel()); } static int parse_mac(const char *s, uint8_t out[6]) { @@ -49,13 +54,34 @@ static int parse_mac(const char *s, uint8_t out[6]) { return 0; } +#if SOC_WIFI_SUPPORT_5G +#define CHAN_RANGE "1..14 | 36..48 | 149..177" +#else +#define CHAN_RANGE "1..14" +#endif + static int cmd_info(int argc, char **argv) { (void)argc; (void)argv; + esp_chip_info_t ci; + esp_chip_info(&ci); + printf("chip : %s rev v%u.%u, CPU %d MHz\n", CONFIG_IDF_TARGET, + (unsigned)(ci.revision / 100), (unsigned)(ci.revision % 100), + CONFIG_ESP_DEFAULT_CPU_FREQ_MHZ); uint8_t mac[6]; radio_get_self_mac(mac); printf("self MAC : %02x:%02x:%02x:%02x:%02x:%02x\n", mac[0], mac[1], mac[2], mac[3], mac[4], mac[5]); printf("channel : %u\n", radio_get_channel()); + char cc[4] = {0}; + if (esp_wifi_get_country_code(cc) == ESP_OK) printf("country : %s\n", cc); +#if SOC_WIFI_SUPPORT_5G + wifi_band_mode_t bm; + if (esp_wifi_get_band_mode(&bm) == ESP_OK) + printf("band mode: %s\n", bm == WIFI_BAND_MODE_AUTO ? "2.4 + 5 GHz" + : bm == WIFI_BAND_MODE_5G_ONLY ? "5 GHz only" : "2.4 GHz only"); +#endif + int8_t q = radio_get_max_tx_power(); + printf("tx power : max %.2f dBm (%d)\n", q / 4.0, q); printf("promisc : %s\n", radio_promisc_enabled() ? "on" : "off"); printf("awdl bssid: %02x:%02x:%02x:%02x:%02x:%02x\n", AWDL_BSSID[0], AWDL_BSSID[1], AWDL_BSSID[2], @@ -76,11 +102,14 @@ static int cmd_mac(int argc, char **argv) { } static int cmd_chan(int argc, char **argv) { - if (argc != 2) { printf("usage: chan <1..13>\n"); return 1; } + if (argc != 2) { printf("usage: chan <" CHAN_RANGE ">\n"); return 1; } int ch = atoi(argv[1]); - if (ch < 1 || ch > 13) { printf("channel out of range\n"); return 1; } + if (ch < 1 || ch > 255 || !radio_channel_supported((uint8_t)ch)) { + printf("channel %d not supported here (" CHAN_RANGE ")\n", ch); + return 1; + } int err = radio_set_channel((uint8_t)ch); - if (err) { printf("set_channel failed: 0x%x\n", err); return 1; } + if (err) { printf("set_channel failed: %s\n", esp_err_to_name(err)); return 1; } refresh_self(); uint8_t mac[6]; radio_get_self_mac(mac); @@ -173,8 +202,12 @@ static int cmd_stats(int argc, char **argv) { radio_get_stats(&s); printf("rx: action=%llu data=%llu other_bssid=%llu parse_err=%llu\n", s.rx_action, s.rx_data, s.rx_other_bssid, s.rx_parse_err); - printf("tx: ok=%llu err=%llu tx_done: ok=%llu fail=%llu\n", - s.tx_ok, s.tx_err, s.tx_done_ok, s.tx_done_fail); + printf("rx data: msdu=%llu amsdu=%llu dup=%llu data_perr=%llu\n", + s.rx_msdu, s.rx_amsdu, s.rx_dup, s.rx_data_perr); + printf("rx checks: fcs_ok=%llu fcs_bad=%llu type_mismatch=%llu\n", + s.rx_fcs_ok, s.rx_fcs_bad, s.rx_type_mismatch); + printf("tx: ok=%llu err=%llu tx_done: ok=%llu fail=%llu unicast data: ack=%llu noack=%llu\n", + s.tx_ok, s.tx_err, s.tx_done_ok, s.tx_done_fail, s.tx_uc_ack, s.tx_uc_noack); return 0; } @@ -185,22 +218,117 @@ static int cmd_reset(int argc, char **argv) { return 0; } +#define CHANLAT_MAX 1000 +#define CHANLAT_CHANS 8 +#if SOC_WIFI_SUPPORT_5G +#define CHANLAT_DEFAULT "6,44,149" /* both bands: what a dual-band AWDL node does */ +#else +#define CHANLAT_DEFAULT "1,6,11" +#endif + +static int cmp_i32(const void *a, const void *b) { + int32_t x = *(const int32_t *)a, y = *(const int32_t *)b; + return (x > y) - (x < y); +} + +static void chanlat_report(const char *what, int32_t *v, int n) { + if (n == 0) { printf(" %-10s no hops\n", what); return; } + qsort(v, (size_t)n, sizeof(v[0]), cmp_i32); + int64_t sum = 0; + for (int i = 0; i < n; i++) sum += v[i]; + int p99 = (n * 99 + 99) / 100 - 1; /* ceil(0.99 n) - 1 */ + printf(" %-10s %4d hops mean %lld min %ld p50 %ld p99 %ld max %ld us\n", + what, n, (long long)(sum / n), (long)v[0], (long)v[n / 2], + (long)v[p99], (long)v[n - 1]); +} + +/* + * chanlat [n] [c1,c2,...]: n calls of esp_wifi_set_channel cycling through the + * list, timed around the call (the radio may still settle afterwards). Hops + * within a band and across bands are reported separately, the first hop on its + * own (a band change may calibrate once). Our own AWDL TX is paused meanwhile, + * and the power is capped if the list touches 149+. + */ static int cmd_chanlat(int argc, char **argv) { int n = (argc >= 2) ? atoi(argv[1]) : 100; if (n < 1) n = 1; - const uint8_t chans[] = {1, 6, 11}; - int64_t total = 0, worst = 0; + if (n > CHANLAT_MAX) n = CHANLAT_MAX; + + char list[64]; + strncpy(list, (argc >= 3) ? argv[2] : CHANLAT_DEFAULT, sizeof(list) - 1); + list[sizeof(list) - 1] = '\0'; + uint8_t chans[CHANLAT_CHANS]; + int nch = 0; + bool srd = false; + for (char *tok = strtok(list, ","); tok; tok = strtok(NULL, ",")) { + int ch = atoi(tok); + if (nch == CHANLAT_CHANS || ch < 1 || ch > 255 || + !radio_channel_supported((uint8_t)ch)) { + printf("usage: chanlat [n] [c1,c2,...] up to %d channels from " CHAN_RANGE "\n", + CHANLAT_CHANS); + return 1; + } + chans[nch++] = (uint8_t)ch; + if (ch >= 149) srd = true; + } + if (nch < 1) { printf("usage: chanlat [n] [c1,c2,...]\n"); return 1; } + + static int32_t same[CHANLAT_MAX], cross[CHANLAT_MAX]; + int ns = 0, nc = 0, errs = 0, wrong = 0; + esp_err_t first_err = ESP_OK; + int32_t first_dt = -1; + uint8_t home = radio_get_channel(), prev = home, first_from = 0, first_to = 0; + + awdl_app_pause_tx(true); + int8_t q = radio_get_max_tx_power(); + if (srd && q > 44) esp_wifi_set_max_tx_power(44); /* restored with the channel */ for (int i = 0; i < n; i++) { - uint8_t ch = chans[i % 3]; + uint8_t ch = chans[i % nch]; int64_t t0 = esp_timer_get_time(); - esp_wifi_set_channel(ch, WIFI_SECOND_CHAN_NONE); - int64_t dt = esp_timer_get_time() - t0; - total += dt; - if (dt > worst) worst = dt; + esp_err_t e = esp_wifi_set_channel(ch, WIFI_SECOND_CHAN_NONE); + int32_t dt = (int32_t)(esp_timer_get_time() - t0); + if (e != ESP_OK) { + if (errs++ == 0) first_err = e; + continue; + } + uint8_t pri = 0; + wifi_second_chan_t sec; + if (esp_wifi_get_channel(&pri, &sec) != ESP_OK || pri != ch) wrong++; + if (first_dt < 0) { + first_dt = dt; first_from = prev; first_to = ch; + } else if ((ch > 14) != (prev > 14)) { + cross[nc++] = dt; + } else { + same[ns++] = dt; + } + prev = ch; + } + int rerr = radio_set_channel(home); /* also restores the TX power ceiling */ + awdl_app_pause_tx(false); + + printf("chanlat %d hops over %s (CPU %d MHz):\n", n, + (argc >= 3) ? argv[2] : CHANLAT_DEFAULT, CONFIG_ESP_DEFAULT_CPU_FREQ_MHZ); + if (first_dt >= 0) + printf(" first hop %u -> %u: %ld us\n", first_from, first_to, (long)first_dt); + chanlat_report("same band", same, ns); + chanlat_report("cross band", cross, nc); + if (errs) printf(" %d hops failed (first: %s)\n", errs, esp_err_to_name(first_err)); + if (wrong) printf(" %d hops reported another channel afterwards\n", wrong); + if (rerr) printf(" back to channel %u failed: %s\n", home, esp_err_to_name(rerr)); + return 0; +} + +static int cmd_txpwr(int argc, char **argv) { + if (argc >= 2) { + int q = atoi(argv[1]); + if (q < 8 || q > 84) { printf("usage: txpwr [8..84] (0.25 dBm units, 80 = 20 dBm)\n"); return 1; } + int err = radio_set_max_tx_power((int8_t)q); + if (err) { printf("set failed: %s\n", esp_err_to_name(err)); return 1; } } - radio_set_channel(radio_get_channel()); - printf("chanlat over %d hops: mean %lld us, worst %lld us\n", - n, (long long)(total / n), (long long)worst); + uint8_t ch = radio_get_channel(); + int8_t q = radio_get_max_tx_power(); + printf("tx power ceiling %.2f dBm (%d) on channel %u%s\n", q / 4.0, q, ch, + ch >= 149 ? " (capped at 11 dBm on 149+)" : ""); return 0; } @@ -233,9 +361,9 @@ static void reg(const char *cmd, const char *help, esp_console_cmd_func_t fn) { void console_register_commands(void) { refresh_self(); - reg("info", "show self MAC, channel, promiscuous state", cmd_info); + reg("info", "show chip, MAC, channel, country, band mode, TX power", cmd_info); reg("mac", "mac - override station MAC", cmd_mac); - reg("chan", "chan <1..13> - set 2.4 GHz channel", cmd_chan); + reg("chan", "chan <" CHAN_RANGE "> - set channel", cmd_chan); reg("rx", "rx on|off - promiscuous AWDL receive", cmd_rx); reg("tx", "tx psf|mif [count] - inject action frame(s)", cmd_tx); reg("txdata", "txdata - inject one AWDL data frame", cmd_txdata); @@ -243,6 +371,7 @@ void console_register_commands(void) { reg("sync", "print master, sync-tree height, sync error, peer count", cmd_sync); reg("stats", "print rx/tx counters", cmd_stats); reg("reset", "zero the counters", cmd_reset); - reg("chanlat", "chanlat [n] - measure channel-switch latency", cmd_chanlat); + reg("chanlat", "chanlat [n] [c1,c2,...] - measure channel-switch latency", cmd_chanlat); + reg("txpwr", "txpwr [8..84] - show/set TX power ceiling (0.25 dBm)", cmd_txpwr); reg("usbbench", "usbbench [kib] - stream data for host-side USB timing", cmd_usbbench); } diff --git a/firmware/spikes/main/radio.c b/firmware/spikes/main/radio.c index c5cdf98..fd9026e 100644 --- a/firmware/spikes/main/radio.c +++ b/firmware/spikes/main/radio.c @@ -15,7 +15,10 @@ #include "esp_timer.h" #include "esp_wifi.h" #include "esp_private/wifi.h" /* esp_wifi_set_tx_done_cb: stable across IDF 5.1..6.x */ +#include "esp_rom_crc.h" #include "nvs_flash.h" +#include "sdkconfig.h" +#include "soc/soc_caps.h" #include "freertos/FreeRTOS.h" #include "freertos/queue.h" @@ -31,6 +34,12 @@ static radio_data_handler_t s_data_handler; static void *s_data_ctx; static awdl_dedup_t s_dedup; /* touched only by rx_cb (Wi-Fi task) */ +/* TX power ceiling (0.25 dBm units): what the user asked for, and the cap for + * channels 149+ (5725-5875 MHz is a 25 mW EIRP short-range-device band in the + * EU; 11 dBm conducted leaves room for a few dBi of antenna gain). */ +#define TXPWR_SRD_CAP_QDBM 44 +static int8_t s_txpwr_user = CONFIG_ESP_PHY_MAX_WIFI_TX_POWER * 4; + static inline void stat_inc(uint64_t *field) { portENTER_CRITICAL(&s_stats_mux); (*field)++; @@ -87,7 +96,25 @@ static void rx_cb(void *buf, wifi_promiscuous_pkt_type_t type) { return; } - if (type == WIFI_PKT_MGMT) { + /* Go by the frame-control type bits rather than the driver's label, and + * count disagreements (reported on some chips; a check for the C5). */ + wifi_promiscuous_pkt_type_t ftype; + switch (frame[0] & 0x0c) { + case 0x00: ftype = WIFI_PKT_MGMT; break; + case 0x08: ftype = WIFI_PKT_DATA; break; + default: stat_inc(&s_stats.rx_type_mismatch); return; + } + if (ftype != type) stat_inc(&s_stats.rx_type_mismatch); + + if (ftype == WIFI_PKT_MGMT) { + /* Is sig_len really MPDU + FCS on this chip? The 4 bytes we strip must + * be the CRC-32 of the frame (esp_rom_crc32_le is the IEEE CRC-32). + * Action frames only: rare and short, so the CRC is cheap. */ + uint32_t fcs = (uint32_t)frame[len] | ((uint32_t)frame[len + 1] << 8) | + ((uint32_t)frame[len + 2] << 16) | ((uint32_t)frame[len + 3] << 24); + stat_inc(esp_rom_crc32_le(0, frame, (uint32_t)len) == fcs + ? &s_stats.rx_fcs_ok : &s_stats.rx_fcs_bad); + awdl_peer_view_t pv; int r = awdl_parse_action(frame, (size_t)len, &pv); if (r != AWDL_OK) { stat_inc(&s_stats.rx_parse_err); return; } @@ -127,6 +154,66 @@ static void rx_cb(void *buf, wifi_promiscuous_pkt_type_t type) { /* ------------------------------------------------------------------------ */ +bool radio_channel_supported(uint8_t ch) { + if (ch >= 1 && ch <= 14) return true; +#if SOC_WIFI_SUPPORT_5G + if (ch >= 36 && ch <= 48 && ch % 4 == 0) return true; /* UNII-1, no DFS */ + if (ch >= 149 && ch <= 177 && ch % 4 == 1) return true; /* UNII-3 / SRD */ +#endif + return false; +} + +static int8_t tx_power_for(uint8_t ch) { + return (ch >= 149 && s_txpwr_user > TXPWR_SRD_CAP_QDBM) ? TXPWR_SRD_CAP_QDBM + : s_txpwr_user; +} + +/* Switch channel with the matching TX power: lower the ceiling before entering + * the capped band, raise it only after leaving it. */ +static esp_err_t set_channel_and_power(uint8_t ch) { + int8_t want = tx_power_for(ch); + int8_t now = 0; + bool lower_first = esp_wifi_get_max_tx_power(&now) != ESP_OK || want < now; + if (lower_first) esp_wifi_set_max_tx_power(want); + esp_err_t err = esp_wifi_set_channel(ch, WIFI_SECOND_CHAN_NONE); + if (err != ESP_OK) { + /* still on the old channel: restore its ceiling */ + esp_wifi_set_max_tx_power(tx_power_for(s_channel)); + return err; + } + s_channel = ch; + if (!lower_first) esp_wifi_set_max_tx_power(want); + return ESP_OK; +} + +/* + * Everything that has to be (re)applied after esp_wifi_start(): power save off + * (we must stay on-channel to hear/inject AWDL), country, on a dual-band chip + * both bands and 20 MHz (set_bandwidth is refused in AUTO band mode; AWDL runs + * at 20 MHz), the tx-done hook, then channel and TX power. + */ +static esp_err_t apply_rf(uint8_t channel) { + esp_wifi_set_ps(WIFI_PS_NONE); + if (CONFIG_ESPDROP_WIFI_COUNTRY[0] != '\0') { + esp_err_t e = esp_wifi_set_country_code(CONFIG_ESPDROP_WIFI_COUNTRY, false); + if (e != ESP_OK) + ESP_LOGW(TAG, "country %s: %s", CONFIG_ESPDROP_WIFI_COUNTRY, esp_err_to_name(e)); + } +#if SOC_WIFI_SUPPORT_5G + /* also guards against a 2.4G-only band mode left in NVS by older firmware */ + esp_err_t e = esp_wifi_set_band_mode(WIFI_BAND_MODE_AUTO); + if (e != ESP_OK) ESP_LOGW(TAG, "band mode: %s", esp_err_to_name(e)); + wifi_bandwidths_t bw = { .ghz_2g = WIFI_BW20, .ghz_5g = WIFI_BW20 }; + e = esp_wifi_set_bandwidths(WIFI_IF_STA, &bw); + if (e != ESP_OK) ESP_LOGW(TAG, "bandwidths: %s", esp_err_to_name(e)); +#endif + esp_wifi_set_tx_done_cb(tx_done_cb); + esp_err_t err = set_channel_and_power(channel); + if (err != ESP_OK) + ESP_LOGE(TAG, "channel %u: %s", channel, esp_err_to_name(err)); + return err; +} + int radio_init(uint8_t channel) { awdl_dedup_init(&s_dedup); /* before promiscuous RX can run */ esp_err_t err = nvs_flash_init(); @@ -150,12 +237,7 @@ int radio_init(uint8_t channel) { if ((err = esp_wifi_set_mode(WIFI_MODE_STA)) != ESP_OK) return err; if ((err = esp_wifi_start()) != ESP_OK) return err; - /* no power save: we must stay on-channel to hear/inject AWDL */ - esp_wifi_set_ps(WIFI_PS_NONE); - esp_wifi_set_tx_done_cb(tx_done_cb); - - s_channel = channel; - esp_wifi_set_channel(channel, WIFI_SECOND_CHAN_NONE); + if ((err = apply_rf(channel)) != ESP_OK) return err; uint8_t mac[6]; esp_wifi_get_mac(WIFI_IF_STA, mac); @@ -174,22 +256,32 @@ int radio_set_self_mac(const uint8_t mac[6]) { bool was_promisc = s_promisc; if (was_promisc) radio_promisc(false); if ((err = esp_wifi_stop()) != ESP_OK) return err; - if ((err = esp_wifi_set_mac(WIFI_IF_STA, mac)) != ESP_OK) { esp_wifi_start(); return err; } + esp_err_t mac_err = esp_wifi_set_mac(WIFI_IF_STA, mac); + /* restart either way, with everything re-applied, before reporting */ if ((err = esp_wifi_start()) != ESP_OK) return err; - esp_wifi_set_ps(WIFI_PS_NONE); - esp_wifi_set_channel(s_channel, WIFI_SECOND_CHAN_NONE); + err = apply_rf(s_channel); if (was_promisc) radio_promisc(true); - return ESP_OK; + return mac_err != ESP_OK ? mac_err : err; } int radio_set_channel(uint8_t channel) { - esp_err_t err = esp_wifi_set_channel(channel, WIFI_SECOND_CHAN_NONE); - if (err == ESP_OK) s_channel = channel; - return err; + if (!radio_channel_supported(channel)) return ESP_ERR_INVALID_ARG; + return set_channel_and_power(channel); } uint8_t radio_get_channel(void) { return s_channel; } +int radio_set_max_tx_power(int8_t qdbm) { + if (qdbm < 8 || qdbm > 84) return ESP_ERR_INVALID_ARG; + s_txpwr_user = qdbm; + return esp_wifi_set_max_tx_power(tx_power_for(s_channel)); +} + +int8_t radio_get_max_tx_power(void) { + int8_t p = 0; + return esp_wifi_get_max_tx_power(&p) == ESP_OK ? p : -1; +} + int radio_tx(const uint8_t *buf, int len) { esp_err_t err = esp_wifi_80211_tx(WIFI_IF_STA, buf, len, false /* keep our seq */); if (err == ESP_OK) stat_inc(&s_stats.tx_ok); @@ -207,7 +299,8 @@ int radio_promisc(bool on) { esp_wifi_set_promiscuous_rx_cb(rx_cb); if ((err = esp_wifi_set_promiscuous(true)) != ESP_OK) return err; /* promiscuous resets the channel; reapply */ - esp_wifi_set_channel(s_channel, WIFI_SECOND_CHAN_NONE); + if ((err = esp_wifi_set_channel(s_channel, WIFI_SECOND_CHAN_NONE)) != ESP_OK) + ESP_LOGW(TAG, "re-applying channel %u: %s", s_channel, esp_err_to_name(err)); } else { if ((err = esp_wifi_set_promiscuous(false)) != ESP_OK) return err; } diff --git a/firmware/spikes/main/radio.h b/firmware/spikes/main/radio.h index b23e3b0..3cb0e1a 100644 --- a/firmware/spikes/main/radio.h +++ b/firmware/spikes/main/radio.h @@ -38,6 +38,12 @@ typedef struct { uint64_t tx_done_fail; /* 80211 tx-done callbacks with status FAIL */ uint64_t tx_uc_ack; /* our unicast DATA frames the peer hardware-ACKed */ uint64_t tx_uc_noack; /* our unicast DATA frames with no ACK after retries */ + /* Bring-up checks for a new chip (C5): the promiscuous type disagreed with + * the frame-control type bits, and whether the 4 bytes after the frame are + * really its FCS (CRC-32 over AWDL action frames, so sig_len includes it). */ + uint64_t rx_type_mismatch; + uint64_t rx_fcs_ok; + uint64_t rx_fcs_bad; } radio_stats_t; /* @@ -61,11 +67,20 @@ typedef void (*radio_data_handler_t)(const uint8_t dst[6], const uint8_t src[6], size_t plen, void *ctx); /* - * Bring up Wi-Fi in station mode without connecting, on the given 2.4 GHz - * channel. Safe to call once at startup. Returns an esp_err_t (0 == ESP_OK). + * Bring up Wi-Fi in station mode without connecting, on the given channel + * (see radio_channel_supported). Safe to call once at startup. Returns an + * esp_err_t (0 == ESP_OK). */ int radio_init(uint8_t channel); +/* + * Whether this chip can be put on the channel: 1..14 everywhere, and on a 5 GHz + * chip (ESP32-C5) also 36..48 and 149..177 (steps of 4). The DFS channels + * 52..144 are left out on purpose: transmitting there needs radar detection. + * The driver's country table has the final say (radio_set_channel fails). + */ +bool radio_channel_supported(uint8_t channel); + /* Our AWDL self address = the station interface MAC. Copies 6 bytes out. */ void radio_get_self_mac(uint8_t out[6]); @@ -76,10 +91,21 @@ void radio_get_self_mac(uint8_t out[6]); */ int radio_set_self_mac(const uint8_t mac[6]); -/* Set the 2.4 GHz channel (1..13). Returns esp_err_t. */ +/* Set the channel (see radio_channel_supported). Applies the TX power cap for + * the target channel first when moving up into the 25 mW band (149+), so we + * never transmit there above it. Returns esp_err_t. */ int radio_set_channel(uint8_t channel); uint8_t radio_get_channel(void); +/* + * TX power ceiling in 0.25 dBm steps (8..84, e.g. 80 = 20 dBm), as for + * esp_wifi_set_max_tx_power. The effective ceiling is the lower of this and the + * 11 dBm cap on channels 149+ (25 mW EIRP, short-range-device band in the EU). + * Returns esp_err_t. radio_get_max_tx_power reports what the driver applies. + */ +int radio_set_max_tx_power(int8_t qdbm); +int8_t radio_get_max_tx_power(void); + /* Inject a raw 802.11 frame (MAC header .. no FCS). Returns esp_err_t. */ int radio_tx(const uint8_t *buf, int len); diff --git a/firmware/spikes/sdkconfig.bridge.defaults b/firmware/spikes/sdkconfig.bridge.defaults index 1d7c794..5a6b9c0 100644 --- a/firmware/spikes/sdkconfig.bridge.defaults +++ b/firmware/spikes/sdkconfig.bridge.defaults @@ -7,9 +7,10 @@ # corrupt the binary COBS stream). Disable the console entirely; device-side # visibility goes out as BRIDGE_MSG_LOG frames instead. -# Pin the chip: a fresh build dir otherwise defaults to esp32, which has no +# Default chip: a fresh build dir otherwise defaults to esp32, which has no # USB-Serial/JTAG peripheral, so usb_serial_jtag_driver_install would be -# undefined at link time. +# undefined at link time. For the C5 pass -DIDF_TARGET=esp32c5, which overrides +# this cleanly (use a separate build dir per chip). CONFIG_IDF_TARGET="esp32c3" CONFIG_ESPDROP_BRIDGE_MODE=y diff --git a/firmware/spikes/sdkconfig.defaults.esp32c5 b/firmware/spikes/sdkconfig.defaults.esp32c5 new file mode 100644 index 0000000..644d898 --- /dev/null +++ b/firmware/spikes/sdkconfig.defaults.esp32c5 @@ -0,0 +1,7 @@ +# EspDrop - ESP32-C5-only defaults. ESP-IDF loads this automatically right after +# sdkconfig.defaults when building for esp32c5 (a C3 build ignores it). + +# The C5 also has an 802.15.4 radio (Thread/Zigbee); enabling it switches on +# software Wi-Fi coexistence, which arbitrates the shared RF. We do not use +# 802.15.4, so keep the Wi-Fi timing measurements free of that arbitration. +CONFIG_IEEE802154_ENABLED=n diff --git a/tools/awdl_bridge.py b/tools/awdl_bridge.py index 1245e47..6bb336d 100644 --- a/tools/awdl_bridge.py +++ b/tools/awdl_bridge.py @@ -26,7 +26,7 @@ # are only logged. Suppressed with --no-config. # # Interactive stdin commands (forwarded to the device): -# mac | stats | radio | tx | node | ping | testframe | resync +# mac | stats | radio | tx | node | chan [n] | ping | testframe | resync # # Needs root: creating a TAP and running `ip link` both require CAP_NET_ADMIN. # The interface is not persistent - it disappears when this process exits. @@ -196,7 +196,7 @@ def main() -> int: print(f"{args.dev} up (mtu {args.mtu}, mac {mac or 'kernel-assigned'}); " f"bridging {args.port}") - print("commands: mac | stats | radio | tx | node | ping | testframe | resync" + print("commands: mac | stats | radio | tx | node | chan [n] | ping | testframe | resync" " (Ctrl-C to quit)") # The C3 keeps running across host sessions (its USB port does not reset From 26958e3be438df51d99d5271e2628c5e51867837 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Oct 2026 16:20:19 +0000 Subject: [PATCH 38/99] fix(c5): keep channels inside the EU bands, review follow-ups for the C5 port - Channel 14 was accepted again (the console used to stop at 13), and with the driver's default country the C3 would then send AWDL frames on 2484 MHz. Channel 177 lies above the 5875 MHz end of the EU SRD band. radio_channel_supported() now allows 1..13, 36..48 and 149..173 and is the real limit (the default "01"/802.11d driver setting accepts more). - chanlat keeps the first band change out of the statistics instead of the first call, which was a no-op when the list starts at home. - Docs: tx ok after `tx mif 20` includes the node's own frames, the C5 advertises espdrop-c5, and the README bridge build has its own dir. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01XzgaN4Bv1m8Ka9iqo7QVUN --- docs/SPIKES.md | 11 ++++++----- firmware/README.md | 4 +++- firmware/spikes/main/Kconfig.projbuild | 2 +- firmware/spikes/main/console.c | 22 ++++++++++++---------- firmware/spikes/main/radio.c | 4 ++-- firmware/spikes/main/radio.h | 10 ++++++---- 6 files changed, 30 insertions(+), 23 deletions(-) diff --git a/docs/SPIKES.md b/docs/SPIKES.md index c1f6ceb..febccd3 100644 --- a/docs/SPIKES.md +++ b/docs/SPIKES.md @@ -43,7 +43,7 @@ beide, die Namen unter `/dev/serial/by-id/` verwenden. |---|---| | `info` | Chip und Revision, eigene MAC, Kanal, Land, Band-Modus (C5), Sendeleistung, Promiscuous-Status | | `mac ` | Stations-MAC setzen (für den ACK-Test) | -| `chan ` | Kanal wechseln: 1..14, auf dem C5 zusätzlich 36..48 und 149..177 | +| `chan ` | Kanal wechseln: 1..13, auf dem C5 zusätzlich 36..48 und 149..173 | | `rx on` / `rx off` | Promiscuous-Empfang (nur AWDL-BSSID) an/aus | | `tx psf [n]` / `tx mif [n]` | n Action-Frames senden | | `txdata ` | einen AWDL-Datenframe an die AWDL-Broadcast-MAC senden | @@ -75,7 +75,7 @@ der Nähe – nicht committen** (die `.gitignore` blockt `*.pcap` und `*.log`). 1. `tx mif 5` auf dem C3. 2. Im `tcpdump`/Wireshark auf dem Laptop nachsehen: Erscheinen die Frames? Wireshark hat einen AWDL-Dissektor – prüfen, ob er Sync-Params, Kanalsequenz, - ARPA-Name (`espdrop-c3`) und Version sauber dekodiert. + ARPA-Name (`espdrop-c3`, auf dem C5 `espdrop-c5`) und Version sauber dekodiert. 3. Querprobe gegen den Host-Test: `firmware/spikes/components/awdl/test/host` gibt denselben MIF als Hexdump aus. @@ -142,15 +142,16 @@ Radarerkennung). am iPhone das Teilen-Menü öffnen, `peers`. In der Kanalsequenz steht 44 oder 149. 4. **Empfang auf 5 GHz.** `chan 44` (bzw. 149), `rx on`, Teilen-Menü offen, `peers` und `stats`: Das iPhone muss auftauchen, `action` steigen. -5. **Senden auf 5 GHz.** `chan 44`, `reset`, `tx mif 20`, `stats` - (`tx ok=20`, `tx_done ok` steigt). Mitschnitt mit einer 5-GHz-fähigen Karte: +5. **Senden auf 5 GHz.** `chan 44`, `reset`, `tx mif 20`, `stats`: + `tx ok` mindestens 20 (der Knoten sendet nebenbei eigene PSF/MIF, die + zählen mit), `tx err` 0, `tx_done ok` steigt. Mitschnitt mit einer 5-GHz-fähigen Karte: `sudo iw reg set DE`, Monitor-Mode wie oben, `sudo iw dev wlan0 set channel 44`. Wireshark sollte saubere AWDL-Frames mit 6 Mbit/s zeigen. 6. **Kanalwechsel (entscheidend für 5 GHz).** `chanlat 200 6,6`, `chanlat 200 6,44`, `chanlat 200 6,149`, `chanlat 200 44,149`. Ein AWDL-Slot dauert 64 TU (≈ 65 ms); ein Wechsel um 2 ms ist gut, unter 5 ms brauchbar (MT7921: 8,5 ms). Alle Zeilen der Ausgabe - aufschreiben, auch den ersten Wechsel und eventuelle Fehler. + aufschreiben, auch den ersten Bandwechsel und eventuelle Fehler. 7. **USB-Durchsatz** wie Spike 5. 8. **Optional: Bridge auf 5 GHz.** Bridge-Firmware für den C5 flashen, im `awdl_bridge.py`-Terminal `chan 44` (bzw. den Kanal aus Test 3) eingeben, diff --git a/firmware/README.md b/firmware/README.md index a1e87f7..669d23c 100644 --- a/firmware/README.md +++ b/firmware/README.md @@ -26,7 +26,9 @@ cd spikes # one build dir per chip, target always given (do not use `idf.py set-target`) idf.py -B build_c3 -DSDKCONFIG=build_c3/sdkconfig -DIDF_TARGET=esp32c3 -p /dev/ttyACM0 build flash monitor idf.py -B build_c5 -DSDKCONFIG=build_c5/sdkconfig -DIDF_TARGET=esp32c5 -p /dev/ttyACM0 build flash monitor -# bridge firmware: add -DSDKCONFIG_DEFAULTS="sdkconfig.defaults;sdkconfig.bridge.defaults" +# bridge firmware: its own build dir, e.g. for the C5 +idf.py -B build_c5_bridge -DSDKCONFIG=build_c5_bridge/sdkconfig -DIDF_TARGET=esp32c5 \ + -DSDKCONFIG_DEFAULTS="sdkconfig.defaults;sdkconfig.bridge.defaults" -p /dev/ttyACM0 build flash ``` `sdkconfig.defaults.esp32c5` is picked up automatically for the C5. diff --git a/firmware/spikes/main/Kconfig.projbuild b/firmware/spikes/main/Kconfig.projbuild index 08dde2c..6e79aaf 100644 --- a/firmware/spikes/main/Kconfig.projbuild +++ b/firmware/spikes/main/Kconfig.projbuild @@ -19,7 +19,7 @@ config ESPDROP_AWDL_NAME config ESPDROP_AWDL_CHANNEL int "AWDL channel at boot" - range 1 177 + range 1 173 default 6 help Channel the AWDL node starts on. 6 is the 2.4 GHz social channel and the diff --git a/firmware/spikes/main/console.c b/firmware/spikes/main/console.c index 67f2f21..11bb959 100644 --- a/firmware/spikes/main/console.c +++ b/firmware/spikes/main/console.c @@ -4,7 +4,7 @@ * Commands (type `help` on the device): * info chip, MAC, channel, country, band mode, TX power * mac override the station MAC (ACK experiment) - * chan set the channel (2.4 GHz; on the C5 also 36..48, 149..177) + * chan set the channel (1..13; on the C5 also 36..48, 149..173) * rx on|off enable/disable promiscuous AWDL receive * tx psf|mif [n] inject n (default 1) action frames * txdata inject one AWDL data frame to the broadcast AWDL addr @@ -55,9 +55,9 @@ static int parse_mac(const char *s, uint8_t out[6]) { } #if SOC_WIFI_SUPPORT_5G -#define CHAN_RANGE "1..14 | 36..48 | 149..177" +#define CHAN_RANGE "1..13 | 36..48 | 149..173" #else -#define CHAN_RANGE "1..14" +#define CHAN_RANGE "1..13" #endif static int cmd_info(int argc, char **argv) { @@ -245,9 +245,9 @@ static void chanlat_report(const char *what, int32_t *v, int n) { /* * chanlat [n] [c1,c2,...]: n calls of esp_wifi_set_channel cycling through the * list, timed around the call (the radio may still settle afterwards). Hops - * within a band and across bands are reported separately, the first hop on its - * own (a band change may calibrate once). Our own AWDL TX is paused meanwhile, - * and the power is capped if the list touches 149+. + * within a band and across bands are reported separately, the first band change + * on its own (it may calibrate once). Our own AWDL TX is paused meanwhile, and + * the power is capped if the list touches 149+. */ static int cmd_chanlat(int argc, char **argv) { int n = (argc >= 2) ? atoi(argv[1]) : 100; @@ -294,9 +294,10 @@ static int cmd_chanlat(int argc, char **argv) { uint8_t pri = 0; wifi_second_chan_t sec; if (esp_wifi_get_channel(&pri, &sec) != ESP_OK || pri != ch) wrong++; - if (first_dt < 0) { - first_dt = dt; first_from = prev; first_to = ch; - } else if ((ch > 14) != (prev > 14)) { + bool xband = (ch > 14) != (prev > 14); + if (xband && first_dt < 0) { + first_dt = dt; first_from = prev; first_to = ch; /* kept apart */ + } else if (xband) { cross[nc++] = dt; } else { same[ns++] = dt; @@ -309,7 +310,8 @@ static int cmd_chanlat(int argc, char **argv) { printf("chanlat %d hops over %s (CPU %d MHz):\n", n, (argc >= 3) ? argv[2] : CHANLAT_DEFAULT, CONFIG_ESP_DEFAULT_CPU_FREQ_MHZ); if (first_dt >= 0) - printf(" first hop %u -> %u: %ld us\n", first_from, first_to, (long)first_dt); + printf(" first band change %u -> %u: %ld us (not in the stats below)\n", + first_from, first_to, (long)first_dt); chanlat_report("same band", same, ns); chanlat_report("cross band", cross, nc); if (errs) printf(" %d hops failed (first: %s)\n", errs, esp_err_to_name(first_err)); diff --git a/firmware/spikes/main/radio.c b/firmware/spikes/main/radio.c index fd9026e..b293824 100644 --- a/firmware/spikes/main/radio.c +++ b/firmware/spikes/main/radio.c @@ -155,10 +155,10 @@ static void rx_cb(void *buf, wifi_promiscuous_pkt_type_t type) { /* ------------------------------------------------------------------------ */ bool radio_channel_supported(uint8_t ch) { - if (ch >= 1 && ch <= 14) return true; + if (ch >= 1 && ch <= 13) return true; /* 14 is Japan-only 802.11b */ #if SOC_WIFI_SUPPORT_5G if (ch >= 36 && ch <= 48 && ch % 4 == 0) return true; /* UNII-1, no DFS */ - if (ch >= 149 && ch <= 177 && ch % 4 == 1) return true; /* UNII-3 / SRD */ + if (ch >= 149 && ch <= 173 && ch % 4 == 1) return true; /* SRD band, <= 5875 MHz */ #endif return false; } diff --git a/firmware/spikes/main/radio.h b/firmware/spikes/main/radio.h index 3cb0e1a..1432811 100644 --- a/firmware/spikes/main/radio.h +++ b/firmware/spikes/main/radio.h @@ -74,10 +74,12 @@ typedef void (*radio_data_handler_t)(const uint8_t dst[6], const uint8_t src[6], int radio_init(uint8_t channel); /* - * Whether this chip can be put on the channel: 1..14 everywhere, and on a 5 GHz - * chip (ESP32-C5) also 36..48 and 149..177 (steps of 4). The DFS channels - * 52..144 are left out on purpose: transmitting there needs radar detection. - * The driver's country table has the final say (radio_set_channel fails). + * Whether this chip can be put on the channel: 1..13 everywhere, and on a 5 GHz + * chip (ESP32-C5) also 36..48 and 149..173 (steps of 4; 173 ends at 5875 MHz, + * the top of the EU short-range-device band). The DFS channels 52..144 are left + * out on purpose: transmitting there needs radar detection. This list is the + * real limit: with the driver's default country ("01", 802.11d) the driver + * itself accepts 1..14 and every 5 GHz channel. */ bool radio_channel_supported(uint8_t channel); From 22074fe0cb47da1a8410ebcc7f88d1b6675a1537 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 21 Oct 2026 20:21:46 +0000 Subject: [PATCH 39/99] feat(c5): probe where the FCS sits in promiscuous buffers, check IPv6 lengths On the C5 every AWDL action frame failed the FCS check (fcs_ok=0, fcs_bad=78), while parsing succeeded because the TLV parser tolerates a truncated last TLV. Either the 4 bytes at sig_len - 4 are not the FCS on this chip, or the ROM CRC call used a different convention. To settle it: - The check now uses its own CRC-32 (nibble table, verified against zlib) instead of esp_rom_crc32_le. - Besides sig_len - 4 it also looks for the FCS right after sig_len and after dump_len (Wi-Fi MAC v3 field, "MPDU length excluding the FCS"), and keeps a snapshot of the last frame: console `rxprobe`. - Every received IPv6 MSDU is checked against its own length field (ip6_short: real bytes cut off, ip6_long: trailing bytes kept), shown by console `stats` and the new bridge `checks` command. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01XzgaN4Bv1m8Ka9iqo7QVUN --- docs/SPIKES.md | 1 + firmware/spikes/main/bridge.c | 13 +++++++ firmware/spikes/main/console.c | 23 ++++++++++-- firmware/spikes/main/radio.c | 66 ++++++++++++++++++++++++++++++---- firmware/spikes/main/radio.h | 20 +++++++++++ tools/awdl_bridge.py | 4 +-- 6 files changed, 116 insertions(+), 11 deletions(-) diff --git a/docs/SPIKES.md b/docs/SPIKES.md index febccd3..5b4c2c8 100644 --- a/docs/SPIKES.md +++ b/docs/SPIKES.md @@ -50,6 +50,7 @@ beide, die Namen unter `/dev/serial/by-id/` verwenden. | `peers` | gesammelte Peer-Ereignisse ausgeben (Name, RSSI, Kanalsequenz, Zeitstempel) | | `stats` | RX/TX-Zähler, inkl. FCS- und Frame-Typ-Prüfung | | `reset` | Zähler nullen | +| `rxprobe` | Längenfelder und letzte Bytes des letzten AWDL-Action-Frames (wo steht die FCS?) | | `chanlat [n] [c1,c2,...]` | Latenz von `esp_wifi_set_channel` über n Wechsel durch die Kanalliste messen (Standard C3: `1,6,11`, C5: `6,44,149`); getrennt nach Wechseln im selben Band und zwischen den Bändern | | `txpwr [q]` | Obergrenze der Sendeleistung anzeigen/setzen (0,25-dBm-Schritte, 80 = 20 dBm) | | `usbbench [kib]` | Datenstrom für die USB-Durchsatzmessung auf dem Host | diff --git a/firmware/spikes/main/bridge.c b/firmware/spikes/main/bridge.c index 1b72a5b..e262fff 100644 --- a/firmware/spikes/main/bridge.c +++ b/firmware/spikes/main/bridge.c @@ -256,6 +256,19 @@ static void handle_cmd(const uint8_t *p, size_t n) { } } cmd_reply(out); + } else if (strcmp(cmd, "checks") == 0) { + /* Bring-up checks on a new chip: is the frame length right (FCS where + * we expect it, IPv6 packets neither cut short nor padded)? */ + radio_stats_t st; + radio_get_stats(&st); + snprintf(out, sizeof(out), + "fcs_ok=%u fcs_bad=%u fcs_at_sig=%u fcs_at_dump=%u type_mm=%u " + "ip6_short=%u ip6_long=%u", + (unsigned)st.rx_fcs_ok, (unsigned)st.rx_fcs_bad, + (unsigned)st.rx_fcs_at_sig, (unsigned)st.rx_fcs_at_dump, + (unsigned)st.rx_type_mismatch, (unsigned)st.rx_ip6_short, + (unsigned)st.rx_ip6_long); + cmd_reply(out); } else if (strcmp(cmd, "testframe") == 0) { send_test_frame(); cmd_reply("sent testframe"); diff --git a/firmware/spikes/main/console.c b/firmware/spikes/main/console.c index 11bb959..0cb62b6 100644 --- a/firmware/spikes/main/console.c +++ b/firmware/spikes/main/console.c @@ -204,13 +204,31 @@ static int cmd_stats(int argc, char **argv) { s.rx_action, s.rx_data, s.rx_other_bssid, s.rx_parse_err); printf("rx data: msdu=%llu amsdu=%llu dup=%llu data_perr=%llu\n", s.rx_msdu, s.rx_amsdu, s.rx_dup, s.rx_data_perr); - printf("rx checks: fcs_ok=%llu fcs_bad=%llu type_mismatch=%llu\n", - s.rx_fcs_ok, s.rx_fcs_bad, s.rx_type_mismatch); + printf("rx checks: fcs_ok=%llu fcs_bad=%llu fcs_at_sig=%llu fcs_at_dump=%llu " + "type_mismatch=%llu ip6_short=%llu ip6_long=%llu\n", + s.rx_fcs_ok, s.rx_fcs_bad, s.rx_fcs_at_sig, s.rx_fcs_at_dump, + s.rx_type_mismatch, s.rx_ip6_short, s.rx_ip6_long); printf("tx: ok=%llu err=%llu tx_done: ok=%llu fail=%llu unicast data: ack=%llu noack=%llu\n", s.tx_ok, s.tx_err, s.tx_done_ok, s.tx_done_fail, s.tx_uc_ack, s.tx_uc_noack); return 0; } +/* Length fields and tail bytes of the last AWDL action frame (bring-up). */ +static int cmd_rxprobe(int argc, char **argv) { + (void)argc; (void)argv; + radio_rx_probe_t p; + radio_get_rx_probe(&p); + if (!p.count) { printf("no AWDL action frame yet (rx on?)\n"); return 1; } + printf("frames=%lu sig_len=%u dump_len=%u rx_state=%u\n", (unsigned long)p.count, + p.sig_len, p.dump_len, p.rx_state); + printf("bytes [sig_len-8, sig_len+8):"); + for (int i = 0; i < 16; i++) printf("%s%02x", i == 8 ? " | " : " ", p.tail[i]); + printf("\nFCS found at: sig_len-4 %s, sig_len %s, dump_len %s\n", + p.fcs_at_sig_minus4 ? "yes" : "no", p.fcs_at_sig ? "yes" : "no", + p.fcs_at_dump ? "yes" : "no"); + return 0; +} + static int cmd_reset(int argc, char **argv) { (void)argc; (void)argv; radio_reset_stats(); @@ -373,6 +391,7 @@ void console_register_commands(void) { reg("sync", "print master, sync-tree height, sync error, peer count", cmd_sync); reg("stats", "print rx/tx counters", cmd_stats); reg("reset", "zero the counters", cmd_reset); + reg("rxprobe", "length fields of the last AWDL action frame (FCS check)", cmd_rxprobe); reg("chanlat", "chanlat [n] [c1,c2,...] - measure channel-switch latency", cmd_chanlat); reg("txpwr", "txpwr [8..84] - show/set TX power ceiling (0.25 dBm)", cmd_txpwr); reg("usbbench", "usbbench [kib] - stream data for host-side USB timing", cmd_usbbench); diff --git a/firmware/spikes/main/radio.c b/firmware/spikes/main/radio.c index b293824..1818535 100644 --- a/firmware/spikes/main/radio.c +++ b/firmware/spikes/main/radio.c @@ -15,7 +15,6 @@ #include "esp_timer.h" #include "esp_wifi.h" #include "esp_private/wifi.h" /* esp_wifi_set_tx_done_cb: stable across IDF 5.1..6.x */ -#include "esp_rom_crc.h" #include "nvs_flash.h" #include "sdkconfig.h" #include "soc/soc_caps.h" @@ -39,6 +38,7 @@ static awdl_dedup_t s_dedup; /* touched only by rx_cb (Wi-Fi task) * * EU; 11 dBm conducted leaves room for a few dBi of antenna gain). */ #define TXPWR_SRD_CAP_QDBM 44 static int8_t s_txpwr_user = CONFIG_ESP_PHY_MAX_WIFI_TX_POWER * 4; +static radio_rx_probe_t s_probe; /* written by rx_cb under s_stats_mux */ static inline void stat_inc(uint64_t *field) { portENTER_CRITICAL(&s_stats_mux); @@ -77,9 +77,37 @@ static void tx_done_cb(uint8_t ifidx, uint8_t *data, uint16_t *data_len, bool tx static void deliver_msdu(const uint8_t dst[6], const uint8_t src[6], uint16_t ethertype, const uint8_t *payload, size_t plen, void *ctx) { (*(int *)ctx)++; + if (ethertype == ETHERTYPE_IPV6 && plen >= 40) { + size_t want = 40 + (((size_t)payload[4] << 8) | payload[5]); + if (plen < want) stat_inc(&s_stats.rx_ip6_short); + else if (plen > want) stat_inc(&s_stats.rx_ip6_long); + } if (s_data_handler) s_data_handler(dst, src, ethertype, payload, plen, s_data_ctx); } +/* IEEE CRC-32 (as zlib's crc32), nibble table. Our own rather than the ROM + * one, so the FCS check on a new chip does not depend on the ROM convention. */ +static uint32_t crc32_ieee(const uint8_t *p, int n) { + static const uint32_t t[16] = { + 0x00000000, 0x1db71064, 0x3b6e20c8, 0x26d930ac, 0x76dc4190, 0x6b6b51f4, + 0x4db26158, 0x5005713c, 0xedb88320, 0xf00f9344, 0xd6d6a3e8, 0xcb61b38c, + 0x9b64c2b0, 0x86d3d2d4, 0xa00ae278, 0xbdbdf21c }; + uint32_t c = 0xffffffffu; + while (n-- > 0) { + c ^= *p++; + c = (c >> 4) ^ t[c & 15]; + c = (c >> 4) ^ t[c & 15]; + } + return ~c; +} + +/* Whether the 4 bytes at f[n] are the FCS of f[0..n) (CRC-32, little endian). */ +static bool fcs_at(const uint8_t *f, int n) { + uint32_t fcs = (uint32_t)f[n] | ((uint32_t)f[n + 1] << 8) | + ((uint32_t)f[n + 2] << 16) | ((uint32_t)f[n + 3] << 24); + return crc32_ieee(f, n) == fcs; +} + /* ---- promiscuous rx callback: filter AWDL BSSID, parse, enqueue ---- */ static void rx_cb(void *buf, wifi_promiscuous_pkt_type_t type) { if (type != WIFI_PKT_MGMT && type != WIFI_PKT_DATA) return; @@ -108,12 +136,30 @@ static void rx_cb(void *buf, wifi_promiscuous_pkt_type_t type) { if (ftype == WIFI_PKT_MGMT) { /* Is sig_len really MPDU + FCS on this chip? The 4 bytes we strip must - * be the CRC-32 of the frame (esp_rom_crc32_le is the IEEE CRC-32). - * Action frames only: rare and short, so the CRC is cheap. */ - uint32_t fcs = (uint32_t)frame[len] | ((uint32_t)frame[len + 1] << 8) | - ((uint32_t)frame[len + 2] << 16) | ((uint32_t)frame[len + 3] << 24); - stat_inc(esp_rom_crc32_le(0, frame, (uint32_t)len) == fcs - ? &s_stats.rx_fcs_ok : &s_stats.rx_fcs_bad); + * be the CRC-32 of the frame. If not, look for the FCS right after + * sig_len and after dump_len, and keep a snapshot for `rxprobe`. + * Action frames only: rare and short (reading 8 bytes past sig_len + * stays inside the RX buffer), so this is cheap. */ + int sig = len + 4, dump = 0; +#if CONFIG_SOC_WIFI_MAC_VERSION_NUM == 3 + dump = pkt->rx_ctrl.dump_len; +#endif + bool a = fcs_at(frame, len); + bool b = sig <= 1500 && fcs_at(frame, sig); + bool c = dump >= (int)IEEE80211_HDR_LEN && dump <= 1500 && fcs_at(frame, dump); + portENTER_CRITICAL(&s_stats_mux); + if (a) s_stats.rx_fcs_ok++; else s_stats.rx_fcs_bad++; + if (b) s_stats.rx_fcs_at_sig++; + if (c) s_stats.rx_fcs_at_dump++; + s_probe.count++; + s_probe.sig_len = (uint16_t)sig; + s_probe.dump_len = (uint16_t)dump; + s_probe.rx_state = (uint8_t)pkt->rx_ctrl.rx_state; + if (sig <= 1500) memcpy(s_probe.tail, frame + sig - 8, sizeof(s_probe.tail)); + s_probe.fcs_at_sig_minus4 = a; + s_probe.fcs_at_sig = b; + s_probe.fcs_at_dump = c; + portEXIT_CRITICAL(&s_stats_mux); awdl_peer_view_t pv; int r = awdl_parse_action(frame, (size_t)len, &pv); @@ -316,6 +362,12 @@ void radio_get_stats(radio_stats_t *out) { portEXIT_CRITICAL(&s_stats_mux); } +void radio_get_rx_probe(radio_rx_probe_t *out) { + portENTER_CRITICAL(&s_stats_mux); + *out = s_probe; + portEXIT_CRITICAL(&s_stats_mux); +} + void radio_reset_stats(void) { portENTER_CRITICAL(&s_stats_mux); memset(&s_stats, 0, sizeof(s_stats)); diff --git a/firmware/spikes/main/radio.h b/firmware/spikes/main/radio.h index 1432811..721047f 100644 --- a/firmware/spikes/main/radio.h +++ b/firmware/spikes/main/radio.h @@ -44,8 +44,28 @@ typedef struct { uint64_t rx_type_mismatch; uint64_t rx_fcs_ok; uint64_t rx_fcs_bad; + /* Where the FCS really is when it is not at sig_len - 4: right after + * sig_len (sig_len would then exclude it), or after dump_len (Wi-Fi MAC v3 + * chips such as the C5 report that field). Counted for action frames. */ + uint64_t rx_fcs_at_sig; + uint64_t rx_fcs_at_dump; + /* IPv6 MSDUs shorter / longer than their IPv6 header says: a wrong frame + * length cuts real bytes off (short) or keeps trailing bytes (long). */ + uint64_t rx_ip6_short; + uint64_t rx_ip6_long; } radio_stats_t; +/* Snapshot of the last AWDL action frame's length fields, for bring-up. */ +typedef struct { + uint32_t count; /* action frames seen so far */ + uint16_t sig_len; /* rx_ctrl.sig_len */ + uint16_t dump_len; /* rx_ctrl.dump_len (Wi-Fi MAC v3), else 0 */ + uint8_t rx_state; /* rx_ctrl.rx_state, 0 = no error */ + uint8_t tail[16]; /* frame bytes [sig_len - 8, sig_len + 8) */ + bool fcs_at_sig_minus4, fcs_at_sig, fcs_at_dump; +} radio_rx_probe_t; +void radio_get_rx_probe(radio_rx_probe_t *out); + /* * Handler for a received, parsed AWDL action frame. Invoked from Wi-Fi task * context inside the promiscuous callback, so it MUST be quick and must not diff --git a/tools/awdl_bridge.py b/tools/awdl_bridge.py index 6bb336d..fe364b1 100644 --- a/tools/awdl_bridge.py +++ b/tools/awdl_bridge.py @@ -26,7 +26,7 @@ # are only logged. Suppressed with --no-config. # # Interactive stdin commands (forwarded to the device): -# mac | stats | radio | tx | node | chan [n] | ping | testframe | resync +# mac | stats | radio | tx | node | chan [n] | checks | ping | testframe | resync # # Needs root: creating a TAP and running `ip link` both require CAP_NET_ADMIN. # The interface is not persistent - it disappears when this process exits. @@ -196,7 +196,7 @@ def main() -> int: print(f"{args.dev} up (mtu {args.mtu}, mac {mac or 'kernel-assigned'}); " f"bridging {args.port}") - print("commands: mac | stats | radio | tx | node | chan [n] | ping | testframe | resync" + print("commands: mac | stats | radio | tx | node | chan [n] | checks | ping | testframe | resync" " (Ctrl-C to quit)") # The C3 keeps running across host sessions (its USB port does not reset From 5323d4a1c4e4c20e0ed448df9d1e0da40409b085 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 21 Oct 2026 20:37:54 +0000 Subject: [PATCH 40/99] feat(c5): find the real end of received frames from the AWDL TLV chain The C5 probe showed no FCS anywhere near the frame end (identical bytes at every candidate offset across different frames, then a RAM address), and dump_len 4 bytes above sig_len, opposite to the ESP-IDF field docs. To pin the frame length down without an FCS, walk the action frame's TLV chain (new awdl_action_tlv_end, host-tested) over the larger reported length and count whether it ends at sig_len - 4 (FCS included, as on the C3), at sig_len, at dump_len or elsewhere; `stats` and `rxprobe` show it. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01XzgaN4Bv1m8Ka9iqo7QVUN --- firmware/spikes/components/awdl/awdl_frame.c | 15 ++++++++++++ .../components/awdl/include/awdl_frame.h | 8 +++++++ .../awdl/test/host/test_awdl_frame.c | 23 +++++++++++++++++++ firmware/spikes/main/console.c | 6 ++++- firmware/spikes/main/radio.c | 9 ++++++++ firmware/spikes/main/radio.h | 8 +++++++ 6 files changed, 68 insertions(+), 1 deletion(-) diff --git a/firmware/spikes/components/awdl/awdl_frame.c b/firmware/spikes/components/awdl/awdl_frame.c index 07228cb..a8565b1 100644 --- a/firmware/spikes/components/awdl/awdl_frame.c +++ b/firmware/spikes/components/awdl/awdl_frame.c @@ -381,6 +381,21 @@ static void parse_chanseq_tlv(const uint8_t *v, size_t vlen, awdl_peer_view_t *o out->has_chanseq = true; } +size_t awdl_action_tlv_end(const uint8_t *buf, size_t len) { + size_t a = IEEE80211_HDR_LEN; + if (!buf || len < a + 16) return 0; + if (buf[a] != AWDL_CATEGORY_VENDOR || memcmp(buf + a + 1, AWDL_OUI, 3) != 0 || + buf[a + 4] != AWDL_ACTION_TYPE) + return 0; + size_t off = a + 16; + while (off + 3 <= len) { + size_t l = (size_t)buf[off + 1] | ((size_t)buf[off + 2] << 8); + if (off + 3 + l > len) break; + off += 3 + l; + } + return off; +} + int awdl_parse_action(const uint8_t *buf, size_t len, awdl_peer_view_t *out) { if (!buf || !out) return AWDL_ERR_ARG; memset(out, 0, sizeof(*out)); diff --git a/firmware/spikes/components/awdl/include/awdl_frame.h b/firmware/spikes/components/awdl/include/awdl_frame.h index 50189af..a7e6185 100644 --- a/firmware/spikes/components/awdl/include/awdl_frame.h +++ b/firmware/spikes/components/awdl/include/awdl_frame.h @@ -264,6 +264,14 @@ typedef void (*awdl_msdu_cb_t)(const uint8_t dst[6], const uint8_t src[6], */ int awdl_parse_data_msdus(const uint8_t *buf, size_t len, awdl_msdu_cb_t cb, void *ctx); +/* + * Offset just past the last complete TLV of an AWDL action frame, walking the + * TLV chain from the action header over at most len bytes; 0 if buf is not an + * AWDL action frame. With len a little larger than the frame, this shows where + * the frame really ends (used to find out how a chip reports frame lengths). + */ +size_t awdl_action_tlv_end(const uint8_t *buf, size_t len); + /* Map an operating-class-encoded or legacy channel pair to a channel number. */ uint8_t awdl_chan_number(const uint8_t enc_bytes[2], enum awdl_chan_enc enc); diff --git a/firmware/spikes/components/awdl/test/host/test_awdl_frame.c b/firmware/spikes/components/awdl/test/host/test_awdl_frame.c index 6307670..1f41c96 100644 --- a/firmware/spikes/components/awdl/test/host/test_awdl_frame.c +++ b/firmware/spikes/components/awdl/test/host/test_awdl_frame.c @@ -349,6 +349,28 @@ static void test_reject_garbage(void) { CHECK(awdl_parse_action(junk, 8, &pv) == AWDL_ERR_SHORT, "short frame not SHORT"); } +/* awdl_action_tlv_end finds the real end of a frame inside a longer buffer, as + * when a chip's reported length is off by the 4-byte FCS. */ +static void test_action_tlv_end(void) { + uint8_t self_mac[6] = {0x02, 0xaa, 0xbb, 0xcc, 0xdd, 0xee}; + awdl_self_t self; + awdl_self_init(&self, self_mac, "espdrop-c5", 6); + uint8_t buf[1600]; + int n = awdl_build_action(buf, sizeof(buf), &self, AWDL_SUBTYPE_MIF, 0, AWDL_EAW_TU, 0); + CHECK(n > 0, "build failed: %d", n); + CHECK(awdl_action_tlv_end(buf, (size_t)n) == (size_t)n, "exact length"); + /* stale buffer bytes after the frame, as seen on the C5 (00 00 d4 36 ...) */ + const uint8_t tail[8] = {0x00, 0x00, 0xd4, 0x36, 0x82, 0x40, 0xd4, 0x36}; + memcpy(buf + n, tail, sizeof(tail)); + CHECK(awdl_action_tlv_end(buf, (size_t)n + 8) == (size_t)n, "end found despite tail"); + /* cut 4 bytes off: the last TLV no longer fits, so the walk stops before */ + size_t cut = awdl_action_tlv_end(buf, (size_t)n - 4); + CHECK(cut < (size_t)n - 4 && cut > IEEE80211_HDR_LEN + 16, "cut frame: %zu", cut); + uint8_t junk[64]; + memset(junk, 0xA5, sizeof(junk)); + CHECK(awdl_action_tlv_end(junk, sizeof(junk)) == 0, "garbage is no action frame"); +} + static void test_sync_election_parse(void) { uint8_t self_mac[6] = {0x02, 0xaa, 0xbb, 0xcc, 0xdd, 0xee}; awdl_self_t self; @@ -747,6 +769,7 @@ int main(void) { test_dedup(); test_data_amsdu(); test_reject_garbage(); + test_action_tlv_end(); test_sync_election_parse(); test_sync_math(); test_election_run(); diff --git a/firmware/spikes/main/console.c b/firmware/spikes/main/console.c index 0cb62b6..2b95f18 100644 --- a/firmware/spikes/main/console.c +++ b/firmware/spikes/main/console.c @@ -204,6 +204,8 @@ static int cmd_stats(int argc, char **argv) { s.rx_action, s.rx_data, s.rx_other_bssid, s.rx_parse_err); printf("rx data: msdu=%llu amsdu=%llu dup=%llu data_perr=%llu\n", s.rx_msdu, s.rx_amsdu, s.rx_dup, s.rx_data_perr); + printf("rx frame end: at sig_len-4=%llu at sig_len=%llu at dump_len=%llu other=%llu\n", + s.rx_end_sig4, s.rx_end_sig, s.rx_end_dump, s.rx_end_other); printf("rx checks: fcs_ok=%llu fcs_bad=%llu fcs_at_sig=%llu fcs_at_dump=%llu " "type_mismatch=%llu ip6_short=%llu ip6_long=%llu\n", s.rx_fcs_ok, s.rx_fcs_bad, s.rx_fcs_at_sig, s.rx_fcs_at_dump, @@ -223,7 +225,9 @@ static int cmd_rxprobe(int argc, char **argv) { p.sig_len, p.dump_len, p.rx_state); printf("bytes [sig_len-8, sig_len+8):"); for (int i = 0; i < 16; i++) printf("%s%02x", i == 8 ? " | " : " ", p.tail[i]); - printf("\nFCS found at: sig_len-4 %s, sig_len %s, dump_len %s\n", + printf("\nTLVs end at %u (sig_len-4=%u, sig_len=%u, dump_len=%u)\n", p.tlv_end, + (unsigned)(p.sig_len - 4), p.sig_len, p.dump_len); + printf("FCS found at: sig_len-4 %s, sig_len %s, dump_len %s\n", p.fcs_at_sig_minus4 ? "yes" : "no", p.fcs_at_sig ? "yes" : "no", p.fcs_at_dump ? "yes" : "no"); return 0; diff --git a/firmware/spikes/main/radio.c b/firmware/spikes/main/radio.c index 1818535..cd8885f 100644 --- a/firmware/spikes/main/radio.c +++ b/firmware/spikes/main/radio.c @@ -147,7 +147,16 @@ static void rx_cb(void *buf, wifi_promiscuous_pkt_type_t type) { bool a = fcs_at(frame, len); bool b = sig <= 1500 && fcs_at(frame, sig); bool c = dump >= (int)IEEE80211_HDR_LEN && dump <= 1500 && fcs_at(frame, dump); + /* The TLV chain shows the true end without any FCS: walk it over the + * larger of the two reported lengths. */ + int cap = dump > sig ? dump : sig; + int end = cap <= 1500 ? (int)awdl_action_tlv_end(frame, (size_t)cap) : 0; portENTER_CRITICAL(&s_stats_mux); + if (end == len) s_stats.rx_end_sig4++; + else if (end == sig) s_stats.rx_end_sig++; + else if (dump && end == dump) s_stats.rx_end_dump++; + else s_stats.rx_end_other++; + s_probe.tlv_end = (uint16_t)end; if (a) s_stats.rx_fcs_ok++; else s_stats.rx_fcs_bad++; if (b) s_stats.rx_fcs_at_sig++; if (c) s_stats.rx_fcs_at_dump++; diff --git a/firmware/spikes/main/radio.h b/firmware/spikes/main/radio.h index 721047f..9562186 100644 --- a/firmware/spikes/main/radio.h +++ b/firmware/spikes/main/radio.h @@ -49,6 +49,13 @@ typedef struct { * chips such as the C5 report that field). Counted for action frames. */ uint64_t rx_fcs_at_sig; uint64_t rx_fcs_at_dump; + /* Where the TLV chain of an action frame really ends (needs no FCS): + * at sig_len - 4 (FCS included in sig_len, as on the C3), at sig_len (no + * FCS in sig_len), at dump_len, or elsewhere. */ + uint64_t rx_end_sig4; + uint64_t rx_end_sig; + uint64_t rx_end_dump; + uint64_t rx_end_other; /* IPv6 MSDUs shorter / longer than their IPv6 header says: a wrong frame * length cuts real bytes off (short) or keeps trailing bytes (long). */ uint64_t rx_ip6_short; @@ -62,6 +69,7 @@ typedef struct { uint16_t dump_len; /* rx_ctrl.dump_len (Wi-Fi MAC v3), else 0 */ uint8_t rx_state; /* rx_ctrl.rx_state, 0 = no error */ uint8_t tail[16]; /* frame bytes [sig_len - 8, sig_len + 8) */ + uint16_t tlv_end; /* where the TLV chain ends (awdl_action_tlv_end) */ bool fcs_at_sig_minus4, fcs_at_sig, fcs_at_dump; } radio_rx_probe_t; void radio_get_rx_probe(radio_rx_probe_t *out); From 5cb43bb0620c8d2311344f32c37d244f12ea0c92 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 21 Oct 2026 20:44:43 +0000 Subject: [PATCH 41/99] docs(c5): record the measured frame layout, fcs_bad is expected on the C5 On the C5 the TLV chain of every AWDL action frame ends at sig_len - 4, exactly as on the C3, so our frame length is right and no change is needed. The 4 bytes there are not the FCS (the C5 does not pass it on) and dump_len is sig_len + 4, so fcs_bad counts every frame on the C5. The C5 test now checks the frame-end and IPv6-length counters instead. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01XzgaN4Bv1m8Ka9iqo7QVUN --- docs/SPIKES.md | 6 ++++-- firmware/spikes/main/radio.c | 4 ++++ 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/docs/SPIKES.md b/docs/SPIKES.md index 5b4c2c8..3d902fd 100644 --- a/docs/SPIKES.md +++ b/docs/SPIKES.md @@ -137,8 +137,10 @@ Radarerkennung). 1. **Chip und USB.** `info` zeigt `chip: esp32c5 rev v1.0` (oder v1.2). ESP-IDF 6.0.3 unterstützt v1.0 und v1.2; die Vorserien-Revision v0.1 startet nicht. 2. **Spikes 1–3 auf Kanal 6** wie beim C3. Zusätzlich in `stats`: - `fcs_ok` steigt mit `action`, `fcs_bad` und `type_mismatch` bleiben 0. - Falls nicht, stimmt auf dem C5 die Länge im Promiscuous-Puffer nicht – bitte melden. + `rx frame end: at sig_len-4` steigt mit `action`, alle anderen „frame end“-Zähler, + `type_mismatch`, `ip6_short` und `ip6_long` bleiben 0. Dann stimmt die Frame-Länge. + (`fcs_ok` steigt nur auf dem C3: der C5 liefert an der Stelle der FCS andere + Bytes, dort zählt jeder Frame als `fcs_bad`. Gemessen, kein Fehler.) 3. **Welchen 5-GHz-Kanal nutzt dein iPhone?** Geht schon auf Kanal 6: `rx on`, am iPhone das Teilen-Menü öffnen, `peers`. In der Kanalsequenz steht 44 oder 149. 4. **Empfang auf 5 GHz.** `chan 44` (bzw. 149), `rx on`, Teilen-Menü offen, diff --git a/firmware/spikes/main/radio.c b/firmware/spikes/main/radio.c index cd8885f..03e0cbb 100644 --- a/firmware/spikes/main/radio.c +++ b/firmware/spikes/main/radio.c @@ -138,6 +138,10 @@ static void rx_cb(void *buf, wifi_promiscuous_pkt_type_t type) { /* Is sig_len really MPDU + FCS on this chip? The 4 bytes we strip must * be the CRC-32 of the frame. If not, look for the FCS right after * sig_len and after dump_len, and keep a snapshot for `rxprobe`. + * Measured on the C5: the TLV chain ends at sig_len - 4 as on the C3, + * so the length is right, but the 4 bytes there are not the FCS (the + * C5 does not hand it over), and dump_len is sig_len + 4. So fcs_bad + * is expected on the C5; the rx_end_* counters are what matters. * Action frames only: rare and short (reading 8 bytes past sig_len * stays inside the RX buffer), so this is cheap. */ int sig = len + 4, dump = 0; From 38a7721068f39b75147354cde4812a7badef70d2 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 21 Oct 2026 20:50:23 +0000 Subject: [PATCH 42/99] docs(c5): record the first ESP32-C5 measurements German iPhones use AWDL channel 44 (5 of 16 slots with the share sheet open, one on 6); channel switches take 0.7-1.5 ms (first band change up to 2.4 ms), well below the 65 ms slot; RX and TX on 44 work; the frame length matches the C3 layout. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01XzgaN4Bv1m8Ka9iqo7QVUN --- docs/SPIKES.md | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/docs/SPIKES.md b/docs/SPIKES.md index 3d902fd..1b880ae 100644 --- a/docs/SPIKES.md +++ b/docs/SPIKES.md @@ -161,6 +161,23 @@ Radarerkennung). dann wie in M2.4 pingen bzw. AirDrop testen. Der Knoten bleibt dabei auf einem Kanal und übernimmt die 5-GHz-Slots des Masters, ganz ohne Kanalwechsel. +### Ergebnisse ESP32-C5 (Waveshare ESP32-C5-Zero, rev v1.0, 240 MHz, ESP-IDF 6.0.3) + +- **Frame-Länge:** wie beim C3, Frame = `sig_len − 4` (TLV-Kette endet dort bei + allen Frames). Die 4 Bytes sind auf dem C5 aber nicht die FCS, `dump_len` ist + `sig_len + 4`; `fcs_bad` zählt daher jeden Frame – erwartet. IPv6-Längen stimmen. +- **iPhone in DE nutzt Kanal 44** (nicht 149). Sequenz im Leerlauf + `6 0 44 0 0 0 0 0 6 0 44 0 …`, mit offenem Teilen-Menü + `44 44 44 0 0 0 0 0 6 44 44 0 …`: 5 von 16 Slots auf 44, nur einer auf 6. +- **Kanalwechsel** (`esp_wifi_set_channel`, Mittel / p99): gleicher Kanal 0,70 / + 0,71 ms, innerhalb 5 GHz 0,92–1,03 / 1,01–1,15 ms, zwischen 2,4 und 5 GHz + 1,29–1,36 / 1,38–1,50 ms, erster Bandwechsel 1,7–2,4 ms. Ein Slot dauert ≈ 65 ms, + ein Wechsel kostet also unter 3 % (MT7921: 8,5 ms). Gemessen ist die Dauer + des API-Aufrufs, nicht die Zeit bis zum ersten Empfang. +- **Empfang und Senden auf Kanal 44** funktionieren: iPhone-Action- und + Datenframes kommen an, eigene MIFs gehen ohne Fehler raus. Die Sendeleistung + meldet der Treiber auf 44 mit 18 dBm. + ## Danach Ergebnisse (auch Fehlschläge) in `docs/UEBERBLICK.md` unter „Offene Fragen" From 6fc593f719fe2131ae1878da3b5c1f2fb85e3931 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 21 Oct 2026 21:14:52 +0000 Subject: [PATCH 43/99] docs(c5): first AirDrop receive works on the C5 bridge at channel 44 A 2.87 MB photo from an iPhone arrived in 12.7 s (about 0.23 MB/s per OpenDrop) with the C5 parked on AWDL channel 44; ping over awdl0 19/20 with every unicast frame ACKed. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01XzgaN4Bv1m8Ka9iqo7QVUN --- docs/SPIKES.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/docs/SPIKES.md b/docs/SPIKES.md index 1b880ae..5b58d61 100644 --- a/docs/SPIKES.md +++ b/docs/SPIKES.md @@ -177,6 +177,11 @@ Radarerkennung). - **Empfang und Senden auf Kanal 44** funktionieren: iPhone-Action- und Datenframes kommen an, eigene MIFs gehen ohne Fehler raus. Die Sendeleistung meldet der Treiber auf 44 mit 18 dBm. +- **Bridge auf Kanal 44** (`chan 44`): Ping zum iPhone 19/20, alle 20 Unicast-Frames + vom iPhone per ACK bestätigt (C3 auf Kanal 6: ~66 %), RTT 4–440 ms (wir senden + nur in den 44er-Slots des iPhones). +- **AirDrop-Empfang (M2.4) funktioniert:** Foto vom iPhone, 2,87 MB in 12,7 s + (≈ 0,23 MB/s laut OpenDrop), korrekt entpackt. ## Danach From 0c48dec16d31159c70b4cb774dfaf80ec1635ae1 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 21 Oct 2026 21:45:14 +0000 Subject: [PATCH 44/99] feat(bridge): usbtest to measure the device->host ceiling, deeper RX queue The C5 measurement run showed the radio is not the limit (the iPhone was on channel 44 in 15 of 16 slots and every slot was ours, 99.6 % of unicast frames ACKed) but 299 of ~41800 received frames were dropped because the device->host queue (depth 32) was full, which makes TCP back off. - `usbtest [kib]` streams full-size dummy frames through the same COBS/USB path and reports kB/s, so the USB/host ceiling can be measured directly. - `stats` also reports bytes_to_host. - The RX forward queue tries depths 64 and 48 first (the heap reserve rule still picks a smaller one where memory is short, e.g. on the C3), and the USB TX ring grows from 2 to 8 KB so the FIFO keeps being fed while the next frame is encoded. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01XzgaN4Bv1m8Ka9iqo7QVUN --- firmware/spikes/main/bridge.c | 50 +++++++++++++++++++++++++++++------ tools/awdl_bridge.py | 4 +-- 2 files changed, 44 insertions(+), 10 deletions(-) diff --git a/firmware/spikes/main/bridge.c b/firmware/spikes/main/bridge.c index e262fff..d67f14e 100644 --- a/firmware/spikes/main/bridge.c +++ b/firmware/spikes/main/bridge.c @@ -15,6 +15,7 @@ #include "esp_log.h" #include "esp_mac.h" #include "esp_system.h" /* esp_get_free_heap_size */ +#include "esp_timer.h" #include "freertos/FreeRTOS.h" #include "freertos/task.h" #include "freertos/queue.h" @@ -27,15 +28,19 @@ #define BRIDGE_MAX_PAYLOAD 1600 #define BRIDGE_MAX_MSG (1 + BRIDGE_MAX_PAYLOAD) /* type + payload */ #define USB_RX_BUF 1024 -#define USB_TX_BUF 2048 +/* Large enough for several full frames, so the driver can keep feeding the + * 64-byte USB FIFO while the forward task COBS-encodes the next frame. */ +#define USB_TX_BUF 8192 /* Device->host RX forwarding: one Ethernet frame per queue slot. The Wi-Fi task * only builds+enqueues (never blocks on USB); a dedicated task drains and sends. */ #define BRIDGE_RX_PAYLOAD_MAX 1500 /* L3 payload cap (awdl0 MTU) */ /* One AWDL window delivers ~15 frames in ~50 ms (airdrop-mt7921 FINDINGS), much - * faster than USB drains them, so the queue must absorb a whole burst. Try the - * big depth first and fall back if the heap is short. */ -static const uint8_t k_rx_q_depths[] = {32, 24, 16, 8}; + * faster than USB drains them, so the queue must absorb a whole burst; on 5 GHz + * an iPhone sends in 15 of 16 slots, so bursts are longer still (C5: 299 drops + * at depth 32 in one transfer). Try the big depth first and fall back if the + * heap is short. */ +static const uint8_t k_rx_q_depths[] = {64, 48, 32, 24, 16, 8}; /* Wi-Fi allocates its RX/TX buffers dynamically from the same heap, so never * take the last of it: a larger depth is used only if this much stays free. */ #define BRIDGE_HEAP_RESERVE (64u * 1024u) @@ -65,6 +70,7 @@ static uint32_t s_bytes_from_host; * 32-bit aligned loads/stores are atomic on the C3, and a stale stats read is * harmless, so these are volatile rather than locked. */ static volatile uint32_t s_frames_to_host; +static volatile uint32_t s_bytes_to_host; static volatile uint32_t s_rx_drops; /* Sink for host->device Ethernet frames (set by app_main to the AWDL TX path). */ @@ -118,8 +124,10 @@ static void bridge_fwd_task(void *arg) { static rx_frame_t fr; for (;;) { if (xQueueReceive(s_rx_fwd_q, &fr, portMAX_DELAY) == pdTRUE) { - if (bridge_send(BRIDGE_MSG_FRAME, fr.eth, fr.len) == 0) + if (bridge_send(BRIDGE_MSG_FRAME, fr.eth, fr.len) == 0) { s_frames_to_host++; + s_bytes_to_host += fr.len; + } } } } @@ -171,10 +179,10 @@ static void handle_cmd(const uint8_t *p, size_t n) { cmd_reply(out); } else if (strcmp(cmd, "stats") == 0) { snprintf(out, sizeof(out), - "frames_from_host=%u bytes_from_host=%u frames_to_host=%u rx_drops=%u " - "rxq=%u heap=%u heap_min=%u", + "frames_from_host=%u bytes_from_host=%u frames_to_host=%u bytes_to_host=%u " + "rx_drops=%u rxq=%u heap=%u heap_min=%u", (unsigned)s_frames_from_host, (unsigned)s_bytes_from_host, - (unsigned)s_frames_to_host, (unsigned)s_rx_drops, + (unsigned)s_frames_to_host, (unsigned)s_bytes_to_host, (unsigned)s_rx_drops, (unsigned)s_rx_q_depth, (unsigned)esp_get_free_heap_size(), (unsigned)esp_get_minimum_free_heap_size()); cmd_reply(out); @@ -269,6 +277,32 @@ static void handle_cmd(const uint8_t *p, size_t n) { (unsigned)st.rx_type_mismatch, (unsigned)st.rx_ip6_short, (unsigned)st.rx_ip6_long); cmd_reply(out); + } else if (strcmp(cmd, "usbtest") == 0 || strncmp(cmd, "usbtest ", 8) == 0) { + /* Device->host ceiling of the bridge path: stream full-size dummy + * Ethernet frames (ethertype 0x88b5, ignored by the host stack) through + * the same COBS/USB path as received AWDL data, and time it. Run it + * while no transfer is going on: this task is busy meanwhile. */ + int kib = cmd[7] ? atoi(cmd + 8) : 1024; + if (kib < 1) kib = 1; + if (kib > 8192) kib = 8192; + static uint8_t f[14 + 1400]; + memset(f, 0xff, 6); + radio_get_self_mac(f + 6); + f[12] = 0x88; f[13] = 0xb5; + memset(f + 14, 0x5a, sizeof(f) - 14); + size_t total = (size_t)kib * 1024, sent = 0; + int fails = 0; + int64_t t0 = esp_timer_get_time(); + while (sent < total && fails < 10) { + if (bridge_send(BRIDGE_MSG_FRAME, f, sizeof(f)) == 0) sent += sizeof(f); + else fails++; + } + int64_t dt = esp_timer_get_time() - t0; + snprintf(out, sizeof(out), "usbtest: %u bytes in %u ms = %u kB/s%s", + (unsigned)sent, (unsigned)(dt / 1000), + dt > 0 ? (unsigned)((uint64_t)sent * 1000u / (uint64_t)dt) : 0u, + fails ? " (writes timed out)" : ""); + cmd_reply(out); } else if (strcmp(cmd, "testframe") == 0) { send_test_frame(); cmd_reply("sent testframe"); diff --git a/tools/awdl_bridge.py b/tools/awdl_bridge.py index fe364b1..c4eb393 100644 --- a/tools/awdl_bridge.py +++ b/tools/awdl_bridge.py @@ -26,7 +26,7 @@ # are only logged. Suppressed with --no-config. # # Interactive stdin commands (forwarded to the device): -# mac | stats | radio | tx | node | chan [n] | checks | ping | testframe | resync +# mac | stats | radio | tx | node | chan [n] | checks | usbtest [kib] | ping | testframe | resync # # Needs root: creating a TAP and running `ip link` both require CAP_NET_ADMIN. # The interface is not persistent - it disappears when this process exits. @@ -196,7 +196,7 @@ def main() -> int: print(f"{args.dev} up (mtu {args.mtu}, mac {mac or 'kernel-assigned'}); " f"bridging {args.port}") - print("commands: mac | stats | radio | tx | node | chan [n] | checks | ping | testframe | resync" + print("commands: mac | stats | radio | tx | node | chan [n] | checks | usbtest [kib] | ping | testframe | resync" " (Ctrl-C to quit)") # The C3 keeps running across host sessions (its USB port does not reset From 0d032eb728d71d3da50abf2bced575e3588390ac Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 21 Oct 2026 22:09:37 +0000 Subject: [PATCH 45/99] feat(bridge): usbtest raw, writing the USB FIFO directly by polling `usbtest` tops out at ~255 kB/s on the C5, and the AirDrop transfer already reached ~0.23 MB/s of payload, so USB is the limit. The driver refills the 64-byte FIFO from its interrupt only after the host fetched the previous packet. `usbtest raw [kib]` sends the same frames by polling the FIFO directly (driver TX interrupt masked, TX lock held), to tell whether that refill latency or the host's polling sets the ceiling. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01XzgaN4Bv1m8Ka9iqo7QVUN --- firmware/spikes/main/CMakeLists.txt | 10 ++++- firmware/spikes/main/bridge.c | 60 +++++++++++++++++++++++++++++ 2 files changed, 69 insertions(+), 1 deletion(-) diff --git a/firmware/spikes/main/CMakeLists.txt b/firmware/spikes/main/CMakeLists.txt index 73e1803..2de9d51 100644 --- a/firmware/spikes/main/CMakeLists.txt +++ b/firmware/spikes/main/CMakeLists.txt @@ -15,10 +15,18 @@ if(IDF_VERSION_MAJOR EQUAL 5 AND IDF_VERSION_MINOR LESS 3) else() set(ESPDROP_USJ_COMPONENT esp_driver_usb_serial_jtag) endif() +# The USB-Serial/JTAG low-level header (bridge `usbtest raw`) moved from "hal" +# to "esp_hal_usb" in ESP-IDF 6. +if(IDF_VERSION_MAJOR GREATER_EQUAL 6) + set(ESPDROP_USJ_LL_COMPONENT esp_hal_usb) +else() + set(ESPDROP_USJ_LL_COMPONENT hal) +endif() message(STATUS "EspDrop: target '${IDF_TARGET}', USB-Serial/JTAG via '${ESPDROP_USJ_COMPONENT}' (IDF ${IDF_VERSION_MAJOR}.${IDF_VERSION_MINOR})") idf_component_register( SRCS "app_main.c" "radio.c" "console.c" "awdl_app.c" "bridge.c" INCLUDE_DIRS "." - REQUIRES awdl esp_wifi esp_event esp_netif nvs_flash console esp_timer esp_hw_support ${ESPDROP_USJ_COMPONENT} + REQUIRES awdl esp_wifi esp_event esp_netif nvs_flash console esp_timer esp_hw_support + ${ESPDROP_USJ_COMPONENT} ${ESPDROP_USJ_LL_COMPONENT} ) diff --git a/firmware/spikes/main/bridge.c b/firmware/spikes/main/bridge.c index d67f14e..f151223 100644 --- a/firmware/spikes/main/bridge.c +++ b/firmware/spikes/main/bridge.c @@ -16,6 +16,7 @@ #include "esp_mac.h" #include "esp_system.h" /* esp_get_free_heap_size */ #include "esp_timer.h" +#include "hal/usb_serial_jtag_ll.h" /* `usbtest raw`: write the FIFO directly */ #include "freertos/FreeRTOS.h" #include "freertos/task.h" #include "freertos/queue.h" @@ -160,6 +161,61 @@ static void cmd_reply(const char *s) { } /* A short text command from the host (BRIDGE_MSG_CMD) -> a CMD_REPLY. */ +/* + * `usbtest raw [kib]`: the same dummy frames as `usbtest`, but written straight + * into the 64-byte USB FIFO by polling, instead of via the driver, which + * refills the FIFO from its interrupt only after the host has fetched the last + * packet. If this is much faster, the refill latency is the limit; if not, it + * is the host's polling. Holds the TX lock and masks the driver's TX interrupt + * meanwhile; a test tool, run it while nothing else is going on. + */ +static void usbtest_raw(int kib, char *out, size_t cap) { + if (kib < 1) kib = 1; + if (kib > 8192) kib = 8192; + static uint8_t msg[1 + 14 + 1400]; + static uint8_t enc[COBS_ENCODE_BOUND(sizeof(msg)) + 1]; + msg[0] = BRIDGE_MSG_FRAME; + memset(msg + 1, 0xff, 6); + radio_get_self_mac(msg + 7); + msg[13] = 0x88; msg[14] = 0xb5; + memset(msg + 15, 0x5a, sizeof(msg) - 15); + size_t enc_len = cobs_encode(msg, sizeof(msg), enc, sizeof(enc) - 1); + enc[enc_len++] = 0x00; + + xSemaphoreTake(s_tx_lock, portMAX_DELAY); + vTaskDelay(pdMS_TO_TICKS(100)); /* let the driver drain its ring buffer */ + usb_serial_jtag_ll_disable_intr_mask(USB_SERIAL_JTAG_INTR_SERIAL_IN_EMPTY); + size_t total = (size_t)kib * 1024, sent = 0; + bool stalled = false; + int64_t t0 = esp_timer_get_time(), next_yield = t0 + 50000; + while (sent < total && !stalled) { + const uint8_t *q = enc; + size_t left = enc_len; + while (left) { + int64_t give_up = esp_timer_get_time() + 1000000; + while (!usb_serial_jtag_ll_txfifo_writable()) + if (esp_timer_get_time() > give_up) { stalled = true; break; } + if (stalled) break; + int w = usb_serial_jtag_ll_write_txfifo(q, left > 64 ? 64 : (uint32_t)left); + usb_serial_jtag_ll_txfifo_flush(); + q += w; + left -= (size_t)w; + } + if (!stalled) sent += sizeof(msg) - 1; + if (esp_timer_get_time() > next_yield) { /* keep the idle task (WDT) alive */ + vTaskDelay(1); + next_yield = esp_timer_get_time() + 50000; + } + } + int64_t dt = esp_timer_get_time() - t0; + usb_serial_jtag_ll_ena_intr_mask(USB_SERIAL_JTAG_INTR_SERIAL_IN_EMPTY); + xSemaphoreGive(s_tx_lock); + snprintf(out, cap, "usbtest raw: %u bytes in %u ms = %u kB/s%s", + (unsigned)sent, (unsigned)(dt / 1000), + dt > 0 ? (unsigned)((uint64_t)sent * 1000u / (uint64_t)dt) : 0u, + stalled ? " (host stopped reading)" : ""); +} + static void handle_cmd(const uint8_t *p, size_t n) { char cmd[64]; size_t k = n < sizeof(cmd) - 1 ? n : sizeof(cmd) - 1; @@ -277,6 +333,10 @@ static void handle_cmd(const uint8_t *p, size_t n) { (unsigned)st.rx_type_mismatch, (unsigned)st.rx_ip6_short, (unsigned)st.rx_ip6_long); cmd_reply(out); + } else if (strcmp(cmd, "usbtest raw") == 0 || strncmp(cmd, "usbtest raw ", 12) == 0) { + int kib = cmd[11] ? atoi(cmd + 12) : 1024; + usbtest_raw(kib, out, sizeof(out)); + cmd_reply(out); } else if (strcmp(cmd, "usbtest") == 0 || strncmp(cmd, "usbtest ", 8) == 0) { /* Device->host ceiling of the bridge path: stream full-size dummy * Ethernet frames (ethertype 0x88b5, ignored by the host stack) through From d03eb4b3dd5ee3251b293b046b810ac85af3b101 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 21 Oct 2026 22:18:36 +0000 Subject: [PATCH 46/99] docs(c5): USB-Serial/JTAG caps the C5 bridge at about 255 kB/s Both the driver path and direct FIFO polling reach ~255 kB/s, so the limit is the port itself, not the driver; the AirDrop transfer already uses ~90 %. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01XzgaN4Bv1m8Ka9iqo7QVUN --- docs/SPIKES.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/docs/SPIKES.md b/docs/SPIKES.md index 5b58d61..387bbcd 100644 --- a/docs/SPIKES.md +++ b/docs/SPIKES.md @@ -182,6 +182,12 @@ Radarerkennung). nur in den 44er-Slots des iPhones). - **AirDrop-Empfang (M2.4) funktioniert:** Foto vom iPhone, 2,87 MB in 12,7 s (≈ 0,23 MB/s laut OpenDrop), korrekt entpackt. +- **Engpass ist USB:** Während der Übertragung war das iPhone in 15 von 16 Slots + auf 44 und wir in allen dabei; verworfen wurde nur an der Warteschlange zum PC. + `usbtest` (Bridge-Pfad über den Treiber) und `usbtest raw` (FIFO direkt per + Polling) schaffen beide ~255 kB/s. Die Grenze liegt also am USB-Serial/JTAG- + Port selbst (ein 64-Byte-Paket je Abholung durch den PC), nicht am Treiber; + AirDrop nutzt davon schon ~90 %. ## Danach From a54cd1819c038721fcc320619be871ed2e6ed4a0 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 21 Oct 2026 22:30:35 +0000 Subject: [PATCH 47/99] feat(c5): follow the master's 5 GHz channel automatically The iPhone puts most of its slots on one 5 GHz channel (44 here, 149 elsewhere) and just one on channel 6. The C5 bridge now parks on that channel by itself instead of needing a manual `chan 44`. - awdl component (host-tested): awdl_chanseq_best_channel picks the channel with the most slots from a master's sequence (ties go to 5 GHz), and awdl_node_set_channel / awdl_node_preferred_channel change the parked channel while keeping sync, election and peers. - awdl_app follows the master onto its preferred social channel that this chip supports, with a 4 s dwell so it cannot flap; enabled by default on 5 GHz chips, never on the C3 (only channel 6 is possible). - Bridge `chan` shows the channel and auto state, `chan ` pins a channel (auto off), `chan auto` re-enables it; `node` reports chan= and auto=. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01XzgaN4Bv1m8Ka9iqo7QVUN --- docs/SPIKES.md | 12 ++-- firmware/spikes/components/awdl/awdl_frame.c | 19 +++++++ firmware/spikes/components/awdl/awdl_node.c | 19 +++++++ .../components/awdl/include/awdl_frame.h | 9 +++ .../components/awdl/include/awdl_node.h | 18 ++++++ .../awdl/test/host/test_awdl_frame.c | 56 +++++++++++++++++++ firmware/spikes/main/app_main.c | 4 ++ firmware/spikes/main/awdl_app.c | 56 +++++++++++++++++++ firmware/spikes/main/awdl_app.h | 16 ++++++ firmware/spikes/main/bridge.c | 33 ++++++----- tools/awdl_bridge.py | 4 +- 11 files changed, 224 insertions(+), 22 deletions(-) diff --git a/docs/SPIKES.md b/docs/SPIKES.md index 387bbcd..a30c8bb 100644 --- a/docs/SPIKES.md +++ b/docs/SPIKES.md @@ -156,10 +156,12 @@ Radarerkennung). 2 ms ist gut, unter 5 ms brauchbar (MT7921: 8,5 ms). Alle Zeilen der Ausgabe aufschreiben, auch den ersten Bandwechsel und eventuelle Fehler. 7. **USB-Durchsatz** wie Spike 5. -8. **Optional: Bridge auf 5 GHz.** Bridge-Firmware für den C5 flashen, im - `awdl_bridge.py`-Terminal `chan 44` (bzw. den Kanal aus Test 3) eingeben, - dann wie in M2.4 pingen bzw. AirDrop testen. Der Knoten bleibt dabei auf - einem Kanal und übernimmt die 5-GHz-Slots des Masters, ganz ohne Kanalwechsel. +8. **Optional: Bridge auf 5 GHz.** Bridge-Firmware für den C5 flashen und wie in + M2.4 pingen bzw. AirDrop testen. Der C5 wählt den 5-GHz-Kanal selbst: er folgt + dem Master auf dessen bevorzugten Sozialkanal (44 oder 149). Mit `node` prüfen + (`auto=on`, `chan=` der erwartete Kanal). `chan 44` pinnt einen festen Kanal + (`auto` aus), `chan auto` schaltet die Automatik wieder ein. Der Knoten bleibt + dabei auf einem Kanal und übernimmt dessen Slots beim Master, ohne Wechsel. ### Ergebnisse ESP32-C5 (Waveshare ESP32-C5-Zero, rev v1.0, 240 MHz, ESP-IDF 6.0.3) @@ -177,7 +179,7 @@ Radarerkennung). - **Empfang und Senden auf Kanal 44** funktionieren: iPhone-Action- und Datenframes kommen an, eigene MIFs gehen ohne Fehler raus. Die Sendeleistung meldet der Treiber auf 44 mit 18 dBm. -- **Bridge auf Kanal 44** (`chan 44`): Ping zum iPhone 19/20, alle 20 Unicast-Frames +- **Bridge auf Kanal 44** (automatisch gewählt): Ping zum iPhone 19/20, alle 20 Unicast-Frames vom iPhone per ACK bestätigt (C3 auf Kanal 6: ~66 %), RTT 4–440 ms (wir senden nur in den 44er-Slots des iPhones). - **AirDrop-Empfang (M2.4) funktioniert:** Foto vom iPhone, 2,87 MB in 12,7 s diff --git a/firmware/spikes/components/awdl/awdl_frame.c b/firmware/spikes/components/awdl/awdl_frame.c index a8565b1..5f83551 100644 --- a/firmware/spikes/components/awdl/awdl_frame.c +++ b/firmware/spikes/components/awdl/awdl_frame.c @@ -104,6 +104,25 @@ void awdl_self_set_chanseq_static(awdl_self_t *self, uint8_t chan_num) { } } +uint8_t awdl_chanseq_best_channel(const uint8_t seq[AWDL_CHANSEQ_LEN], + const uint8_t *allowed, int n_allowed) { + uint8_t best = 0; + int best_count = 0; + for (int i = 0; i < n_allowed; i++) { + uint8_t ch = allowed[i]; + int count = 0; + for (unsigned s = 0; s < AWDL_CHANSEQ_LEN; s++) + if (seq[s] == ch) count++; + if (count == 0) continue; + /* more slots wins; on a tie the higher channel number (5 GHz) wins */ + if (count > best_count || (count == best_count && ch > best)) { + best = ch; + best_count = count; + } + } + return best; +} + bool awdl_self_set_chanseq_intersect(awdl_self_t *self, const uint8_t master_chanseq[AWDL_CHANSEQ_LEN], uint8_t keep_chan) { diff --git a/firmware/spikes/components/awdl/awdl_node.c b/firmware/spikes/components/awdl/awdl_node.c index f90107c..c997081 100644 --- a/firmware/spikes/components/awdl/awdl_node.c +++ b/firmware/spikes/components/awdl/awdl_node.c @@ -102,6 +102,25 @@ void awdl_node_on_rx_action(awdl_node_t *n, const awdl_peer_view_t *pv, } } +void awdl_node_set_channel(awdl_node_t *n, uint8_t chan) { + if (chan == n->self.master_chan) return; + n->self.master_chan = chan; + /* The old mirror was for the old channel; rebuild for the new one. With a + * master that names the new channel this re-mirrors at once; otherwise the + * static sequence on the new channel, as just after boot. */ + n->seq_derived = false; + awdl_node_update_chanseq(n); +} + +uint8_t awdl_node_preferred_channel(const awdl_node_t *n, + const uint8_t *allowed, int n_allowed) { + if (!n->have_master) return 0; + const awdl_peer_t *m = awdl_peers_get((awdl_peer_table_t *)&n->peers, + n->election.sync_addr); + if (!m || !m->has_chanseq) return 0; + return awdl_chanseq_best_channel(m->chanseq, allowed, n_allowed); +} + void awdl_node_tick(awdl_node_t *n, uint64_t now_us) { if (now_us > n->peers.timeout_us) awdl_peers_age(&n->peers, now_us - n->peers.timeout_us); diff --git a/firmware/spikes/components/awdl/include/awdl_frame.h b/firmware/spikes/components/awdl/include/awdl_frame.h index a7e6185..12aad68 100644 --- a/firmware/spikes/components/awdl/include/awdl_frame.h +++ b/firmware/spikes/components/awdl/include/awdl_frame.h @@ -272,6 +272,15 @@ int awdl_parse_data_msdus(const uint8_t *buf, size_t len, awdl_msdu_cb_t cb, voi */ size_t awdl_action_tlv_end(const uint8_t *buf, size_t len); +/* + * Pick which channel to park on from a master's channel sequence: of the + * `allowed` channels, the one with the most slots in `seq`. Ties go to the + * highest channel number (prefer 5 GHz, where a shared slot carries more). + * Returns 0 if none of the allowed channels appears in the sequence. + */ +uint8_t awdl_chanseq_best_channel(const uint8_t seq[AWDL_CHANSEQ_LEN], + const uint8_t *allowed, int n_allowed); + /* Map an operating-class-encoded or legacy channel pair to a channel number. */ uint8_t awdl_chan_number(const uint8_t enc_bytes[2], enum awdl_chan_enc enc); diff --git a/firmware/spikes/components/awdl/include/awdl_node.h b/firmware/spikes/components/awdl/include/awdl_node.h index 2bd1deb..7b1a28c 100644 --- a/firmware/spikes/components/awdl/include/awdl_node.h +++ b/firmware/spikes/components/awdl/include/awdl_node.h @@ -61,6 +61,24 @@ void awdl_node_on_rx_action(awdl_node_t *n, const awdl_peer_view_t *pv, /* Age out stale peers and re-run election. Call ~1 Hz. */ void awdl_node_tick(awdl_node_t *n, uint64_t now_us); +/* + * Park the node on a different channel (the physical radio must be retuned to + * match separately; this only changes what we advertise). Keeps sync, election + * and peers, and re-derives the channel sequence for the new channel from the + * current master straight away. No-op if chan is already the current channel. + */ +void awdl_node_set_channel(awdl_node_t *n, uint8_t chan); + +/* + * The channel the node would prefer to be on, given the tracked master's + * sequence, out of the `allowed` channels (e.g. the social channels the radio + * supports). 0 if there is no master, its sequence is unknown, or it names none + * of the allowed channels. The caller retunes the radio and calls + * awdl_node_set_channel when this differs from the current channel. + */ +uint8_t awdl_node_preferred_channel(const awdl_node_t *n, + const uint8_t *allowed, int n_allowed); + /* Build one of our own PSF/MIF frames with timing from our AW clock. */ int awdl_node_build_action(awdl_node_t *n, uint8_t *buf, size_t cap, uint8_t subtype, uint64_t now_us); diff --git a/firmware/spikes/components/awdl/test/host/test_awdl_frame.c b/firmware/spikes/components/awdl/test/host/test_awdl_frame.c index 1f41c96..7cfecdb 100644 --- a/firmware/spikes/components/awdl/test/host/test_awdl_frame.c +++ b/firmware/spikes/components/awdl/test/host/test_awdl_frame.c @@ -552,6 +552,60 @@ static void iphone_announce(awdl_sync_t *iph, uint64_t t, const uint8_t ip[6], pv->sync_aw_counter = awdl_sync_current_aw(t, iph); } +static void test_best_channel(void) { + const uint8_t allowed[] = {6, 44, 149}; + uint8_t idle[AWDL_CHANSEQ_LEN] = {0}; + idle[0] = 6; idle[2] = 44; idle[8] = 6; idle[10] = 44; + CHECK(awdl_chanseq_best_channel(idle, allowed, 3) == 44, "idle tie -> 44"); + uint8_t busy[AWDL_CHANSEQ_LEN] = {44,44,44,0,0,0,0,0,6,44,44,0,0,0,0,0}; + CHECK(awdl_chanseq_best_channel(busy, allowed, 3) == 44, "busy -> 44"); + uint8_t only6[AWDL_CHANSEQ_LEN] = {0}; + only6[0] = 6; only6[8] = 6; + CHECK(awdl_chanseq_best_channel(only6, allowed, 3) == 6, "only 6"); + const uint8_t only_c3[] = {6}; /* a C3 can only use channel 6 */ + CHECK(awdl_chanseq_best_channel(busy, only_c3, 1) == 6, "C3 keeps 6"); + uint8_t only149[AWDL_CHANSEQ_LEN] = {0}; + only149[3] = 149; only149[11] = 149; + const uint8_t allow5[] = {44, 149}; + CHECK(awdl_chanseq_best_channel(only149, allow5, 2) == 149, "149 only"); + uint8_t none[AWDL_CHANSEQ_LEN] = {0}; + none[0] = 36; none[8] = 36; + CHECK(awdl_chanseq_best_channel(none, allowed, 3) == 0, "none present -> 0"); +} + +static void test_node_set_channel(void) { + uint8_t me[6] = {0x02, 0, 0, 0, 0, 0x01}; + uint8_t ip[6] = {0xca, 0x2a, 0xd6, 0x10, 0x8e, 0xae}; + awdl_node_t n; + awdl_node_init(&n, me, "espdrop-c5", 6, 0); + + awdl_sync_t iph; + awdl_sync_init(&iph, 7000); + awdl_peer_view_t pv; + iphone_announce(&iph, 100000, ip, &pv); /* master on 6 (0,8) and 44 (2,10) */ + memset(pv.chanseq, 0, sizeof(pv.chanseq)); + pv.chanseq[0] = 6; pv.chanseq[2] = 44; pv.chanseq[8] = 6; pv.chanseq[10] = 44; + awdl_node_on_rx_action(&n, &pv, -40, 100000); + CHECK(n.have_master, "should have a master"); + + const uint8_t allowed[] = {6, 44, 149}; + CHECK(awdl_node_preferred_channel(&n, allowed, 3) == 44, "prefer 44 (tie)"); + + awdl_node_set_channel(&n, 44); + CHECK(n.self.master_chan == 44, "master_chan updated"); + CHECK(n.seq_derived, "re-mirrored on the new channel"); + awdl_node_status_t st; + awdl_node_status(&n, &st); + for (int i = 0; i < (int)AWDL_CHANSEQ_LEN; i++) { + uint8_t expect = (i == 2 || i == 10) ? 44 : 0; + CHECK(st.self_chanseq[i] == expect, "seq[%d]=%u expected %u", + i, st.self_chanseq[i], expect); + } + CHECK(n.sync_locked && memcmp(n.synced_master, ip, 6) == 0, "sync kept"); + awdl_node_set_channel(&n, 44); + CHECK(n.self.master_chan == 44, "same channel no-op"); +} + static void test_node_sync_hold(void) { uint8_t me[6] = {0x02, 0, 0, 0, 0, 0x01}; uint8_t ip[6] = {0xca, 0x2a, 0xd6, 0x10, 0x8e, 0xae}; @@ -770,6 +824,8 @@ int main(void) { test_data_amsdu(); test_reject_garbage(); test_action_tlv_end(); + test_best_channel(); + test_node_set_channel(); test_sync_election_parse(); test_sync_math(); test_election_run(); diff --git a/firmware/spikes/main/app_main.c b/firmware/spikes/main/app_main.c index 7a20485..a05fc96 100644 --- a/firmware/spikes/main/app_main.c +++ b/firmware/spikes/main/app_main.c @@ -18,6 +18,7 @@ #include "esp_err.h" /* ESP_OK, used by both the bridge and console branches */ #include "esp_log.h" #include "sdkconfig.h" +#include "soc/soc_caps.h" #include "radio.h" @@ -74,6 +75,9 @@ void app_main(void) { radio_set_data_handler(bridge_rx_frame, NULL); bridge_set_frame_sink(awdl_app_tx_ethernet, NULL); awdl_app_set_peer_cb(on_peer_evt, NULL); /* host builds the neighbour table */ +#if SOC_WIFI_SUPPORT_5G + awdl_app_set_auto_channel(true); /* follow the iPhone onto 44/149 on its own */ +#endif if (radio_promisc(true) != ESP_OK) bridge_log("warning: promiscuous RX did not enable"); bridge_log("awdl rx/tx up on %s ch %u", CONFIG_IDF_TARGET, chan); diff --git a/firmware/spikes/main/awdl_app.c b/firmware/spikes/main/awdl_app.c index c84b5bc..aa7b6af 100644 --- a/firmware/spikes/main/awdl_app.c +++ b/firmware/spikes/main/awdl_app.c @@ -63,6 +63,15 @@ static bool s_started; static char s_name[AWDL_NAME_MAX + 1]; /* kept for awdl_app_set_self */ static volatile bool s_tx_paused; /* awdl_app_pause_tx: no PSF/MIF/data TX */ +/* Automatic channel: follow the master onto its preferred social channel. Off + * by default (set on for 5 GHz chips at start); a manual `chan` turns it off. */ +static bool s_auto_chan; +static int64_t s_auto_next_us; /* earliest next auto switch (dwell) */ +#define AUTO_CHAN_DWELL_US 4000000 /* >= peer timeout, so no flapping */ +/* Social channels we may park on, newest Apple set; radio_channel_supported + * filters out what this chip cannot do (the C3 keeps only 6). */ +static const uint8_t k_social_chans[] = {6, 44, 149}; + /* Peer-change notification. Shadow set is touched only by the AWDL task. */ static awdl_peer_cb_t s_peer_cb; static void *s_peer_cb_ctx; @@ -74,6 +83,29 @@ static int s_peer_nshadow; * task (sent/fail), read by the `tx` command. Aligned 32-bit, stats only. */ static volatile uint32_t s_tx_queued, s_tx_q_drop, s_tx_sent_uc, s_tx_sent_mc, s_tx_fail; +/* Preferred park channel from the master's sequence, restricted to the social + * channels this chip supports. Call with s_lock held (reads s_node). 0 = keep. */ +static uint8_t pick_auto_channel(void) { + uint8_t allowed[sizeof(k_social_chans)]; + int na = 0; + for (unsigned i = 0; i < sizeof(k_social_chans); i++) + if (radio_channel_supported(k_social_chans[i])) + allowed[na++] = k_social_chans[i]; + return awdl_node_preferred_channel(&s_node, allowed, na); +} + +/* Retune the radio and the node to a new channel together (node sync is kept). + * Takes s_lock. Used by the auto-channel logic and the bridge `chan` command. */ +static int retune(uint8_t chan) { + int err = radio_set_channel(chan); + if (err) return err; + xSemaphoreTake(s_lock, portMAX_DELAY); + awdl_node_set_channel(&s_node, chan); + xSemaphoreGive(s_lock); + if (s_task) xTaskNotifyGive(s_task); /* re-derive slots, flush promptly */ + return 0; +} + /* radio handler: Wi-Fi task context, must be quick. Copy into the queue. */ static void on_action(const awdl_peer_view_t *pv, int8_t rssi, uint8_t channel, uint64_t now_us, void *ctx) { @@ -173,13 +205,22 @@ static void awdl_task(void *arg) { if (now >= next_tick) { uint8_t cur[AWDL_MAX_PEERS][6]; int ncur = 0; + uint8_t want_chan = 0; xSemaphoreTake(s_lock, portMAX_DELAY); awdl_node_tick(&s_node, (uint64_t)now); for (int i = 0; i < AWDL_MAX_PEERS; i++) if (s_node.peers.peers[i].used) memcpy(cur[ncur++], s_node.peers.peers[i].addr, 6); + if (s_auto_chan && now >= s_auto_next_us) { + uint8_t best = pick_auto_channel(); /* reads s_node, under lock */ + if (best && best != radio_get_channel()) want_chan = best; + } xSemaphoreGive(s_lock); emit_peer_changes(cur, ncur); /* notify host of peer add/remove */ + if (want_chan) { + retune(want_chan); /* takes s_lock itself */ + s_auto_next_us = esp_timer_get_time() + AUTO_CHAN_DWELL_US; + } next_tick = now + TICK_INTERVAL_US; } @@ -306,6 +347,21 @@ int awdl_app_format_peers(char *buf, size_t cap) { return off; } +int awdl_app_set_channel(uint8_t chan, bool keep_auto) { + if (!s_started) return -1; + if (!keep_auto) s_auto_chan = false; /* a manual channel pins it */ + int err = retune(chan); + if (!err) s_auto_next_us = esp_timer_get_time() + AUTO_CHAN_DWELL_US; + return err; +} + +void awdl_app_set_auto_channel(bool on) { + s_auto_chan = on; + s_auto_next_us = 0; /* act on the next tick */ +} + +bool awdl_app_auto_channel(void) { return s_auto_chan; } + void awdl_app_pause_tx(bool paused) { s_tx_paused = paused; if (!paused && s_task) xTaskNotifyGive(s_task); /* catch up on due frames */ diff --git a/firmware/spikes/main/awdl_app.h b/firmware/spikes/main/awdl_app.h index fb306e8..88b521c 100644 --- a/firmware/spikes/main/awdl_app.h +++ b/firmware/spikes/main/awdl_app.h @@ -47,6 +47,22 @@ void awdl_app_tx(uint8_t subtype); * channels, so nothing goes out on a channel we only pass through. */ void awdl_app_pause_tx(bool paused); +/* + * Retune the radio and the AWDL node to a channel at runtime, keeping sync and + * the peer table (unlike awdl_app_set_self, which resets the node). keep_auto + * false pins the channel and turns automatic channel selection off (a manual + * choice); true is for the automatic logic itself. Returns an esp_err_t. + */ +int awdl_app_set_channel(uint8_t chan, bool keep_auto); + +/* + * Automatic channel: when on, the node follows the master onto its preferred + * social channel (e.g. 44 or 149 on a 5 GHz chip) and falls back to its boot + * channel otherwise. Off on the ESP32-C3 (only channel 6 is possible). + */ +void awdl_app_set_auto_channel(bool on); +bool awdl_app_auto_channel(void); + /* * Queue one host Ethernet frame (dst||src||ethertype||payload) for transmission * as an AWDL data frame. Only IPv6 (ethertype 0x86dd) is forwarded; other types diff --git a/firmware/spikes/main/bridge.c b/firmware/spikes/main/bridge.c index f151223..3693af9 100644 --- a/firmware/spikes/main/bridge.c +++ b/firmware/spikes/main/bridge.c @@ -269,11 +269,13 @@ static void handle_cmd(const uint8_t *p, size_t n) { } else { int o = snprintf(nbuf, sizeof(nbuf), "master=%02x:%02x:%02x:%02x:%02x:%02x%s peers=%d height=%u " - "sync_err=%d derived=%s seq=", + "sync_err=%d chan=%u auto=%s derived=%s seq=", ns.master_addr[0], ns.master_addr[1], ns.master_addr[2], ns.master_addr[3], ns.master_addr[4], ns.master_addr[5], ns.have_master ? "" : "(self)", ns.peer_count, (unsigned)ns.height, (int)ns.last_sync_err_tu, + (unsigned)radio_get_channel(), + awdl_app_auto_channel() ? "on" : "off", ns.seq_derived ? "yes" : "no"); for (unsigned i = 0; i < AWDL_CHANSEQ_LEN && o > 0 && o < (int)sizeof(nbuf); i++) o += snprintf(nbuf + o, sizeof(nbuf) - (size_t)o, "%s%u", @@ -301,23 +303,24 @@ static void handle_cmd(const uint8_t *p, size_t n) { (unsigned)st.tx_uc_ack, (unsigned)st.tx_uc_noack); cmd_reply(out); } else if (strcmp(cmd, "chan") == 0 || strncmp(cmd, "chan ", 5) == 0) { - /* `chan` shows the AWDL channel, `chan ` moves the node there (it - * restarts: sync and peers are learned again). On the C5 that allows - * single-channel 5 GHz experiments (44/149) with the full data path. */ - if (cmd[4] == '\0') { - snprintf(out, sizeof(out), "chan %u", radio_get_channel()); + /* `chan` shows the channel and whether auto is on; `chan ` pins the + * node to a channel (keeps sync/peers); `chan auto` follows the master + * onto its preferred 5 GHz channel (44/149) on its own. */ + const char *arg = cmd[4] == '\0' ? "" : cmd + 5; + if (arg[0] == '\0') { + snprintf(out, sizeof(out), "chan %u (auto %s)", radio_get_channel(), + awdl_app_auto_channel() ? "on" : "off"); + } else if (strcmp(arg, "auto") == 0) { + awdl_app_set_auto_channel(true); + snprintf(out, sizeof(out), "chan auto on (now %u)", radio_get_channel()); } else { - int ch = atoi(cmd + 5); + int ch = atoi(arg); int err = (ch >= 1 && ch <= 255 && radio_channel_supported((uint8_t)ch)) - ? radio_set_channel((uint8_t)ch) : ESP_ERR_NOT_SUPPORTED; - if (err != ESP_OK) { + ? awdl_app_set_channel((uint8_t)ch, false) : ESP_ERR_NOT_SUPPORTED; + if (err != ESP_OK) snprintf(out, sizeof(out), "chan %d: %s", ch, esp_err_to_name(err)); - } else { - uint8_t mac[6]; - radio_get_self_mac(mac); - awdl_app_set_self(mac, (uint8_t)ch); - snprintf(out, sizeof(out), "chan %d (AWDL node restarted)", ch); - } + else + snprintf(out, sizeof(out), "chan %d (auto off)", ch); } cmd_reply(out); } else if (strcmp(cmd, "checks") == 0) { diff --git a/tools/awdl_bridge.py b/tools/awdl_bridge.py index c4eb393..7786358 100644 --- a/tools/awdl_bridge.py +++ b/tools/awdl_bridge.py @@ -26,7 +26,7 @@ # are only logged. Suppressed with --no-config. # # Interactive stdin commands (forwarded to the device): -# mac | stats | radio | tx | node | chan [n] | checks | usbtest [kib] | ping | testframe | resync +# mac | stats | radio | tx | node | chan [n|auto] | checks | usbtest [kib] | ping | testframe | resync # # Needs root: creating a TAP and running `ip link` both require CAP_NET_ADMIN. # The interface is not persistent - it disappears when this process exits. @@ -196,7 +196,7 @@ def main() -> int: print(f"{args.dev} up (mtu {args.mtu}, mac {mac or 'kernel-assigned'}); " f"bridging {args.port}") - print("commands: mac | stats | radio | tx | node | chan [n] | checks | usbtest [kib] | ping | testframe | resync" + print("commands: mac | stats | radio | tx | node | chan [n|auto] | checks | usbtest [kib] | ping | testframe | resync" " (Ctrl-C to quit)") # The C3 keeps running across host sessions (its USB port does not reset From 2e903affb1629e5091428601451f0eb913bbfef3 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 21 Oct 2026 22:35:53 +0000 Subject: [PATCH 48/99] feat(m3): send to an iPhone via a BLE wake from the PC's Bluetooth Sending (PC -> iPhone) needs the phone's AWDL awake so it advertises its AirDrop service; with the share sheet closed Apple wakes the receiver with a Bluetooth LE Continuity advertisement. The firmware and awdl0 are unchanged (the host->radio data path already carries IPv6); this adds the two PC-side pieces, following the proven airdrop-mt7921 approach. - tools/airdrop_wake.py broadcasts the Apple Continuity AirDrop advert through BlueZ's LEAdvertisingManager1 (so bluetoothd enables it, unlike raw HCI or btmgmt). Empty hashes for AirDrop "Everyone"; --appleid/--phone/--email hash an identifier for a contacts-only target. Keeps a reference to the advert object, re-arms on loss, and unregisters cleanly. - tools/opendrop_send.sh waits for awdl0, runs the wake, browses with `opendrop find` until a receiver appears (stopping it with SIGINT, bounded so a stuck find cannot hang the script), then `opendrop send -r -f ...`. Accepts several files, -r to choose a receiver by name/id, --no-wake, -t. - docs/SPIKES.md: how to send; the OpenDrop send patches are already installed. Tested only with fakes here (no phone/bluetooth in the container): the BLE payload matches the reference byte for byte, and the find/parse/send/cleanup orchestration runs end to end. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01XzgaN4Bv1m8Ka9iqo7QVUN --- docs/SPIKES.md | 26 ++++++ tools/airdrop_wake.py | 191 +++++++++++++++++++++++++++++++++++++++++ tools/opendrop_send.sh | 161 ++++++++++++++++++++++++++++++++++ 3 files changed, 378 insertions(+) create mode 100755 tools/airdrop_wake.py create mode 100755 tools/opendrop_send.sh diff --git a/docs/SPIKES.md b/docs/SPIKES.md index a30c8bb..b9b6afa 100644 --- a/docs/SPIKES.md +++ b/docs/SPIKES.md @@ -191,6 +191,32 @@ Radarerkennung). Port selbst (ein 64-Byte-Paket je Abholung durch den PC), nicht am Treiber; AirDrop nutzt davon schon ~90 %. +## Senden an ein iPhone (M3, PC -> iPhone) + +Empfangen ist damit fertig. Für die Gegenrichtung muss das iPhone seinen +AirDrop-Dienst über AWDL annoncieren, und das tut es nur, wenn sein AWDL wach +ist. Bei geschlossenem Teilen-Menü weckt Apple den Empfänger per Bluetooth LE. +Dieses BLE-Signal sendet der PC selbst; der C5 macht über `awdl0` unverändert +weiter. (Nachgewiesen im Referenzprojekt airdrop-mt7921, docs/UEBERBLICK.md.) + +Voraussetzungen auf dem PC: Bluetooth an (`bluetoothctl power on`) und die +Pakete `python3-dbus` und `python3-gi` (Fedora: `python3-dbus python3-gobject`). + +1. Bridge wie beim Empfang starten; der C5 wählt den 5-GHz-Kanal automatisch. +2. Senden (als normaler User), eine oder mehrere Dateien: + ```sh + tools/opendrop_send.sh foto.jpg + ``` + Das Skript sendet das BLE-Wecksignal (`tools/airdrop_wake.py`), sucht den + Empfänger auf `awdl0` und überträgt dann. +3. Am iPhone: AirDrop auf „Für alle für 10 Minuten“, entsperrt, **Teilen-Menü + geschlossen** (ein offenes Menü macht das iPhone zum Sender, der keinen + Empfänger annonciert). Bei der Abfrage „Annehmen“ tippen. + +Mehrere Apple-Geräte in Reichweite: mit `-r "Annas iPhone"` das Ziel am Namen +wählen. Kontaktе-statt-„Jeder“: Identifikator über +`ESPDROP_WAKE_ARGS="--appleid du@icloud.com"` an das Wecksignal geben. + ## Danach Ergebnisse (auch Fehlschläge) in `docs/UEBERBLICK.md` unter „Offene Fragen" diff --git a/tools/airdrop_wake.py b/tools/airdrop_wake.py new file mode 100755 index 0000000..26dcd61 --- /dev/null +++ b/tools/airdrop_wake.py @@ -0,0 +1,191 @@ +#!/usr/bin/env python3 +# EspDrop - wake a nearby iPhone's AirDrop receiver with a Bluetooth LE advert. +# +# To SEND a file to an iPhone (PC -> iPhone) the phone must be advertising its +# AirDrop service (_airdrop._tcp) over AWDL. It only does that while its AWDL +# interface is awake, and with the share sheet CLOSED the way Apple wakes it is +# a Bluetooth LE "Continuity" advertisement from the sender. This tool sends +# that advert from the PC's own Bluetooth, so the iPhone then appears to +# `opendrop find` on awdl0 and accepts an upload (tools/opendrop_send.sh). +# +# This is the receiver's side of Apple's own bootstrap: per Stute et al. +# (USENIX Security 2019) a receiver wakes its AWDL interface "if at least one +# contact match was found in contacts-only mode, or if it is discoverable by +# everyone". So with the target set to AirDrop -> Everyone the hash fields may +# be empty; for contacts-only, pass an identifier already in the target's +# address book (--appleid / --phone / --email) and it is hashed as Apple does. +# +# Wire format (furiousMAC/continuity, manufacturer 0x004C = Apple, type 0x05): +# 05 12 <8 zero bytes> 01 AA AA PP PP EE EE EE EE 00 +# type/len, zero prefix, AirDrop version 1, then the first 2 bytes of +# SHA-256(apple id), (phone), (email), (email2), zero suffix. +# +# It goes out through BlueZ's LEAdvertisingManager1 D-Bus API, so bluetoothd +# (which owns the controller on a normal desktop) enables it itself; raw HCI or +# btmgmt fight bluetoothd for the controller. Needs python3-dbus and PyGObject +# (Debian/Ubuntu: apt install python3-dbus python3-gi; Fedora: python3-dbus +# python3-gobject) and an adapter that is powered (`bluetoothctl power on`). +# +# tools/airdrop_wake.py # Everyone mode (empty hashes) +# tools/airdrop_wake.py --appleid you@icloud.com # contacts-only target +# +# Leave it running in its own terminal while opendrop_send.sh runs, then stop +# it with Ctrl-C (it unregisters the advert cleanly). +# +# SPDX-License-Identifier: GPL-3.0-or-later +# Copyright (C) 2026 EspDrop contributors + +import argparse +import hashlib +import signal +import sys + +try: + import dbus + import dbus.mainloop.glib + import dbus.service + from gi.repository import GLib +except ImportError as e: + sys.exit("error: missing %s; install python3-dbus and python3-gi " + "(Fedora: python3-dbus python3-gobject)" % e.name) + +APPLE_CID = 0x004C # Apple's Bluetooth company identifier +ADAPTER = "/org/bluez/hci0" +ADV_PATH = "/org/bluez/espdrop/airdrop_adv" + + +def hash2(identifier: str) -> bytes: + """First 2 bytes of SHA-256(identifier), or 0x0000 for an empty string.""" + if not identifier: + return b"\x00\x00" + return hashlib.sha256(identifier.encode()).digest()[:2] + + +def build_payload(appleid, phone, email, email2) -> bytes: + # Manufacturer-specific data AFTER the 2-byte company id (BlueZ prepends it). + return (b"\x05\x12" + b"\x00" * 8 + b"\x01" + + hash2(appleid) + hash2(phone) + hash2(email) + hash2(email2) + + b"\x00") + + +class Advertisement(dbus.service.Object): + """An org.bluez.LEAdvertisement1 broadcast advert carrying our payload.""" + + def __init__(self, bus, path, payload, on_release): + super().__init__(bus, path) + self._payload = payload + self._on_release = on_release + + @dbus.service.method("org.freedesktop.DBus.Properties", + in_signature="s", out_signature="a{sv}") + def GetAll(self, interface): + if interface != "org.bluez.LEAdvertisement1": + raise dbus.exceptions.DBusException( + "org.freedesktop.DBus.Error.InvalidArgs") + return { + "Type": dbus.String("broadcast"), + "ManufacturerData": dbus.Dictionary( + {dbus.UInt16(APPLE_CID): dbus.Array(self._payload, signature="y")}, + signature="qv"), + # The legacy advert is 31 bytes and this payload already spends 24; + # adding a TX-power field risks overflowing it. + "IncludeTxPower": dbus.Boolean(False), + } + + @dbus.service.method("org.bluez.LEAdvertisement1") + def Release(self): + # BlueZ calls this when it drops the advert (e.g. the controller went + # away), the only notice we get; let the main loop re-register. + self._on_release() + + +def main() -> int: + ap = argparse.ArgumentParser(description="Wake an iPhone's AirDrop receiver over BLE") + ap.add_argument("--appleid", default="", help="Apple ID to hash (contacts-only target)") + ap.add_argument("--phone", default="", help="phone number as stored in the target's contacts") + ap.add_argument("--email", default="", help="email address to hash") + ap.add_argument("--email2", default="", help="second email address to hash") + ap.add_argument("--adapter", default=ADAPTER, help="BlueZ adapter object path") + args = ap.parse_args() + + payload = build_payload(args.appleid, args.phone, args.email, args.email2) + everyone = not any((args.appleid, args.phone, args.email, args.email2)) + print("AirDrop BLE wake: manufacturer 0x%04X, data %s" % (APPLE_CID, payload.hex())) + print("mode:", "Everyone (empty hashes)" if everyone + else "contacts-only (hashed identifiers)") + print("The target iPhone must be on AirDrop -> Everyone (or have a matching") + print("contact) with the share sheet CLOSED. Ctrl-C to stop.") + + dbus.mainloop.glib.DBusGMainLoop(set_as_default=True) + bus = dbus.SystemBus() + try: + # follow_name_owner_changes: survive a bluetoothd restart, which hands + # org.bluez to a new owner that a bound proxy would not follow. + manager = dbus.Interface( + bus.get_object("org.bluez", args.adapter, follow_name_owner_changes=True), + "org.bluez.LEAdvertisingManager1") + except dbus.exceptions.DBusException as e: + sys.exit("error: no BlueZ adapter at %s (%s); is bluetooth on?" % (args.adapter, e)) + + loop = GLib.MainLoop() + # RegisterAdvertisement is asynchronous; `pending` keeps the 3 s re-arm + # timer from registering a second advert on top of an in-flight first. + state = {"up": False, "pending": False} + + def ok(): + state["pending"] = False + if not state["up"]: + print("advert up", flush=True) + state["up"] = True + + def ko(error): + state["pending"] = False + if state["up"]: + print("advert lost (%s) - re-arming" % error, flush=True) + state["up"] = False + + def released(): + if state["up"]: + print("advert released by BlueZ - re-arming", flush=True) + state["up"] = False + + # Keep a reference: if the object is collected its D-Bus path disappears and + # BlueZ silently drops the advert. + adv = Advertisement(bus, ADV_PATH, payload, released) + + def arm(): + if not state["up"] and not state["pending"]: + state["pending"] = True + try: + manager.RegisterAdvertisement( + ADV_PATH, dbus.Dictionary({}, signature="sv"), + reply_handler=ok, error_handler=ko) + except dbus.exceptions.DBusException as exc: + state["pending"] = False + print("register: %s" % exc, file=sys.stderr, flush=True) + return True # keep the timer + + arm() + GLib.timeout_add_seconds(3, arm) + + def stop(*_): + try: + manager.UnregisterAdvertisement(ADV_PATH) + except dbus.exceptions.DBusException: + pass + try: + adv.remove_from_connection() + except Exception: + pass + print("\nstopped.") + loop.quit() + return False + + GLib.unix_signal_add(GLib.PRIORITY_DEFAULT, signal.SIGINT, stop) + GLib.unix_signal_add(GLib.PRIORITY_DEFAULT, signal.SIGTERM, stop) + loop.run() + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tools/opendrop_send.sh b/tools/opendrop_send.sh new file mode 100755 index 0000000..0cef359 --- /dev/null +++ b/tools/opendrop_send.sh @@ -0,0 +1,161 @@ +#!/usr/bin/env bash +# EspDrop - send a file to an iPhone with the patched OpenDrop over awdl0. +# +# Run tools/awdl_bridge.py first (it creates awdl0 and, on the C5, follows the +# iPhone onto its 5 GHz channel). Then, as your normal user: +# +# tools/opendrop_send.sh photo.jpg +# tools/opendrop_send.sh -n MyPC file1.pdf file2.png +# tools/opendrop_send.sh -r "Anna's iPhone" clip.mov # pick the receiver +# +# On the iPhone: AirDrop "Everyone for 10 Minutes", unlock, and LEAVE THE SHARE +# SHEET CLOSED (an open share sheet makes the phone a sender, which never +# advertises a receiver for us to find). This script broadcasts the BLE wake +# (tools/airdrop_wake.py) to bring the phone's AirDrop receiver up, browses for +# it on awdl0, and sends. The phone shows an Accept prompt; tap Accept. +# +# Options: -n NAME sender name shown on the iPhone (default: EspDrop) +# -i IFACE interface (default: awdl0) +# -r RECV receiver: its name, 12-hex id, or index (default: the +# first one found this run) +# -t SECS how long to browse for a receiver (default: 60) +# --no-wake do not broadcast the BLE wake (if something else does) +# -h this help +# Environment: OPENDROP_VENV (default ~/opendrop-venv), ESPDROP_TLS_KEYLOG=FILE. +# Contacts-only target: pass identifiers to the wake via +# ESPDROP_WAKE_ARGS="--appleid you@icloud.com" tools/opendrop_send.sh file +# +# SPDX-License-Identifier: GPL-3.0-or-later +# Copyright (C) 2026 EspDrop contributors + +set -uo pipefail + +NAME=EspDrop +IFACE=awdl0 +RECV="" +BROWSE=60 +WAKE=1 +VENV="${OPENDROP_VENV:-$HOME/opendrop-venv}" +HERE="$(cd "$(dirname "$0")" && pwd)" + +usage() { awk 'NR > 1 { if (/^# SPDX/) exit; sub(/^# ?/, ""); print }' "$0"; } + +# getopts does not do long options; pull --no-wake out first. +ARGS=() +for a in "$@"; do + case "$a" in + --no-wake) WAKE=0 ;; + --help) usage; exit 0 ;; + *) ARGS+=("$a") ;; + esac +done +set -- ${ARGS[@]+"${ARGS[@]}"} + +while getopts "n:i:r:t:h" opt; do + case "$opt" in + n) NAME="$OPTARG" ;; + i) IFACE="$OPTARG" ;; + r) RECV="$OPTARG" ;; + t) BROWSE="$OPTARG" ;; + h) usage; exit 0 ;; + *) usage >&2; exit 2 ;; + esac +done +shift $((OPTIND - 1)) + +die() { echo "error: $*" >&2; exit 1; } + +[ "$#" -ge 1 ] || die "give at least one file to send (see -h)" +[ "$(id -u)" -eq 0 ] && die "run as your normal user, not root (state lives in ~/.opendrop)" +[ -x "$VENV/bin/opendrop" ] || die "no OpenDrop in $VENV - run tools/opendrop_install.sh first" +case "$NAME" in *"'"*|*'"'*) die "the name must not contain quotes (it goes into the TLS certificate)" ;; esac +command -v ip >/dev/null || die "the 'ip' command (iproute2) is required" + +files=() +for f in "$@"; do + [ -f "$f" ] || die "no such file: $f" + files+=(-f "$f") +done + +# Our link-local on IFACE once usable (exists, not tentative), else empty. +usable_ll() { + ip -6 -o addr show dev "$IFACE" scope link 2>/dev/null \ + | awk '!/tentative/ { for (i = 1; i <= NF; i++) if ($i == "inet6") { split($(i+1), a, "/"); print a[1]; exit } }' +} +[ -n "$(usable_ll)" ] || die "$IFACE has no usable link-local; is 'sudo tools/awdl_bridge.py /dev/ttyACM0' running?" + +report="$HOME/.opendrop/discover.last.json" +findlog="$(mktemp -t espdrop-find-XXXXXX.log)" +wake_pid="" +find_pid="" +# Ignore SIGPIPE so a closed stdout (| tee quit) cannot kill us mid-cleanup. +trap '' PIPE +# Stop `opendrop find` with SIGINT (so its finally: writes the report) and reap +# it, but never block: escalate to SIGKILL if it does not go within ~3 s. +stop_find() { + [ -n "$find_pid" ] || return 0 + kill -INT "$find_pid" 2>/dev/null + for _ in $(seq 1 30); do + kill -0 "$find_pid" 2>/dev/null || break + sleep 0.1 + done + kill -KILL "$find_pid" 2>/dev/null + wait "$find_pid" 2>/dev/null + find_pid="" +} +cleanup() { + stop_find + [ -n "$wake_pid" ] && kill "$wake_pid" 2>/dev/null + rm -f "$findlog" +} +trap 'cleanup; exit 130' INT TERM HUP +trap cleanup EXIT + +keylog=() +[ -n "${ESPDROP_TLS_KEYLOG:-}" ] && { mkdir -p "$(dirname "$ESPDROP_TLS_KEYLOG")" && keylog=(SSLKEYLOGFILE="$ESPDROP_TLS_KEYLOG"); } + +if [ "$WAKE" -eq 1 ]; then + echo "broadcasting the AirDrop BLE wake (phone: AirDrop Everyone, share sheet CLOSED) ..." + # shellcheck disable=SC2086 + "$HERE/airdrop_wake.py" ${ESPDROP_WAKE_ARGS:-} >/dev/null 2>&1 & + wake_pid=$! + sleep 1 + kill -0 "$wake_pid" 2>/dev/null || { wake_pid=""; echo "warning: BLE wake did not start (python3-dbus / python3-gi installed, bluetooth on?); browsing anyway" >&2; } +fi + +echo "browsing for an AirDrop receiver on $IFACE (up to ${BROWSE}s) ..." +: > "$findlog" +env PYTHONUNBUFFERED=1 "$VENV/bin/opendrop" -n "$NAME" -i "$IFACE" find > "$findlog" 2>&1 & +find_pid=$! + +# `opendrop find` never exits on its own; stop at the first receiver or timeout. +recv_id="" +for _ in $(seq 1 "$((BROWSE * 2))"); do + kill -0 "$find_pid" 2>/dev/null || break + recv_id=$(grep -oE 'Found[[:space:]]+index[[:space:]]+[0-9]+[[:space:]]+ID[[:space:]]+[0-9a-f]{12}' "$findlog" \ + | grep -oE '[0-9a-f]{12}$' | head -1) + [ -n "$recv_id" ] && break + sleep 0.5 +done +stop_find # SIGINT so its finally: writes the report, then bounded reap + +grep -E 'Found[[:space:]]+index' "$findlog" | sed 's/^/ /' || true +if [ -z "$recv_id" ] && [ -z "$RECV" ]; then + echo "no AirDrop receiver found." >&2 + echo " - the iPhone must be on AirDrop -> Everyone with the share sheet CLOSED," >&2 + echo " unlocked and awake, and in range on the channel awdl0 is parked on (check" >&2 + echo " 'node' in the bridge: auto=on and chan= the phone's 5 GHz channel)." >&2 + echo " - the BLE wake needs the PC's bluetooth on; without python3-dbus/python3-gi" >&2 + echo " it is skipped. Raise the browse time with -t if it is slow to appear." >&2 + exit 1 +fi + +target="${RECV:-$recv_id}" +echo "sending to '${target}' ..." +# send reads the receiver's address/port from $report, which find just wrote. +[ -s "$report" ] || die "no discovery report at $report (find wrote nothing)" +env PYTHONUNBUFFERED=1 ${keylog[@]+"${keylog[@]}"} \ + "$VENV/bin/opendrop" -n "$NAME" -i "$IFACE" send -r "$target" "${files[@]}" +rc=$? +[ "$rc" -eq 0 ] && echo "sent." || echo "send failed (exit $rc); tap Accept on the phone and retry, or raise -t." >&2 +exit "$rc" From caa8c3b390df3f407072c1660544541ba8843815 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 22 Oct 2026 20:20:43 +0000 Subject: [PATCH 49/99] fix(m3): show why a send found no receiver The first send attempt found nothing and printed only a generic hint. Run the browse with OpenDrop's -d, keep its log in ~/espdrop-find.log (overridable with ESPDROP_FIND_LOG) instead of deleting it, and on failure print the relevant lines, calling out "not discoverable" (the device answered but is on Contacts Only) explicitly. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01XzgaN4Bv1m8Ka9iqo7QVUN --- tools/opendrop_send.sh | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/tools/opendrop_send.sh b/tools/opendrop_send.sh index 0cef359..22a43c9 100755 --- a/tools/opendrop_send.sh +++ b/tools/opendrop_send.sh @@ -21,7 +21,8 @@ # -t SECS how long to browse for a receiver (default: 60) # --no-wake do not broadcast the BLE wake (if something else does) # -h this help -# Environment: OPENDROP_VENV (default ~/opendrop-venv), ESPDROP_TLS_KEYLOG=FILE. +# Environment: OPENDROP_VENV (default ~/opendrop-venv), ESPDROP_TLS_KEYLOG=FILE, +# ESPDROP_FIND_LOG (debug log of the browse, default ~/espdrop-find.log). # Contacts-only target: pass identifiers to the wake via # ESPDROP_WAKE_ARGS="--appleid you@icloud.com" tools/opendrop_send.sh file # @@ -85,7 +86,8 @@ usable_ll() { [ -n "$(usable_ll)" ] || die "$IFACE has no usable link-local; is 'sudo tools/awdl_bridge.py /dev/ttyACM0' running?" report="$HOME/.opendrop/discover.last.json" -findlog="$(mktemp -t espdrop-find-XXXXXX.log)" +# Kept after the run (overwritten each time) so a failed browse can be read. +findlog="${ESPDROP_FIND_LOG:-$HOME/espdrop-find.log}" wake_pid="" find_pid="" # Ignore SIGPIPE so a closed stdout (| tee quit) cannot kill us mid-cleanup. @@ -106,7 +108,6 @@ stop_find() { cleanup() { stop_find [ -n "$wake_pid" ] && kill "$wake_pid" 2>/dev/null - rm -f "$findlog" } trap 'cleanup; exit 130' INT TERM HUP trap cleanup EXIT @@ -125,7 +126,9 @@ fi echo "browsing for an AirDrop receiver on $IFACE (up to ${BROWSE}s) ..." : > "$findlog" -env PYTHONUNBUFFERED=1 "$VENV/bin/opendrop" -n "$NAME" -i "$IFACE" find > "$findlog" 2>&1 & +# -d: log every service seen and why a receiver was skipped (e.g. "not +# discoverable" = the device answers but is on Contacts Only). +env PYTHONUNBUFFERED=1 "$VENV/bin/opendrop" -n "$NAME" -d -i "$IFACE" find > "$findlog" 2>&1 & find_pid=$! # `opendrop find` never exits on its own; stop at the first receiver or timeout. @@ -141,7 +144,11 @@ stop_find # SIGINT so its finally: writes the report, then bounded reap grep -E 'Found[[:space:]]+index' "$findlog" | sed 's/^/ /' || true if [ -z "$recv_id" ] && [ -z "$RECV" ]; then - echo "no AirDrop receiver found." >&2 + echo "no AirDrop receiver found. What the browse saw (full log: $findlog):" >&2 + grep -iE 'discover|airdrop|receiver|service|resolv|error|exception|refused|timed out' "$findlog" \ + | grep -v 'Save discovery results' | tail -n 15 | sed 's/^/ /' >&2 + grep -qi 'not discoverable' "$findlog" && \ + echo " -> a device answered but is not discoverable for us: set it to AirDrop Everyone." >&2 echo " - the iPhone must be on AirDrop -> Everyone with the share sheet CLOSED," >&2 echo " unlocked and awake, and in range on the channel awdl0 is parked on (check" >&2 echo " 'node' in the bridge: auto=on and chan= the phone's 5 GHz channel)." >&2 From 79c7fb06515d8c486e4b2dca4a694c813b8def1b Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 22 Oct 2026 20:36:18 +0000 Subject: [PATCH 50/99] fix(m3): the target needs its share sheet OPEN, not closed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The first send found no receiver: our _airdrop._tcp queries went out with the BLE wake running and the iPad as AWDL master on channel 44, but nothing answered. The "share sheet closed" instruction came from an older, untested comment in airdrop-mt7921's blewake.sh; that project's working sends (FINDINGS §27, §37) had the target's share sheet open plus the BLE wake, and with the sheet closed the device never advertised a receiver at all. Correct the instructions in opendrop_send.sh, airdrop_wake.py and SPIKES.md (also mention the iPad), and fix a typo in the doc. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01XzgaN4Bv1m8Ka9iqo7QVUN --- docs/SPIKES.md | 20 +++++++++++--------- tools/airdrop_wake.py | 15 ++++++++------- tools/opendrop_send.sh | 19 ++++++++++--------- 3 files changed, 29 insertions(+), 25 deletions(-) diff --git a/docs/SPIKES.md b/docs/SPIKES.md index b9b6afa..0f3edec 100644 --- a/docs/SPIKES.md +++ b/docs/SPIKES.md @@ -193,11 +193,12 @@ Radarerkennung). ## Senden an ein iPhone (M3, PC -> iPhone) -Empfangen ist damit fertig. Für die Gegenrichtung muss das iPhone seinen -AirDrop-Dienst über AWDL annoncieren, und das tut es nur, wenn sein AWDL wach -ist. Bei geschlossenem Teilen-Menü weckt Apple den Empfänger per Bluetooth LE. -Dieses BLE-Signal sendet der PC selbst; der C5 macht über `awdl0` unverändert -weiter. (Nachgewiesen im Referenzprojekt airdrop-mt7921, docs/UEBERBLICK.md.) +Empfangen ist damit fertig. Für die Gegenrichtung muss das iPhone oder iPad als +Empfänger über AWDL antworten. Das tut es nur, wenn sein AWDL wach ist (offenes +Teilen-Menü) **und** ein Bluetooth-LE-Signal in der Nähe meldet, dass jemand +senden will. Dieses BLE-Signal sendet der PC selbst; der C5 macht über `awdl0` +unverändert weiter. (Nachgewiesen im Referenzprojekt airdrop-mt7921, FINDINGS +§27 und §37: gleiche Verbindung, gleiches offenes Menü, gefunden nur mit BLE.) Voraussetzungen auf dem PC: Bluetooth an (`bluetoothctl power on`) und die Pakete `python3-dbus` und `python3-gi` (Fedora: `python3-dbus python3-gobject`). @@ -209,12 +210,13 @@ Pakete `python3-dbus` und `python3-gi` (Fedora: `python3-dbus python3-gobject`). ``` Das Skript sendet das BLE-Wecksignal (`tools/airdrop_wake.py`), sucht den Empfänger auf `awdl0` und überträgt dann. -3. Am iPhone: AirDrop auf „Für alle für 10 Minuten“, entsperrt, **Teilen-Menü - geschlossen** (ein offenes Menü macht das iPhone zum Sender, der keinen - Empfänger annonciert). Bei der Abfrage „Annehmen“ tippen. +3. Am iPhone/iPad: AirDrop auf „Für alle für 10 Minuten“, entsperrt, + **Teilen-Menü öffnen und offen lassen** (es hält AWDL wach; bei geschlossenem + Menü meldet sich das Gerät nie als Empfänger). Bei der Abfrage „Annehmen“ + tippen. Mehrere Apple-Geräte in Reichweite: mit `-r "Annas iPhone"` das Ziel am Namen -wählen. Kontaktе-statt-„Jeder“: Identifikator über +wählen. „Nur Kontakte“ statt „Jeder“: Identifikator über `ESPDROP_WAKE_ARGS="--appleid du@icloud.com"` an das Wecksignal geben. ## Danach diff --git a/tools/airdrop_wake.py b/tools/airdrop_wake.py index 26dcd61..a4710bd 100755 --- a/tools/airdrop_wake.py +++ b/tools/airdrop_wake.py @@ -1,11 +1,12 @@ #!/usr/bin/env python3 # EspDrop - wake a nearby iPhone's AirDrop receiver with a Bluetooth LE advert. # -# To SEND a file to an iPhone (PC -> iPhone) the phone must be advertising its -# AirDrop service (_airdrop._tcp) over AWDL. It only does that while its AWDL -# interface is awake, and with the share sheet CLOSED the way Apple wakes it is -# a Bluetooth LE "Continuity" advertisement from the sender. This tool sends -# that advert from the PC's own Bluetooth, so the iPhone then appears to +# To SEND a file to an iPhone (PC -> iPhone) the phone must answer for its +# AirDrop service (_airdrop._tcp) over AWDL. It does that only while its AWDL is +# awake (share sheet open) AND a nearby sender's Bluetooth LE "Continuity" +# advertisement says someone wants to send (airdrop-mt7921 FINDINGS §27: same +# link, same open sheet, found only while the advert ran). This tool sends that +# advert from the PC's own Bluetooth, so the iPhone then appears to # `opendrop find` on awdl0 and accepts an upload (tools/opendrop_send.sh). # # This is the receiver's side of Apple's own bootstrap: per Stute et al. @@ -113,8 +114,8 @@ def main() -> int: print("AirDrop BLE wake: manufacturer 0x%04X, data %s" % (APPLE_CID, payload.hex())) print("mode:", "Everyone (empty hashes)" if everyone else "contacts-only (hashed identifiers)") - print("The target iPhone must be on AirDrop -> Everyone (or have a matching") - print("contact) with the share sheet CLOSED. Ctrl-C to stop.") + print("The target iPhone/iPad must be on AirDrop -> Everyone (or have a matching") + print("contact), unlocked, with its share sheet OPEN. Ctrl-C to stop.") dbus.mainloop.glib.DBusGMainLoop(set_as_default=True) bus = dbus.SystemBus() diff --git a/tools/opendrop_send.sh b/tools/opendrop_send.sh index 22a43c9..4a7dbf3 100755 --- a/tools/opendrop_send.sh +++ b/tools/opendrop_send.sh @@ -8,11 +8,12 @@ # tools/opendrop_send.sh -n MyPC file1.pdf file2.png # tools/opendrop_send.sh -r "Anna's iPhone" clip.mov # pick the receiver # -# On the iPhone: AirDrop "Everyone for 10 Minutes", unlock, and LEAVE THE SHARE -# SHEET CLOSED (an open share sheet makes the phone a sender, which never -# advertises a receiver for us to find). This script broadcasts the BLE wake -# (tools/airdrop_wake.py) to bring the phone's AirDrop receiver up, browses for -# it on awdl0, and sends. The phone shows an Accept prompt; tap Accept. +# On the iPhone/iPad: AirDrop "Everyone for 10 Minutes", unlock, open the share +# sheet and LEAVE IT OPEN: it keeps the device's AWDL awake. With the sheet +# closed it never advertises a receiver, BLE wake or not; airdrop-mt7921's +# working sends (FINDINGS §27, §37) had the sheet open plus the BLE wake. This +# script broadcasts that wake (tools/airdrop_wake.py), browses for the receiver +# on awdl0, and sends. The device shows an Accept prompt; tap Accept. # # Options: -n NAME sender name shown on the iPhone (default: EspDrop) # -i IFACE interface (default: awdl0) @@ -116,7 +117,7 @@ keylog=() [ -n "${ESPDROP_TLS_KEYLOG:-}" ] && { mkdir -p "$(dirname "$ESPDROP_TLS_KEYLOG")" && keylog=(SSLKEYLOGFILE="$ESPDROP_TLS_KEYLOG"); } if [ "$WAKE" -eq 1 ]; then - echo "broadcasting the AirDrop BLE wake (phone: AirDrop Everyone, share sheet CLOSED) ..." + echo "broadcasting the AirDrop BLE wake (device: AirDrop Everyone, unlocked, share sheet OPEN) ..." # shellcheck disable=SC2086 "$HERE/airdrop_wake.py" ${ESPDROP_WAKE_ARGS:-} >/dev/null 2>&1 & wake_pid=$! @@ -149,9 +150,9 @@ if [ -z "$recv_id" ] && [ -z "$RECV" ]; then | grep -v 'Save discovery results' | tail -n 15 | sed 's/^/ /' >&2 grep -qi 'not discoverable' "$findlog" && \ echo " -> a device answered but is not discoverable for us: set it to AirDrop Everyone." >&2 - echo " - the iPhone must be on AirDrop -> Everyone with the share sheet CLOSED," >&2 - echo " unlocked and awake, and in range on the channel awdl0 is parked on (check" >&2 - echo " 'node' in the bridge: auto=on and chan= the phone's 5 GHz channel)." >&2 + echo " - the iPhone/iPad must be on AirDrop -> Everyone, unlocked, with its share" >&2 + echo " sheet OPEN, and in range on the channel awdl0 is parked on (check 'node'" >&2 + echo " in the bridge: auto=on and chan= the device's 5 GHz channel)." >&2 echo " - the BLE wake needs the PC's bluetooth on; without python3-dbus/python3-gi" >&2 echo " it is skipped. Raise the browse time with -t if it is slow to appear." >&2 exit 1 From 7930ff0057010231c8122873c4c1d12f6a034047 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 22 Oct 2026 20:47:49 +0000 Subject: [PATCH 51/99] fix(host): never bring awdl0 up with a random MAC The bridge's one-shot MAC query had failed, so awdl0 came up with a kernel-assigned MAC (fe80::484a:... instead of the C5's EUI-64 address). Over the air our frames still came from the C5's MAC, but their IPv6 source belonged to another MAC, and an iPad never answered our AirDrop queries. - query_mac sends a bare 0x00 first, so bytes left in the device's COBS reader from an earlier session cannot corrupt the command (empty frames are ignored), and tries 3 times, which also covers a device still booting. - Without a MAC (and without --mac) the bridge now stops with an error instead of configuring awdl0 with a random one. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01XzgaN4Bv1m8Ka9iqo7QVUN --- tools/awdl_bridge.py | 44 +++++++++++++++++++++++++++++++------------- 1 file changed, 31 insertions(+), 13 deletions(-) diff --git a/tools/awdl_bridge.py b/tools/awdl_bridge.py index 7786358..ef8a0a3 100644 --- a/tools/awdl_bridge.py +++ b/tools/awdl_bridge.py @@ -118,19 +118,28 @@ def usable_link_local(dev: str): return None -def query_mac(usb_fd: int, reader: link.Reader, timeout: float = 2.0): - """Ask the device for its Wi-Fi MAC; print any LOG lines seen meanwhile.""" - try: - link.write_all(usb_fd, link.frame(link.MSG_CMD, b"mac")) - except (OSError, TimeoutError) as e: - print(f"warning: could not query device MAC ({e})", file=sys.stderr) - return None - for t, p in link.read_frames(usb_fd, reader, timeout): - text = p.decode("utf-8", "replace").strip() - if t == link.MSG_LOG: - print(f"[device] {text}") - elif t == link.MSG_CMD_REPLY and text.startswith("mac "): - return text[4:].strip() +def query_mac(usb_fd: int, reader: link.Reader, timeout: float = 2.0, tries: int = 3): + """Ask the device for its Wi-Fi MAC; print any LOG lines seen meanwhile. + + Each try starts with a bare 0x00 delimiter: bytes left in the device's COBS + reader from an earlier session would otherwise corrupt the first command + (the device ignores the empty frame). Several tries also cover a device that + is still booting, e.g. right after flashing. + """ + for attempt in range(1, tries + 1): + try: + link.write_all(usb_fd, b"\x00" + link.frame(link.MSG_CMD, b"mac")) + except (OSError, TimeoutError) as e: + print(f"warning: could not query device MAC ({e})", file=sys.stderr) + return None + for t, p in link.read_frames(usb_fd, reader, timeout): + text = p.decode("utf-8", "replace").strip() + if t == link.MSG_LOG: + print(f"[device] {text}") + elif t == link.MSG_CMD_REPLY and text.startswith("mac "): + return text[4:].strip() + if attempt < tries: + print(f"no MAC reply from the device yet (try {attempt}/{tries}), retrying ...") return None @@ -164,6 +173,15 @@ def main() -> int: if mac and not MAC_RE.match(mac): print(f"warning: ignoring malformed MAC {mac!r}", file=sys.stderr) mac = None + # awdl0 must carry the device's own Wi-Fi MAC: Apple peers tie a peer's + # IPv6 link-local to the MAC it transmits from, and ignore traffic whose + # source address belongs to some other MAC (a kernel-assigned TAP MAC + # made an iPad never answer our AirDrop queries). + if not mac and not args.no_config: + print("error: the device did not report its MAC, so awdl0 would get a random\n" + "one that Apple devices do not answer. Replug the device and retry, or\n" + "pass it with --mac (console `info` shows it).", file=sys.stderr) + return 1 tap_fd = open_tap(args.dev) os.set_blocking(tap_fd, False) From 1e6ecbd92b8f334b5dc302c5de073ede34577430 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 22 Oct 2026 21:02:58 +0000 Subject: [PATCH 52/99] fix(m3): tell a blocked /Discover apart from a non-discoverable device OpenDrop resolves the receiver via mDNS, then POSTs /Discover over HTTPS; a timeout there is also logged as "not discoverable", and a hung TCP connect (no default timeout) only shows as "AirDrop service found" with no result. Say which case it is and how to check the TCP path. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01XzgaN4Bv1m8Ka9iqo7QVUN --- tools/opendrop_send.sh | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/tools/opendrop_send.sh b/tools/opendrop_send.sh index 4a7dbf3..7cf3ee4 100755 --- a/tools/opendrop_send.sh +++ b/tools/opendrop_send.sh @@ -148,8 +148,16 @@ if [ -z "$recv_id" ] && [ -z "$RECV" ]; then echo "no AirDrop receiver found. What the browse saw (full log: $findlog):" >&2 grep -iE 'discover|airdrop|receiver|service|resolv|error|exception|refused|timed out' "$findlog" \ | grep -v 'Save discovery results' | tail -n 15 | sed 's/^/ /' >&2 - grep -qi 'not discoverable' "$findlog" && \ - echo " -> a device answered but is not discoverable for us: set it to AirDrop Everyone." >&2 + # OpenDrop: mDNS gives "AirDrop service found", then an HTTPS POST /Discover + # to the device; a timeout there is also logged as "not discoverable". + if grep -qi 'not discoverable' "$findlog"; then + echo " -> a device was found but /Discover gave no name: it is on Contacts Only," >&2 + echo " or the HTTPS request to it timed out (see the TCP check below)." >&2 + elif grep -q 'AirDrop service found' "$findlog"; then + echo " -> a device was found via mDNS, but its /Discover (HTTPS to the device over" >&2 + echo " awdl0) never answered: our TCP to it is not getting through. Check with" >&2 + echo " sudo tcpdump -i $IFACE -n 'tcp portrange 8770-8771' while sending." >&2 + fi echo " - the iPhone/iPad must be on AirDrop -> Everyone, unlocked, with its share" >&2 echo " sheet OPEN, and in range on the channel awdl0 is parked on (check 'node'" >&2 echo " in the bridge: auto=on and chan= the device's 5 GHz channel)." >&2 From 6413188bf5caf6105a5134575575bfd2281e531c Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 22 Oct 2026 21:19:04 +0000 Subject: [PATCH 53/99] fix(m3): start the browse before the BLE wake A device woken by the BLE advert announces its AirDrop receiver once, unsolicited; with the wake started first, that announcement could arrive before `opendrop find` was listening (the capture showed the receiver's answer at the very start and nothing afterwards, with no TCP attempt). Start the browse, then the wake two seconds later. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01XzgaN4Bv1m8Ka9iqo7QVUN --- tools/opendrop_send.sh | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/tools/opendrop_send.sh b/tools/opendrop_send.sh index 7cf3ee4..db42dd9 100755 --- a/tools/opendrop_send.sh +++ b/tools/opendrop_send.sh @@ -116,6 +116,15 @@ trap cleanup EXIT keylog=() [ -n "${ESPDROP_TLS_KEYLOG:-}" ] && { mkdir -p "$(dirname "$ESPDROP_TLS_KEYLOG")" && keylog=(SSLKEYLOGFILE="$ESPDROP_TLS_KEYLOG"); } +echo "browsing for an AirDrop receiver on $IFACE (up to ${BROWSE}s) ..." +: > "$findlog" +# Browse FIRST, then wake: a device woken by the BLE advert announces itself +# once, unsolicited, and that announcement is lost if nobody is listening yet. +# -d: log every service seen and why a receiver was skipped. +env PYTHONUNBUFFERED=1 "$VENV/bin/opendrop" -n "$NAME" -d -i "$IFACE" find > "$findlog" 2>&1 & +find_pid=$! +sleep 2 + if [ "$WAKE" -eq 1 ]; then echo "broadcasting the AirDrop BLE wake (device: AirDrop Everyone, unlocked, share sheet OPEN) ..." # shellcheck disable=SC2086 @@ -125,13 +134,6 @@ if [ "$WAKE" -eq 1 ]; then kill -0 "$wake_pid" 2>/dev/null || { wake_pid=""; echo "warning: BLE wake did not start (python3-dbus / python3-gi installed, bluetooth on?); browsing anyway" >&2; } fi -echo "browsing for an AirDrop receiver on $IFACE (up to ${BROWSE}s) ..." -: > "$findlog" -# -d: log every service seen and why a receiver was skipped (e.g. "not -# discoverable" = the device answers but is on Contacts Only). -env PYTHONUNBUFFERED=1 "$VENV/bin/opendrop" -n "$NAME" -d -i "$IFACE" find > "$findlog" 2>&1 & -find_pid=$! - # `opendrop find` never exits on its own; stop at the first receiver or timeout. recv_id="" for _ in $(seq 1 "$((BROWSE * 2))"); do From bf033066334ab413b828d6bb47a909c35991ca10 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 22 Oct 2026 21:50:36 +0000 Subject: [PATCH 54/99] feat(tools): mdns_probe.py, check mDNS on awdl0 reaches a program `opendrop find` registered no AirDrop service at all although tcpdump had shown a receiver's mDNS answer. tcpdump sees packets before the firewall and before any socket, so this probe binds the mDNS port like zeroconf does, joins ff02::fb on the interface, asks for _airdrop._tcp every 1.5 s and reports per source what actually arrives at the socket. Standard library only. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01XzgaN4Bv1m8Ka9iqo7QVUN --- tools/mdns_probe.py | 110 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 110 insertions(+) create mode 100755 tools/mdns_probe.py diff --git a/tools/mdns_probe.py b/tools/mdns_probe.py new file mode 100755 index 0000000..05bb229 --- /dev/null +++ b/tools/mdns_probe.py @@ -0,0 +1,110 @@ +#!/usr/bin/env python3 +# EspDrop - check that mDNS on awdl0 actually reaches a program. +# +# tcpdump sees packets before the firewall and before any socket, so "it is in +# the capture" does not mean an application receives it. This probe binds the +# mDNS port like OpenDrop's zeroconf does, joins ff02::fb on the interface, +# asks for AirDrop receivers (_airdrop._tcp) every 1.5 s, and prints what +# arrives at the socket. Standard library only; run as your normal user while +# the bridge is up, with the target device in the state for sending: +# +# tools/mdns_probe.py # 30 s on awdl0 +# tools/mdns_probe.py -t 60 -i awdl0 +# +# Reading the result: packets from other addresses arriving at all means the +# kernel delivers mDNS on awdl0 to programs; a response mentioning _airdrop +# means a receiver answered. If tcpdump shows such answers but this probe gets +# nothing from other hosts, something between (a firewall, a missing group +# membership) drops them. +# +# SPDX-License-Identifier: GPL-3.0-or-later +# Copyright (C) 2026 EspDrop contributors + +import argparse +import select +import socket +import struct +import sys +import time + +MDNS_ADDR = "ff02::fb" +MDNS_PORT = 5353 +SERVICE = "_airdrop._tcp.local." + + +def ptr_query(name: str) -> bytes: + """A one-question mDNS query: name PTR IN (multicast response requested).""" + q = b"".join(bytes([len(p)]) + p.encode() for p in name.rstrip(".").split(".")) + b"\x00" + return struct.pack("!6H", 0, 0, 1, 0, 0, 0) + q + struct.pack("!2H", 12, 1) + + +def main() -> int: + ap = argparse.ArgumentParser(description="mDNS reception check on an interface") + ap.add_argument("-i", "--iface", default="awdl0") + ap.add_argument("-t", "--time", type=float, default=30.0, help="seconds to listen") + args = ap.parse_args() + + try: + idx = socket.if_nametoindex(args.iface) + except OSError: + print(f"error: no interface {args.iface}", file=sys.stderr) + return 1 + + s = socket.socket(socket.AF_INET6, socket.SOCK_DGRAM) + s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + if hasattr(socket, "SO_REUSEPORT"): + s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEPORT, 1) + s.bind(("::", MDNS_PORT)) + group = socket.inet_pton(socket.AF_INET6, MDNS_ADDR) + struct.pack("@I", idx) + try: + s.setsockopt(socket.IPPROTO_IPV6, socket.IPV6_JOIN_GROUP, group) + except OSError as e: + print(f"error: joining {MDNS_ADDR} on {args.iface} failed: {e}", file=sys.stderr) + return 1 + s.setsockopt(socket.IPPROTO_IPV6, socket.IPV6_MULTICAST_IF, struct.pack("@I", idx)) + s.setsockopt(socket.IPPROTO_IPV6, socket.IPV6_MULTICAST_HOPS, 255) + s.setsockopt(socket.IPPROTO_IPV6, socket.IPV6_MULTICAST_LOOP, 0) + + query = ptr_query(SERVICE) + print(f"listening on [{MDNS_ADDR}%{args.iface}]:{MDNS_PORT} for {args.time:.0f}s, " + f"asking for {SERVICE} every 1.5 s ...") + end = time.monotonic() + args.time + next_q = 0.0 + seen = {} # source -> [packets, responses, airdrop responses] + while time.monotonic() < end: + now = time.monotonic() + if now >= next_q: + try: + s.sendto(query, (MDNS_ADDR, MDNS_PORT, 0, idx)) + except OSError as e: + print(f"send failed: {e}", file=sys.stderr) + next_q = now + 1.5 + r, _, _ = select.select([s], [], [], max(0.0, min(next_q, end) - now)) + if not r: + continue + data, addr = s.recvfrom(9000) + src = addr[0] + if len(data) < 12: + continue + is_resp = bool(struct.unpack("!H", data[2:4])[0] & 0x8000) + airdrop = b"_airdrop" in data + st = seen.setdefault(src, [0, 0, 0]) + st[0] += 1 + st[1] += is_resp + st[2] += is_resp and airdrop + if is_resp and airdrop: + print(f"{time.strftime('%H:%M:%S')} AirDrop RESPONSE from {src} ({len(data)} bytes)") + elif st[0] == 1: + print(f"{time.strftime('%H:%M:%S')} first packet from {src} " + f"({'response' if is_resp else 'query'}, {len(data)} bytes)") + + print("\nsummary (per source: packets / responses / AirDrop responses):") + if not seen: + print(" nothing received at all - not even other devices' mDNS reached this socket") + for src, (n, nr, na) in seen.items(): + print(f" {src}: {n} / {nr} / {na}") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) From efea1cf859b4869d4c583e957615daf68014632e Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 22 Oct 2026 22:11:31 +0000 Subject: [PATCH 55/99] tools: zeroconf_probe.py - watch OpenDrop's zeroconf browse per socket mdns_probe showed the iPad's _airdrop._tcp answers reach an ordinary socket on awdl0 while `opendrop find` reports nothing. Feeding a response shaped like the captured one (14 answers, NSEC, OPT) into zeroconf 0.151.5 with the patched AirDropBrowser does yield 'Add service', so parsing and the browser are fine and the loss must be at zeroconf's sockets on the real link. This probe runs zeroconf from the OpenDrop venv exactly like find does and logs, per packet, which zeroconf socket got it and whether it parsed; a non-joining witness socket shows what the kernel accepted under zeroconf's own group membership; it prints the ff02::fb membership of the interface and every UDP 5353 socket (owner, uid, rx queue, drops), then a verdict. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01XzgaN4Bv1m8Ka9iqo7QVUN --- docs/SPIKES.md | 9 + tools/zeroconf_probe.py | 357 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 366 insertions(+) create mode 100755 tools/zeroconf_probe.py diff --git a/docs/SPIKES.md b/docs/SPIKES.md index 0f3edec..cdb24a9 100644 --- a/docs/SPIKES.md +++ b/docs/SPIKES.md @@ -219,6 +219,15 @@ Mehrere Apple-Geräte in Reichweite: mit `-r "Annas iPhone"` das Ziel am Namen wählen. „Nur Kontakte“ statt „Jeder“: Identifikator über `ESPDROP_WAKE_ARGS="--appleid du@icloud.com"` an das Wecksignal geben. +Fehlersuche, wenn kein Empfänger gefunden wird (Gerät bereit, Wecksignal läuft): +- `tools/mdns_probe.py`: Kommen mDNS-Antworten des Geräts überhaupt bei einem + Programm an? (Standardbibliothek, ohne venv.) +- `~/opendrop-venv/bin/python tools/zeroconf_probe.py`: dieselbe Suche wie + `opendrop find` mit OpenDrops zeroconf. Zeigt pro Paket, was zeroconf auf + welchem Socket bekam und ob es es lesen konnte, die Gruppenmitgliedschaft + von `awdl0` und alle anderen Sockets auf UDP 5353, und endet mit einem + Befund. Die Ausgabe enthält Gerätenamen und Adressen. + ## Danach Ergebnisse (auch Fehlschläge) in `docs/UEBERBLICK.md` unter „Offene Fragen" diff --git a/tools/zeroconf_probe.py b/tools/zeroconf_probe.py new file mode 100755 index 0000000..59416f0 --- /dev/null +++ b/tools/zeroconf_probe.py @@ -0,0 +1,357 @@ +#!/usr/bin/env python3 +# EspDrop - watch OpenDrop's own zeroconf browse for AirDrop receivers. +# +# tools/mdns_probe.py shows whether mDNS on awdl0 reaches a program at all. +# This goes one step further: it runs the zeroconf library from the OpenDrop +# venv the way `opendrop find` sets it up (the interface's first IPv6 address, +# IPv6 only, a browser for _airdrop._tcp plus the patched 1.5 s repeat query) +# and reports what zeroconf itself received on each of its sockets, whether it +# parsed it, and whether the browser reported a receiver. A plain "witness" +# socket listens alongside WITHOUT joining the mDNS group itself (Linux hands a +# wildcard-bound socket every group packet the interface accepted), so it sees +# exactly what zeroconf's own membership lets in and changes nothing. It also +# lists the group memberships on the interface and every socket on UDP 5353, +# since a second mDNS stack of the same user (a leftover `opendrop`, a browser) +# can take unicast answers meant for this one. +# +# Run it with the venv's python while the bridge is up and the target is ready +# (AirDrop Everyone, unlocked, share sheet OPEN, tools/airdrop_wake.py running): +# +# ~/opendrop-venv/bin/python tools/zeroconf_probe.py +# ~/opendrop-venv/bin/python tools/zeroconf_probe.py -t 60 -i awdl0 +# +# The output names devices and addresses; redact them before sharing it. +# +# SPDX-License-Identifier: GPL-3.0-or-later +# Copyright (C) 2026 EspDrop contributors + +import argparse +import logging +import os +import select +import socket +import struct +import sys +import threading +import time + +try: + import ifaddr + import zeroconf + from zeroconf import DNSOutgoing, DNSQuestion, IPVersion, ServiceBrowser, Zeroconf, const +except ImportError as e: + sys.exit(f"error: {e.name} not found; run this with the OpenDrop venv's python:\n" + f" ~/opendrop-venv/bin/python {sys.argv[0]}") + +MDNS_ADDR = "ff02::fb" +MDNS_PORT = 5353 +SERVICE = "_airdrop._tcp.local." +IPV6_RECVPKTINFO = getattr(socket, "IPV6_RECVPKTINFO", 49) +IPV6_PKTINFO = getattr(socket, "IPV6_PKTINFO", 50) +IF_FLAGS = {0x01: "temporary", 0x08: "dadfailed", 0x20: "deprecated", 0x40: "tentative"} + +lock = threading.Lock() +t0 = time.monotonic() + + +def say(msg: str) -> None: + with lock: + print(f"{time.monotonic() - t0:6.1f}s {msg}", flush=True) + + +def udp_5353_sockets() -> list: + """Every UDP socket on port 5353: (family, local addr, uid, inode, rx_queue, drops).""" + out = [] + for fam, path in (("v6", "/proc/net/udp6"), ("v4", "/proc/net/udp")): + try: + lines = open(path).read().splitlines()[1:] + except OSError: + continue + for line in lines: + f = line.split() + hexaddr, hexport = f[1].split(":") + if int(hexport, 16) != MDNS_PORT: + continue + raw = bytes.fromhex(hexaddr) + if sys.byteorder == "little": # the kernel prints 32-bit words in host order + raw = b"".join(raw[i:i + 4][::-1] for i in range(0, len(raw), 4)) + addr = socket.inet_ntop(socket.AF_INET6 if fam == "v6" else socket.AF_INET, raw) + out.append((fam, addr, int(f[7]), f[9], int(f[4].split(":")[1], 16), int(f[-1]))) + return out + + +def socket_owners() -> dict: + """inode -> "pid name" for the processes we may look into.""" + owners = {} + for pid in filter(str.isdigit, os.listdir("/proc")): + try: + name = open(f"/proc/{pid}/comm").read().strip() + cmd = open(f"/proc/{pid}/cmdline", "rb").read().replace(b"\0", b" ").decode(errors="replace").strip() + for fd in os.listdir(f"/proc/{pid}/fd"): + link = os.readlink(f"/proc/{pid}/fd/{fd}") + if link.startswith("socket:["): + owners[link[8:-1]] = f"pid {pid} {name}: {cmd[:70]}" + except OSError: + continue + return owners + + +def print_sockets(title: str, mine: set) -> None: + socks = udp_5353_sockets() + owners = socket_owners() + me = os.getuid() + print(f"{title}:") + if not socks: + print(" (none)") + for fam, addr, uid, inode, rxq, drops in socks: + who = "this probe" if inode in mine else owners.get(inode, "process not visible (other user?)") + same = " SAME USER" if uid == me and inode not in mine else "" + print(f" udp{fam} [{addr}]:5353 uid {uid}{same} rxq {rxq} drops {drops} {who}") + + +class Stats: + def __init__(self): + self.by_src = {} # (source, socket) -> [packets, AirDrop responses, invalid] + self.dups = 0 + self.witness = {} # source -> [AirDrop responses multicast, unicast] + self.found = [] + + +class ZeroconfLog(logging.Handler): + """Turns zeroconf's debug log into per-packet facts.""" + + def __init__(self, st: Stats): + super().__init__(logging.DEBUG) + self.st = st + + def emit(self, rec: logging.LogRecord) -> None: + try: + self._emit(rec) + except Exception as e: # never let a diagnostic kill zeroconf's loop + say(f"(log handler: {e!r})") + + def _emit(self, rec): + tmpl, a = str(rec.msg), rec.args or () + if tmpl.startswith("Received from") and len(a) == 6: # valid packet + addr, _port, sock, msg, n, data = a + self._count(addr, sock, not msg.is_query(), data, valid=True, msg=msg, n=n) + elif tmpl.startswith("Received from") and len(a) == 5: # failed to parse + addr, _port, sock, n, data = a + self._count(addr, sock, bool(data[2] & 0x80) if len(data) > 2 else False, data, valid=False, n=n) + elif tmpl.startswith("Ignoring duplicate"): + self.st.dups += 1 + elif tmpl.startswith(("Received invalid packet", "Unable to parse", "Record for")): + text = rec.getMessage().split(" while unpacking")[0][:160] + err = f" ({rec.exc_info[1]})" if rec.exc_info and rec.exc_info[1] else "" + say(f"zeroconf parse problem: {text}{err}") + elif tmpl.startswith(("Creating new socket", "Adding %r", "Configuring socket")): + say("zeroconf setup: " + rec.getMessage()[:150]) + elif rec.levelno >= logging.WARNING: + say(f"zeroconf {rec.levelname}: {rec.getMessage()[:200]}") + + def _count(self, addr, sock, is_resp, data, valid, n, msg=None): + # sock is " ()": the wildcard one is the listen socket. + kind = "listen" if "('::'," in str(sock) else "respond" + key = (addr, f"{kind} socket fd {str(sock).split()[0]}") + st = self.st.by_src.setdefault(key, [0, 0, 0]) + st[0] += 1 + st[2] += not valid + if not (is_resp and b"_airdrop" in data): + return + st[1] += 1 + if not valid: + say(f"zeroconf got an AirDrop response from {addr} on its {kind} socket " + f"({n} bytes) but could NOT parse it") + return + recs = [r for r in msg.answers() if "_airdrop" in r.name.lower()] + desc = ", ".join(sorted({f"{const._TYPES.get(r.type, r.type)}" + + (f"->{r.alias}" if hasattr(r, "alias") else "") + + (f":{r.port}" if hasattr(r, "port") else "") + for r in recs})) + say(f"zeroconf got AirDrop response from {addr} on its {kind} socket ({n} bytes): {desc or 'no _airdrop records'}") + + +def group_members(iface: str) -> str: + """How many sockets joined ff02::fb on iface (/proc/net/igmp6), as text.""" + try: + for line in open("/proc/net/igmp6"): + f = line.split() + if len(f) >= 4 and f[1] == iface and f[2] == "ff0200000000000000000000000000fb": + return f"{iface} is a member of {MDNS_ADDR} ({int(f[3])} joins)" + except OSError: + return "/proc/net/igmp6 not readable" + return f"{iface} is NOT a member of {MDNS_ADDR} - group answers are dropped before any socket" + + +def witness(st: Stats, stop: threading.Event, ready: list) -> None: + """A plain socket next to zeroconf's: what the kernel hands to programs.""" + try: + s = socket.socket(socket.AF_INET6, socket.SOCK_DGRAM) + s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + if hasattr(socket, "SO_REUSEPORT"): + s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEPORT, 1) + s.bind(("::", MDNS_PORT)) + # No IPV6_JOIN_GROUP on purpose (see the top of the file). + s.setsockopt(socket.IPPROTO_IPV6, IPV6_RECVPKTINFO, 1) + except OSError as e: + say(f"witness socket failed: {e}") + return + ready.append(str(os.fstat(s.fileno()).st_ino)) + while not stop.is_set(): + r, _, _ = select.select([s], [], [], 0.3) + if not r: + continue + data, anc, _flags, src = s.recvmsg(9000, socket.CMSG_SPACE(20)) + if len(data) < 12 or not (data[2] & 0x80) or b"_airdrop" not in data: + continue + dst = "?" + for lvl, typ, val in anc: + if lvl == socket.IPPROTO_IPV6 and typ == IPV6_PKTINFO and len(val) >= 16: + dst = socket.inet_ntop(socket.AF_INET6, val[:16]) + w = st.witness.setdefault(src[0], [0, 0]) + w[0 if dst.startswith("ff") else 1] += 1 + say(f"witness got AirDrop response from {src[0]} to {dst} ({len(data)} bytes)") + s.close() + + +class Listener: + def __init__(self, st: Stats): + self.st = st + + def _report(self, what, zc, type_, name): + t = time.monotonic() + info = zc.get_service_info(type_, name) # as OpenDrop does, in the browser thread + took = time.monotonic() - t + if info is None: + say(f"browser: {what} {name} - get_service_info gave nothing after {took:.1f}s") + return + self.st.found.append(name) + say(f"browser: {what} {name} -> {info.server} port {info.port} " + f"addresses {info.parsed_addresses()} (resolved in {took:.1f}s)") + + def add_service(self, zc, type_, name): + self._report("ADD", zc, type_, name) + + def update_service(self, zc, type_, name): + self._report("update", zc, type_, name) + + def remove_service(self, zc, type_, name): + say(f"browser: remove {name}") + + +def main() -> int: + ap = argparse.ArgumentParser(description="watch OpenDrop's zeroconf browse on an interface") + ap.add_argument("-i", "--iface", default="awdl0") + ap.add_argument("-t", "--time", type=float, default=45.0, help="seconds to browse") + args = ap.parse_args() + + adapter = next((a for a in ifaddr.get_adapters() if a.name == args.iface), None) + if adapter is None: + print(f"error: no interface {args.iface}", file=sys.stderr) + return 1 + v6 = [ip.ip for ip in adapter.ips if ip.is_IPv6] + print(f"zeroconf {zeroconf.__version__}, python {sys.version.split()[0]}, {args.iface} index {adapter.index}") + flags = {} + try: + for line in open("/proc/net/if_inet6"): + f = line.split() + if f[5] == args.iface: + flags[socket.inet_ntop(socket.AF_INET6, bytes.fromhex(f[0]))] = int(f[4], 16) + except OSError: + pass + for i, (addr, _flow, scope) in enumerate(v6): + fl = [n for b, n in IF_FLAGS.items() if flags.get(addr, 0) & b] + print(f" IPv6 {addr} scope {scope}{' [' + ','.join(fl) + ']' if fl else ''}" + f"{' <- what OpenDrop uses (the first one)' if i == 0 else ''}") + if not v6: + print(f"error: {args.iface} has no IPv6 address", file=sys.stderr) + return 1 + if len(v6) > 1: + print(" note: more than one IPv6 address; OpenDrop binds its sender to the first only") + print_sockets("other UDP 5353 sockets before we start", set()) + print("before:", group_members(args.iface)) + + st = Stats() + zlog = logging.getLogger("zeroconf") + zlog.addHandler(ZeroconfLog(st)) + zlog.setLevel(logging.DEBUG) + zlog.propagate = False + + stop = threading.Event() + mine = [] + threading.Thread(target=witness, args=(st, stop, mine), daemon=True).start() + + print(f"\nbrowsing {SERVICE} for {args.time:.0f}s like `opendrop find` ...") + zc = Zeroconf(interfaces=[v6[0][0]], ip_version=IPVersion.V6Only) + browser = ServiceBrowser(zc, SERVICE, Listener(st)) + say("with zeroconf: " + group_members(args.iface)) + + def repeat(): # the patched OpenDrop's steady query (opendrop-mdns-repeat) + while not stop.wait(1.5): + try: + out = DNSOutgoing(const._FLAGS_QR_QUERY) + out.add_question(DNSQuestion(SERVICE, const._TYPE_PTR, const._CLASS_IN)) + zc.send(out) + except Exception as e: + say(f"repeat query failed: {e}") + threading.Thread(target=repeat, daemon=True).start() + + end = time.monotonic() + args.time + next_status = time.monotonic() + 10 + try: + while time.monotonic() < end: + time.sleep(0.2) + if time.monotonic() >= next_status: + next_status += 10 + zr = sum(v[1] for v in st.by_src.values()) + wr = sum(sum(v) for v in st.witness.values()) + say(f"status: zeroconf AirDrop responses {zr}, witness {wr}, receivers found {len(set(st.found))}") + except KeyboardInterrupt: + pass + stop.set() + + print("\nzeroconf received (source / socket: packets, AirDrop responses, invalid):") + if not st.by_src: + print(" nothing at all - not even our own looped-back queries") + for (src, sock), (n, na, bad) in sorted(st.by_src.items()): + print(f" {src} / {sock}: {n} packets, {na} AirDrop responses, {bad} invalid") + if st.dups: + print(f" ({st.dups} duplicate packets ignored by zeroconf, normal)") + print("witness socket (AirDrop responses: multicast / unicast):") + if not st.witness: + print(" none") + for src, (mc, uc) in st.witness.items(): + print(f" {src}: {mc} / {uc}") + for t in zc.engine.readers: # mark zeroconf's own sockets in the list + try: + mine.append(str(os.fstat(t.fileno).st_ino)) + except (OSError, TypeError): + pass + print_sockets("UDP 5353 sockets at the end", set(mine)) + + zr = sum(v[1] for v in st.by_src.values()) + zbad = sum(v[2] for v in st.by_src.values()) + wr = sum(sum(v) for v in st.witness.values()) + print("\nverdict:", end=" ") + if st.found: + print("zeroconf FOUND the receiver. Browsing works here; compare with `opendrop find`.") + elif zr and zbad: + print("zeroconf received AirDrop answers but could not parse some (see 'parse problem').") + elif zr: + print("zeroconf received and parsed AirDrop answers, yet the browser reported nothing.") + elif wr: + print("the witness got AirDrop answers that zeroconf never saw: its sockets miss\n" + " what the kernel delivers (see the socket list: another mDNS stack of the same user?).") + else: + print("no AirDrop answer reached any socket. If tcpdump on the interface showed\n" + " answers from the device during this run, the group membership is missing\n" + " (see the member line above); if not, the device did not answer.") + + # zeroconf.close() can hang on this link (see opendrop find); do not wait for it. + threading.Thread(target=lambda: (browser.cancel(), zc.close()), daemon=True).start() + time.sleep(1) + os._exit(0) + + +if __name__ == "__main__": + sys.exit(main()) From 2d45348d6343d91e1d77bce250ce1430f0f690d3 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 22 Oct 2026 22:24:36 +0000 Subject: [PATCH 56/99] tools: point at the expiring 'Everyone for 10 Minutes' when nothing answers The first zeroconf_probe run on hardware shows zeroconf receiving from four hosts on awdl0, the iPad included, with ff02::fb joined: reception works, the iPad simply sent no AirDrop answer that run, where mdns_probe got 14 earlier. The probe's verdict now says so when the group is joined, and it, opendrop_send.sh and SPIKES.md name the likely cause: iOS drops 'Everyone for 10 Minutes' back to Contacts Only after ten minutes. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01XzgaN4Bv1m8Ka9iqo7QVUN --- docs/SPIKES.md | 3 ++- tools/opendrop_send.sh | 2 ++ tools/zeroconf_probe.py | 13 ++++++++++--- 3 files changed, 14 insertions(+), 4 deletions(-) diff --git a/docs/SPIKES.md b/docs/SPIKES.md index cdb24a9..c3d4449 100644 --- a/docs/SPIKES.md +++ b/docs/SPIKES.md @@ -210,7 +210,8 @@ Pakete `python3-dbus` und `python3-gi` (Fedora: `python3-dbus python3-gobject`). ``` Das Skript sendet das BLE-Wecksignal (`tools/airdrop_wake.py`), sucht den Empfänger auf `awdl0` und überträgt dann. -3. Am iPhone/iPad: AirDrop auf „Für alle für 10 Minuten“, entsperrt, +3. Am iPhone/iPad: AirDrop auf „Für alle für 10 Minuten“ (fällt nach 10 Minuten + auf „Nur Kontakte“ zurück, also kurz vor dem Senden neu wählen), entsperrt, **Teilen-Menü öffnen und offen lassen** (es hält AWDL wach; bei geschlossenem Menü meldet sich das Gerät nie als Empfänger). Bei der Abfrage „Annehmen“ tippen. diff --git a/tools/opendrop_send.sh b/tools/opendrop_send.sh index db42dd9..fdfe8fb 100755 --- a/tools/opendrop_send.sh +++ b/tools/opendrop_send.sh @@ -163,6 +163,8 @@ if [ -z "$recv_id" ] && [ -z "$RECV" ]; then echo " - the iPhone/iPad must be on AirDrop -> Everyone, unlocked, with its share" >&2 echo " sheet OPEN, and in range on the channel awdl0 is parked on (check 'node'" >&2 echo " in the bridge: auto=on and chan= the device's 5 GHz channel)." >&2 + echo " - 'Everyone for 10 Minutes' falls back to Contacts Only after 10 minutes;" >&2 + echo " select it again right before sending." >&2 echo " - the BLE wake needs the PC's bluetooth on; without python3-dbus/python3-gi" >&2 echo " it is skipped. Raise the browse time with -t if it is slow to appear." >&2 exit 1 diff --git a/tools/zeroconf_probe.py b/tools/zeroconf_probe.py index 59416f0..9b24c00 100755 --- a/tools/zeroconf_probe.py +++ b/tools/zeroconf_probe.py @@ -343,9 +343,16 @@ def repeat(): # the patched OpenDrop's steady query (opendrop-mdns-repeat) print("the witness got AirDrop answers that zeroconf never saw: its sockets miss\n" " what the kernel delivers (see the socket list: another mDNS stack of the same user?).") else: - print("no AirDrop answer reached any socket. If tcpdump on the interface showed\n" - " answers from the device during this run, the group membership is missing\n" - " (see the member line above); if not, the device did not answer.") + heard = sorted({src for src, _sock in st.by_src} - {v6[0][0]}) + if "NOT a member" in group_members(args.iface): + print("no AirDrop answer reached any socket, and the interface is not in the\n" + " mDNS group, so group answers are dropped before any socket.") + else: + print("no AirDrop answer arrived: the device did not answer this run.\n" + f" zeroconf did receive from {len(heard)} other host(s), so reception works.\n" + " Check the device: AirDrop 'Everyone for 10 Minutes' falls back to\n" + " Contacts Only after 10 minutes (select it again), unlocked, share sheet\n" + " open, and the BLE wake running.") # zeroconf.close() can hang on this link (see opendrop find); do not wait for it. threading.Thread(target=lambda: (browser.cancel(), zc.close()), daemon=True).start() From c5d81f97f02f53652d9fef26f0c570508152f188 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 22 Oct 2026 22:40:29 +0000 Subject: [PATCH 57/99] opendrop_send: record how far the /Discover TCP got before giving up The receiver is now found via mDNS, but the HTTPS /Discover to it hangs: OpenDrop's connection has no timeout, so the log just ends at 'Send /Discover request'. Before stopping find (which closes the socket), read the connection's state with ss and the peer's neighbour entry, and say what SYN-SENT or ESTAB means for where the exchange stalls. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01XzgaN4Bv1m8Ka9iqo7QVUN --- tools/opendrop_send.sh | 24 ++++++++++++++++++++++-- 1 file changed, 22 insertions(+), 2 deletions(-) diff --git a/tools/opendrop_send.sh b/tools/opendrop_send.sh index fdfe8fb..dd11308 100755 --- a/tools/opendrop_send.sh +++ b/tools/opendrop_send.sh @@ -143,6 +143,18 @@ for _ in $(seq 1 "$((BROWSE * 2))"); do [ -n "$recv_id" ] && break sleep 0.5 done +# mDNS found the device but /Discover is still pending: record how far its TCP +# got before stop_find closes the socket (OpenDrop's connect has no timeout). +tcp_state="" +neigh_state="" +if [ -z "$recv_id" ] && grep -q 'AirDrop service found' "$findlog"; then + # "AirDrop service found: , :, ID ..." + peer=$(sed -nE 's/.*AirDrop service found: [^,]*, ([0-9a-fA-F:.]+):([0-9]+),.*/\1 \2/p' "$findlog" | tail -n 1) + if [ -n "$peer" ]; then + command -v ss >/dev/null && tcp_state=$(ss -tnoH "( dport = :${peer#* } )" 2>/dev/null) + neigh_state=$(ip -6 neigh show dev "$IFACE" "${peer% *}" 2>/dev/null) + fi +fi stop_find # SIGINT so its finally: writes the report, then bounded reap grep -E 'Found[[:space:]]+index' "$findlog" | sed 's/^/ /' || true @@ -157,8 +169,16 @@ if [ -z "$recv_id" ] && [ -z "$RECV" ]; then echo " or the HTTPS request to it timed out (see the TCP check below)." >&2 elif grep -q 'AirDrop service found' "$findlog"; then echo " -> a device was found via mDNS, but its /Discover (HTTPS to the device over" >&2 - echo " awdl0) never answered: our TCP to it is not getting through. Check with" >&2 - echo " sudo tcpdump -i $IFACE -n 'tcp portrange 8770-8771' while sending." >&2 + echo " $IFACE) never answered." >&2 + echo " neighbour entry: ${neigh_state:-none}" >&2 + echo " its TCP connection when we gave up:" >&2 + printf '%s\n' "${tcp_state:-none (refused, failed or already closed)}" | sed 's/^/ /' >&2 + case "$tcp_state" in + *SYN-SENT*) echo " SYN-SENT: the device never answered our SYN - our frames do not reach" >&2 + echo " it, or its answers do not reach us (bridge 'tx': uc_noack?)." >&2 ;; + *ESTAB*) echo " ESTAB: TCP is up; the TLS handshake or the /Discover reply stalls." >&2 ;; + esac + echo " Watch it with sudo tcpdump -i $IFACE -n 'tcp port 8770 or icmp6'" >&2 fi echo " - the iPhone/iPad must be on AirDrop -> Everyone, unlocked, with its share" >&2 echo " sheet OPEN, and in range on the channel awdl0 is parked on (check 'node'" >&2 From 73a22049a6bfa7ec08df6d731470613f34ba4fd8 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 22 Oct 2026 22:57:44 +0000 Subject: [PATCH 58/99] feat(m3): switchable advertised sequence, mirror or verbatim (bridge `seq`) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On a send the iPad is found via mDNS, ACKs every unicast frame we send (uc_ack), yet never sends us a unicast frame: no SYN-ACK, no echo reply, not even a unicast mDNS answer. airdrop-mt7921 FINDINGS §25 saw exactly that and traced it to the channel sequence advertised; verbatim (the master's sequence unchanged) is the one iOS 26 is known to unicast to. - awdl_self_set_chanseq_verbatim copies the master's sequence, opclass per slot, with the same never-nowhere guard as the intersect. - awdl_node seq_mode (mirror by default) and awdl_node_set_seq_mode; TX is gated on slots naming our own channel, which is what it was for mirror and keeps verbatim from sending in the master's channel 6 slots. - awdl_app keeps the mode across set_self; bridge `seq [mirror|verbatim]`, `node` shows mode=. Host-tested (also under ASan/UBSan); C5 and C3 bridge and C5 console build. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01XzgaN4Bv1m8Ka9iqo7QVUN --- docs/SPIKES.md | 9 +++ firmware/spikes/components/awdl/awdl_frame.c | 16 +++++ firmware/spikes/components/awdl/awdl_node.c | 26 +++++++-- .../components/awdl/include/awdl_frame.h | 11 ++++ .../components/awdl/include/awdl_node.h | 23 ++++++-- .../awdl/test/host/test_awdl_frame.c | 58 +++++++++++++++++++ firmware/spikes/main/awdl_app.c | 14 +++++ firmware/spikes/main/awdl_app.h | 8 +++ firmware/spikes/main/bridge.c | 19 +++++- tools/awdl_bridge.py | 4 +- 10 files changed, 174 insertions(+), 14 deletions(-) diff --git a/docs/SPIKES.md b/docs/SPIKES.md index c3d4449..cf764a5 100644 --- a/docs/SPIKES.md +++ b/docs/SPIKES.md @@ -228,6 +228,15 @@ Fehlersuche, wenn kein Empfänger gefunden wird (Gerät bereit, Wecksignal läuf welchem Socket bekam und ob es es lesen konnte, die Gruppenmitgliedschaft von `awdl0` und alle anderen Sockets auf UDP 5353, und endet mit einem Befund. Die Ausgabe enthält Gerätenamen und Adressen. +- Gefunden, aber `/Discover` hängt in SYN-SENT, und `ping -6` an das Gerät + bleibt ohne Antwort, obwohl `tx` im Bridge-Fenster jeden Unicast als + quittiert zählt (`uc_ack`): Das Gerät empfängt uns, sendet uns aber keinen + Unicast. Im Referenzprojekt lag das an der angekündigten Kanal-Sequenz + (airdrop-mt7921 FINDINGS §25/§26). Im Bridge-Fenster umschaltbar, ohne neu + zu flashen: `seq mirror` (Standard: unser Kanal nur in den Slots des Masters + auf diesem Kanal) oder `seq verbatim` (die Sequenz des Masters unverändert). + Gesendet wird in beiden Fällen nur in den Slots auf unserem Kanal. `node` + zeigt den Modus (`mode=`) und beide Sequenzen. ## Danach diff --git a/firmware/spikes/components/awdl/awdl_frame.c b/firmware/spikes/components/awdl/awdl_frame.c index 5f83551..96c6a90 100644 --- a/firmware/spikes/components/awdl/awdl_frame.c +++ b/firmware/spikes/components/awdl/awdl_frame.c @@ -149,6 +149,22 @@ bool awdl_self_set_chanseq_intersect(awdl_self_t *self, return true; } +bool awdl_self_set_chanseq_verbatim(awdl_self_t *self, + const uint8_t master_chanseq[AWDL_CHANSEQ_LEN], + uint8_t keep_chan) { + unsigned present = 0; + for (unsigned i = 0; i < AWDL_CHANSEQ_LEN; i++) + if (master_chanseq[i] == keep_chan) present++; + if (present == 0) return false; /* as in the intersect, never "nowhere" */ + + for (unsigned i = 0; i < AWDL_CHANSEQ_LEN; i++) { + uint8_t ch = master_chanseq[i]; + self->seq[i][0] = ch; + self->seq[i][1] = ch ? opclass_for(ch) : 0; + } + return true; +} + /* ------------------------------------------------------------------------ */ /* 802.11 header */ /* ------------------------------------------------------------------------ */ diff --git a/firmware/spikes/components/awdl/awdl_node.c b/firmware/spikes/components/awdl/awdl_node.c index c997081..b655b4b 100644 --- a/firmware/spikes/components/awdl/awdl_node.c +++ b/firmware/spikes/components/awdl/awdl_node.c @@ -48,14 +48,18 @@ static void peer_apply_view(awdl_peer_t *p, const awdl_peer_view_t *pv) { } /* Re-derive the channel sequence we advertise. When we track a master that has - * advertised a channel sequence naming our channel, mirror its slots on our own - * channel. Otherwise keep the last mirror we derived; only before the very - * first mirror (just after boot) do we advertise the lone static sequence. */ + * advertised a channel sequence naming our channel, derive ours from it (mirror + * or verbatim, see seq_mode). Otherwise keep the last one we derived; only + * before the very first (just after boot) do we advertise the static sequence. */ static void awdl_node_update_chanseq(awdl_node_t *n) { if (n->have_master) { awdl_peer_t *m = awdl_peers_get(&n->peers, n->election.sync_addr); - if (m && m->has_chanseq && - awdl_self_set_chanseq_intersect(&n->self, m->chanseq, n->self.master_chan)) { + bool ok = false; + if (m && m->has_chanseq) + ok = n->seq_mode == AWDL_SEQ_VERBATIM + ? awdl_self_set_chanseq_verbatim(&n->self, m->chanseq, n->self.master_chan) + : awdl_self_set_chanseq_intersect(&n->self, m->chanseq, n->self.master_chan); + if (ok) { n->seq_derived = true; return; } @@ -112,6 +116,15 @@ void awdl_node_set_channel(awdl_node_t *n, uint8_t chan) { awdl_node_update_chanseq(n); } +void awdl_node_set_seq_mode(awdl_node_t *n, uint8_t mode) { + if (mode == n->seq_mode) return; + n->seq_mode = mode; + /* The kept sequence was derived the other way; derive afresh (as after a + * channel change), falling back to static only without a usable master. */ + n->seq_derived = false; + awdl_node_update_chanseq(n); +} + uint8_t awdl_node_preferred_channel(const awdl_node_t *n, const uint8_t *allowed, int n_allowed) { if (!n->have_master) return 0; @@ -139,7 +152,7 @@ int awdl_node_build_action(awdl_node_t *n, uint8_t *buf, size_t cap, bool awdl_node_tx_slot_ready(const awdl_node_t *n, uint64_t now_us) { uint16_t slot = awdl_sync_current_eaw(now_us, &n->sync) % AWDL_CHANSEQ_LEN; - return n->self.seq[slot][0] != 0; /* non-zero channel => we advertise presence */ + return n->self.seq[slot][0] == n->self.master_chan; /* a slot on our channel */ } void awdl_node_status(const awdl_node_t *n, awdl_node_status_t *out) { @@ -149,6 +162,7 @@ void awdl_node_status(const awdl_node_t *n, awdl_node_status_t *out) { out->last_sync_err_tu = n->last_sync_err_tu; out->peer_count = awdl_peers_count(&n->peers); out->seq_derived = n->seq_derived; + out->seq_mode = n->seq_mode; for (unsigned i = 0; i < AWDL_CHANSEQ_LEN; i++) out->self_chanseq[i] = n->self.seq[i][0]; const awdl_peer_t *m = n->have_master diff --git a/firmware/spikes/components/awdl/include/awdl_frame.h b/firmware/spikes/components/awdl/include/awdl_frame.h index 12aad68..caf28ca 100644 --- a/firmware/spikes/components/awdl/include/awdl_frame.h +++ b/firmware/spikes/components/awdl/include/awdl_frame.h @@ -156,6 +156,17 @@ bool awdl_self_set_chanseq_intersect(awdl_self_t *self, const uint8_t master_chanseq[AWDL_CHANSEQ_LEN], uint8_t keep_chan); +/* + * Copy a master's channel sequence into ours unchanged, slots on other channels + * included (airdrop-mt7921 FINDINGS §25/§26 "verbatim", the sequence iOS 26 is + * known to send unicast to). On one radio we are still only on keep_chan, so + * the caller transmits only where the master is on keep_chan. Same guard as the + * intersect: false, sequence unchanged, if the master names keep_chan nowhere. + */ +bool awdl_self_set_chanseq_verbatim(awdl_self_t *self, + const uint8_t master_chanseq[AWDL_CHANSEQ_LEN], + uint8_t keep_chan); + /* ------------------------------------------------------------------------ */ /* Parsed peer view (what the parser extracts from a received action frame) */ /* ------------------------------------------------------------------------ */ diff --git a/firmware/spikes/components/awdl/include/awdl_node.h b/firmware/spikes/components/awdl/include/awdl_node.h index 7b1a28c..e65b115 100644 --- a/firmware/spikes/components/awdl/include/awdl_node.h +++ b/firmware/spikes/components/awdl/include/awdl_node.h @@ -28,6 +28,12 @@ extern "C" { #endif +/* How our advertised sequence is derived from the master's (awdl_node_set_seq_mode). */ +enum awdl_seq_mode { + AWDL_SEQ_MIRROR = 0, /* our channel where the master is on it, 0 elsewhere */ + AWDL_SEQ_VERBATIM = 1, /* the master's sequence unchanged (all its channels) */ +}; + typedef struct { awdl_self_t self; /* transmit state (addr, name, channel sequence) */ awdl_sync_t sync; /* our AW clock */ @@ -48,6 +54,7 @@ typedef struct { * ages out or names our channel nowhere, instead of falling back to the flat * all-one-channel sequence (which iOS 26 refuses to send unicast to). */ bool seq_derived; + uint8_t seq_mode; /* enum awdl_seq_mode, MIRROR after init */ } awdl_node_t; /* now_us: a monotonic microsecond timestamp (esp_timer_get_time() on hardware). */ @@ -69,6 +76,10 @@ void awdl_node_tick(awdl_node_t *n, uint64_t now_us); */ void awdl_node_set_channel(awdl_node_t *n, uint8_t chan); +/* Switch how the advertised sequence is derived (enum awdl_seq_mode) and + * re-derive it at once; sync, election and peers are kept. */ +void awdl_node_set_seq_mode(awdl_node_t *n, uint8_t mode); + /* * The channel the node would prefer to be on, given the tracked master's * sequence, out of the `allowed` channels (e.g. the social channels the radio @@ -85,11 +96,12 @@ int awdl_node_build_action(awdl_node_t *n, uint8_t *buf, size_t cap, /* * True if, at now_us, the EAW slot we are in is one our advertised channel - * sequence marks as present (non-zero). Transmitting only in these slots keeps - * our behaviour consistent with what we advertise, and (since we are physically - * on our channel every slot) lines our frames up with the slots the master is - * also on that channel, i.e. when it can actually hear us. When we advertise a - * flat sequence (no master), every slot is present, so this is always true. + * sequence puts on our own channel. Transmitting only in these slots keeps our + * behaviour consistent with what we advertise, and (since we are physically on + * our channel every slot) lines our frames up with the slots the master is also + * on that channel, i.e. when it can actually hear us. Verbatim slots naming + * another channel are not ours. When we advertise a flat sequence (no master), + * every slot is ours, so this is always true. */ bool awdl_node_tx_slot_ready(const awdl_node_t *n, uint64_t now_us); @@ -100,6 +112,7 @@ typedef struct { int64_t last_sync_err_tu; int peer_count; bool seq_derived; /* see awdl_node_t */ + uint8_t seq_mode; /* enum awdl_seq_mode */ uint8_t self_chanseq[AWDL_CHANSEQ_LEN]; /* what we advertise (channel numbers) */ bool master_has_chanseq; uint8_t master_chanseq[AWDL_CHANSEQ_LEN]; /* the tracked master's, if known */ diff --git a/firmware/spikes/components/awdl/test/host/test_awdl_frame.c b/firmware/spikes/components/awdl/test/host/test_awdl_frame.c index 7cfecdb..338a895 100644 --- a/firmware/spikes/components/awdl/test/host/test_awdl_frame.c +++ b/firmware/spikes/components/awdl/test/host/test_awdl_frame.c @@ -815,6 +815,63 @@ static void test_tx_slot_gating(void) { } } +/* Verbatim: we advertise the master's whole sequence (its channel 6 slots too) + * but still transmit only in the slots on our own channel; switching modes + * re-derives at once and keeps sync. */ +static void test_node_seq_verbatim(void) { + uint8_t me[6] = {0x02, 0, 0, 0, 0, 0x01}; + uint8_t ip[6] = {0xca, 0x2a, 0xd6, 0x10, 0x8e, 0xae}; + awdl_node_t n; + awdl_node_init(&n, me, "espdrop-c5", 44, 0); + CHECK(n.seq_mode == AWDL_SEQ_MIRROR, "default mode is mirror"); + + awdl_sync_t iph; + awdl_sync_init(&iph, 7000); + awdl_peer_view_t pv; + iphone_announce(&iph, 100000, ip, &pv); + /* iPad with its share sheet open: 44 44 44 0 0 0 0 0 6 44 44 0 0 0 0 0 */ + static const uint8_t mseq[AWDL_CHANSEQ_LEN] = {44, 44, 44, 0, 0, 0, 0, 0, + 6, 44, 44, 0, 0, 0, 0, 0}; + memcpy(pv.chanseq, mseq, sizeof(mseq)); + awdl_node_on_rx_action(&n, &pv, -40, 100000); + CHECK(n.have_master && n.seq_derived, "mirrored from the master"); + CHECK(n.self.seq[8][0] == 0 && n.self.seq[0][0] == 44, "mirror drops the 6 slot"); + + awdl_node_set_seq_mode(&n, AWDL_SEQ_VERBATIM); + CHECK(n.seq_derived, "verbatim derived at once"); + uint8_t buf[1500]; + int len = awdl_node_build_action(&n, buf, sizeof(buf), AWDL_SUBTYPE_MIF, 150000); + awdl_peer_view_t ours; + CHECK(len > 0 && awdl_parse_action(buf, (size_t)len, &ours) == AWDL_OK, "parse our MIF"); + for (int i = 0; i < (int)AWDL_CHANSEQ_LEN; i++) + CHECK(ours.chanseq[i] == mseq[i], "verbatim chanseq[%d]=%u expected %u", + i, ours.chanseq[i], mseq[i]); + CHECK(n.self.seq[8][1] == 0x51 && n.self.seq[0][1] == 0x80 && n.self.seq[3][1] == 0, + "opclass per slot: 6 -> 0x51, 44 -> 0x80, empty -> 0"); + + /* TX only where the slot is on our channel (44), never in the 6 slot. */ + awdl_sync_init(&n.sync, 0); + uint64_t eaw_us = (uint64_t)AWDL_EAW_TU * AWDL_TU_US; + for (int s = 0; s < (int)AWDL_CHANSEQ_LEN; s++) { + bool ours_slot = mseq[s] == 44; + CHECK(awdl_node_tx_slot_ready(&n, (uint64_t)s * eaw_us + 1000) == ours_slot, + "verbatim: slot %d ready expected %d", s, ours_slot); + } + + /* A master naming our channel nowhere keeps the last verbatim sequence. */ + memset(pv.chanseq, 0, sizeof(pv.chanseq)); + pv.chanseq[0] = 6; pv.chanseq[8] = 6; + awdl_node_on_rx_action(&n, &pv, -40, 200000); + CHECK(n.self.seq[1][0] == 44 && n.self.seq[8][0] == 6, "kept the last verbatim"); + + /* Back to mirror: derived again from the master's current sequence; with + * no 44 in it the derivation fails, so static on 44 (as after boot). */ + awdl_node_set_seq_mode(&n, AWDL_SEQ_MIRROR); + CHECK(n.seq_mode == AWDL_SEQ_MIRROR && !n.seq_derived, "mirror re-derives"); + CHECK(n.self.seq[8][0] == 44 && n.self.seq[3][0] == 44, "static fallback on 44"); + CHECK(n.sync_locked && memcmp(n.synced_master, ip, 6) == 0, "sync kept"); +} + int main(void) { test_mif_roundtrip(); test_psf_roundtrip(); @@ -837,6 +894,7 @@ int main(void) { test_node_chanseq_keep_on_no_ch6(); test_chanseq_realistic_slot0(); test_tx_slot_gating(); + test_node_seq_verbatim(); if (g_fail == 0) printf("\nALL TESTS PASSED\n"); else printf("\n%d CHECK(S) FAILED\n", g_fail); diff --git a/firmware/spikes/main/awdl_app.c b/firmware/spikes/main/awdl_app.c index aa7b6af..1da79cb 100644 --- a/firmware/spikes/main/awdl_app.c +++ b/firmware/spikes/main/awdl_app.c @@ -71,6 +71,8 @@ static int64_t s_auto_next_us; /* earliest next auto switch (dwell) /* Social channels we may park on, newest Apple set; radio_channel_supported * filters out what this chip cannot do (the C3 keeps only 6). */ static const uint8_t k_social_chans[] = {6, 44, 149}; +/* How our sequence follows the master's (enum awdl_seq_mode); survives set_self. */ +static uint8_t s_seq_mode = AWDL_SEQ_MIRROR; /* Peer-change notification. Shadow set is touched only by the AWDL task. */ static awdl_peer_cb_t s_peer_cb; @@ -313,6 +315,7 @@ void awdl_app_set_self(const uint8_t self_mac[6], uint8_t chan) { if (!s_started) return; xSemaphoreTake(s_lock, portMAX_DELAY); awdl_node_init(&s_node, self_mac, s_name, chan, (uint64_t)esp_timer_get_time()); + awdl_node_set_seq_mode(&s_node, s_seq_mode); xSemaphoreGive(s_lock); } @@ -362,6 +365,17 @@ void awdl_app_set_auto_channel(bool on) { bool awdl_app_auto_channel(void) { return s_auto_chan; } +void awdl_app_set_seq_mode(uint8_t mode) { + s_seq_mode = mode; + if (!s_started) return; + xSemaphoreTake(s_lock, portMAX_DELAY); + awdl_node_set_seq_mode(&s_node, mode); + xSemaphoreGive(s_lock); + if (s_task) xTaskNotifyGive(s_task); /* advertise the new sequence promptly */ +} + +uint8_t awdl_app_seq_mode(void) { return s_seq_mode; } + void awdl_app_pause_tx(bool paused) { s_tx_paused = paused; if (!paused && s_task) xTaskNotifyGive(s_task); /* catch up on due frames */ diff --git a/firmware/spikes/main/awdl_app.h b/firmware/spikes/main/awdl_app.h index 88b521c..c95b298 100644 --- a/firmware/spikes/main/awdl_app.h +++ b/firmware/spikes/main/awdl_app.h @@ -63,6 +63,14 @@ int awdl_app_set_channel(uint8_t chan, bool keep_auto); void awdl_app_set_auto_channel(bool on); bool awdl_app_auto_channel(void); +/* + * How the advertised channel sequence follows the master's: AWDL_SEQ_MIRROR + * (our channel in the master's slots on it, 0 elsewhere; the default) or + * AWDL_SEQ_VERBATIM (the master's sequence unchanged). Keeps sync and peers. + */ +void awdl_app_set_seq_mode(uint8_t mode); +uint8_t awdl_app_seq_mode(void); + /* * Queue one host Ethernet frame (dst||src||ethertype||payload) for transmission * as an AWDL data frame. Only IPv6 (ethertype 0x86dd) is forwarded; other types diff --git a/firmware/spikes/main/bridge.c b/firmware/spikes/main/bridge.c index 3693af9..fa29a0d 100644 --- a/firmware/spikes/main/bridge.c +++ b/firmware/spikes/main/bridge.c @@ -269,13 +269,14 @@ static void handle_cmd(const uint8_t *p, size_t n) { } else { int o = snprintf(nbuf, sizeof(nbuf), "master=%02x:%02x:%02x:%02x:%02x:%02x%s peers=%d height=%u " - "sync_err=%d chan=%u auto=%s derived=%s seq=", + "sync_err=%d chan=%u auto=%s mode=%s derived=%s seq=", ns.master_addr[0], ns.master_addr[1], ns.master_addr[2], ns.master_addr[3], ns.master_addr[4], ns.master_addr[5], ns.have_master ? "" : "(self)", ns.peer_count, (unsigned)ns.height, (int)ns.last_sync_err_tu, (unsigned)radio_get_channel(), awdl_app_auto_channel() ? "on" : "off", + ns.seq_mode == AWDL_SEQ_VERBATIM ? "verbatim" : "mirror", ns.seq_derived ? "yes" : "no"); for (unsigned i = 0; i < AWDL_CHANSEQ_LEN && o > 0 && o < (int)sizeof(nbuf); i++) o += snprintf(nbuf + o, sizeof(nbuf) - (size_t)o, "%s%u", @@ -323,6 +324,22 @@ static void handle_cmd(const uint8_t *p, size_t n) { snprintf(out, sizeof(out), "chan %d (auto off)", ch); } cmd_reply(out); + } else if (strcmp(cmd, "seq") == 0 || strncmp(cmd, "seq ", 4) == 0) { + /* `seq` shows how our advertised sequence follows the master's; `seq + * mirror` (default) puts our channel only where the master is on it, + * `seq verbatim` copies the master's sequence unchanged. An experiment: + * which one the iPhone sends unicast to (see `node`, ping6). */ + const char *arg = cmd[3] == '\0' ? "" : cmd + 4; + if (strcmp(arg, "mirror") == 0) + awdl_app_set_seq_mode(AWDL_SEQ_MIRROR); + else if (strcmp(arg, "verbatim") == 0) + awdl_app_set_seq_mode(AWDL_SEQ_VERBATIM); + if (arg[0] && strcmp(arg, "mirror") && strcmp(arg, "verbatim")) + snprintf(out, sizeof(out), "seq: use `seq`, `seq mirror` or `seq verbatim`"); + else + snprintf(out, sizeof(out), "seq %s", + awdl_app_seq_mode() == AWDL_SEQ_VERBATIM ? "verbatim" : "mirror"); + cmd_reply(out); } else if (strcmp(cmd, "checks") == 0) { /* Bring-up checks on a new chip: is the frame length right (FCS where * we expect it, IPv6 packets neither cut short nor padded)? */ diff --git a/tools/awdl_bridge.py b/tools/awdl_bridge.py index ef8a0a3..3c92189 100644 --- a/tools/awdl_bridge.py +++ b/tools/awdl_bridge.py @@ -26,7 +26,7 @@ # are only logged. Suppressed with --no-config. # # Interactive stdin commands (forwarded to the device): -# mac | stats | radio | tx | node | chan [n|auto] | checks | usbtest [kib] | ping | testframe | resync +# mac | stats | radio | tx | node | chan [n|auto] | seq [mirror|verbatim] | checks | usbtest [kib] | ping | testframe | resync # # Needs root: creating a TAP and running `ip link` both require CAP_NET_ADMIN. # The interface is not persistent - it disappears when this process exits. @@ -214,7 +214,7 @@ def main() -> int: print(f"{args.dev} up (mtu {args.mtu}, mac {mac or 'kernel-assigned'}); " f"bridging {args.port}") - print("commands: mac | stats | radio | tx | node | chan [n|auto] | checks | usbtest [kib] | ping | testframe | resync" + print("commands: mac | stats | radio | tx | node | chan [n|auto] | seq [mirror|verbatim] | checks | usbtest [kib] | ping | testframe | resync" " (Ctrl-C to quit)") # The C3 keeps running across host sessions (its USB port does not reset From 0085a9f44c630f9fc13d5a3abfd6e91a1a307d70 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 23 Oct 2026 08:23:22 +0000 Subject: [PATCH 59/99] docs(m3): sending to an iPad works with the PC's Bluetooth wake Record the first working PC -> iPad send on the C5 bridge (channel 44): ping answers with the share sheet closed and open, in both sequence modes, so mirror stays the default. The previous day's 'found but no unicast back' state cleared after reflashing and restarting; its cause is unknown, so the troubleshooting list now starts with a full restart. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01XzgaN4Bv1m8Ka9iqo7QVUN --- docs/SPIKES.md | 14 +++++++++++++- docs/UEBERBLICK.md | 5 ++++- 2 files changed, 17 insertions(+), 2 deletions(-) diff --git a/docs/SPIKES.md b/docs/SPIKES.md index cf764a5..219a82f 100644 --- a/docs/SPIKES.md +++ b/docs/SPIKES.md @@ -220,6 +220,15 @@ Mehrere Apple-Geräte in Reichweite: mit `-r "Annas iPhone"` das Ziel am Namen wählen. „Nur Kontakte“ statt „Jeder“: Identifikator über `ESPDROP_WAKE_ARGS="--appleid du@icloud.com"` an das Wecksignal geben. +Ergebnis (2026-10-23, C5-Bridge auf Kanal 44, BLE-Wecksignal vom PC): Senden an +ein iPad funktioniert, die Datei wird übertragen. Ping vom PC zum iPad bekommt +Antworten mit Teilen-Menü zu und offen, in beiden Sequenz-Modi (`seq mirror` +und `seq verbatim`); der Standard bleibt `mirror`. Am Vortag war dasselbe +iPad zwar per mDNS gefunden worden, hatte aber keinen einzigen Unicast an uns +geschickt (SYN-SENT, 100 % Ping-Verlust, obwohl `tx` jeden Frame als +quittiert zählte). Am nächsten Tag, nach neuem Flashen und Neustart von +Bridge und iPad-Sitzung, war das weg; die Ursache ist nicht geklärt. + Fehlersuche, wenn kein Empfänger gefunden wird (Gerät bereit, Wecksignal läuft): - `tools/mdns_probe.py`: Kommen mDNS-Antworten des Geräts überhaupt bei einem Programm an? (Standardbibliothek, ohne venv.) @@ -231,7 +240,10 @@ Fehlersuche, wenn kein Empfänger gefunden wird (Gerät bereit, Wecksignal läuf - Gefunden, aber `/Discover` hängt in SYN-SENT, und `ping -6` an das Gerät bleibt ohne Antwort, obwohl `tx` im Bridge-Fenster jeden Unicast als quittiert zählt (`uc_ack`): Das Gerät empfängt uns, sendet uns aber keinen - Unicast. Im Referenzprojekt lag das an der angekündigten Kanal-Sequenz + Unicast. Zuerst alles neu starten: C5 abziehen und wieder anstecken, Bridge + neu starten, am iPhone/iPad AirDrop einmal aus- und wieder einschalten (so + verschwand es beim ersten Auftreten, siehe Ergebnis oben). Hält es an, die + Sequenz prüfen: Im Referenzprojekt lag das an der angekündigten Kanal-Sequenz (airdrop-mt7921 FINDINGS §25/§26). Im Bridge-Fenster umschaltbar, ohne neu zu flashen: `seq mirror` (Standard: unser Kanal nur in den Slots des Masters auf diesem Kanal) oder `seq verbatim` (die Sequenz des Masters unverändert). diff --git a/docs/UEBERBLICK.md b/docs/UEBERBLICK.md index 8a9b0e6..c99ce39 100644 --- a/docs/UEBERBLICK.md +++ b/docs/UEBERBLICK.md @@ -197,7 +197,10 @@ neuer Scheduler. Ziel: iPhone als Peer sehen, Sync + Election, Log über USB. **M2 – Linux-Pfad (Option A):** Host-Bridge USB ↔ TAP `awdl0`. Dann `ping6` zum iPhone, danach mit dem gepatchten OpenDrop eine Datei **empfangen**. -**M3 – BLE-Wake auf dem ESP:** Datei an das iPhone **senden**. +**M3 – BLE-Wake auf dem ESP:** Datei an das iPhone **senden**. Stand 2026-10-23: +Senden funktioniert mit dem BLE-Wecksignal vom Bluetooth des PCs +(`tools/opendrop_send.sh`, siehe `docs/SPIKES.md`); das Wecksignal vom ESP selbst +ist noch offen. **M4 – Windows (Option C):** lwIP + mDNS auf dem ESP, Socket-Proxy-Protokoll, Host-App (z. B. Python oder Rust) mit OpenDrop-Logik inkl. iOS-26-Patches. From eae1816345ef7e17e43de8df5b7e4fa2fa730d0c Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 23 Oct 2026 08:59:53 +0000 Subject: [PATCH 60/99] feat(m3): AirDrop BLE wake from the ESP's own Bluetooth Sending no longer needs the PC's Bluetooth: the bridge firmware broadcasts the Continuity AirDrop advert itself, byte for byte what airdrop_wake.py sends from the PC. - main/ble_wake.c: NimBLE (broadcaster role only), non-connectable legacy advert at 200-250 ms, a fresh non-resolvable private address per wake, always timed (1..600 s, stops by itself), optional contact hashes. Initialised after Wi-Fi (coexistence) and before the bridge takes USB. - bridge `wake` / `wake [16 hex]` / `wake off`. - sdkconfig.bridge.defaults: Bluetooth on, large single-app partition (the C5 image is ~1.2 MB). A fresh build dir is needed for the new defaults. - awdl_bridge.py: control socket /run/espdrop/.sock (mode 0600, owned by the sudo user) passing allowed device commands and their reply; leaves cleanly on SIGTERM/SIGHUP too. tools/espdrop_cmd.py is its client and hashes --appleid/--phone/--email/--email2 into `wake`. - opendrop_send.sh --wake=auto|esp|pc|none: auto uses the ESP when it can, re-arms it for Accept and upload, switches it off at the end. C5 and C3 bridge and C5 console build; socket and send-script paths tested against a fake device and fake OpenDrop. Not yet tried on hardware. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01XzgaN4Bv1m8Ka9iqo7QVUN --- docs/SPIKES.md | 44 +++++-- docs/UEBERBLICK.md | 5 +- firmware/README.md | 5 + firmware/spikes/main/CMakeLists.txt | 4 +- firmware/spikes/main/app_main.c | 8 ++ firmware/spikes/main/ble_wake.c | 151 ++++++++++++++++++++++ firmware/spikes/main/ble_wake.h | 56 ++++++++ firmware/spikes/main/bridge.c | 43 ++++++ firmware/spikes/sdkconfig.bridge.defaults | 16 +++ tools/awdl_bridge.py | 119 ++++++++++++++++- tools/espdrop_cmd.py | 89 +++++++++++++ tools/opendrop_send.sh | 72 ++++++++--- 12 files changed, 577 insertions(+), 35 deletions(-) create mode 100644 firmware/spikes/main/ble_wake.c create mode 100644 firmware/spikes/main/ble_wake.h create mode 100755 tools/espdrop_cmd.py diff --git a/docs/SPIKES.md b/docs/SPIKES.md index 219a82f..f3dc72c 100644 --- a/docs/SPIKES.md +++ b/docs/SPIKES.md @@ -196,20 +196,45 @@ Radarerkennung). Empfangen ist damit fertig. Für die Gegenrichtung muss das iPhone oder iPad als Empfänger über AWDL antworten. Das tut es nur, wenn sein AWDL wach ist (offenes Teilen-Menü) **und** ein Bluetooth-LE-Signal in der Nähe meldet, dass jemand -senden will. Dieses BLE-Signal sendet der PC selbst; der C5 macht über `awdl0` -unverändert weiter. (Nachgewiesen im Referenzprojekt airdrop-mt7921, FINDINGS -§27 und §37: gleiche Verbindung, gleiches offenes Menü, gefunden nur mit BLE.) +senden will. (Nachgewiesen im Referenzprojekt airdrop-mt7921, FINDINGS §27 +und §37: gleiche Verbindung, gleiches offenes Menü, gefunden nur mit BLE.) + +Das Wecksignal kommt vom Bluetooth des ESP selbst (Bridge-Firmware, Befehl +`wake`) oder vom Bluetooth des PCs (`tools/airdrop_wake.py`, braucht +`python3-dbus` und `python3-gi`, Fedora: `python3-dbus python3-gobject`). +`opendrop_send.sh` nimmt den ESP, wenn dessen Firmware Bluetooth hat, sonst den +PC; `--wake=esp`, `--wake=pc` oder `--wake=none` wählt fest. Der ESP sendet +genau dieselben Bytes wie das PC-Werkzeug, mit einer neuen zufälligen +BLE-Adresse je Wecken und immer mit Ablaufzeit (höchstens 600 s), damit ein +abgestürztes Skript keine Geräte in der Nähe dauerhaft weckt. + +Bluetooth in der Bridge-Firmware: seit `sdkconfig.bridge.defaults` mit NimBLE +(nur Broadcaster) und der großen App-Partition (1,5 MB, das Image hat auf dem +C5 etwa 1,2 MB). ESP-IDF übernimmt neue Defaults nicht in ein schon +vorhandenes `sdkconfig`, also den Build-Ordner einmal löschen: +```sh +rm -rf build_c5_bridge +idf.py -B build_c5_bridge -DSDKCONFIG=build_c5_bridge/sdkconfig -DIDF_TARGET=esp32c5 \ + -DSDKCONFIG_DEFAULTS="sdkconfig.defaults;sdkconfig.bridge.defaults" -p /dev/ttyACM0 build flash +``` +Im Bridge-Fenster zeigt `wake` dann `wake off` (ohne Bluetooth: `wake +unavailable`). Bluetooth teilt sich das Funkteil mit Wi-Fi und braucht Heap: +`stats` zeigt `rxq=` (Tiefe der Empfangs-Warteschlange, bisher 48) und `heap=`. -Voraussetzungen auf dem PC: Bluetooth an (`bluetoothctl power on`) und die -Pakete `python3-dbus` und `python3-gi` (Fedora: `python3-dbus python3-gobject`). +Die Bridge stellt für Werkzeuge den Steuer-Socket `/run/espdrop/awdl0.sock` +bereit (gehört dem Benutzer, der sie mit `sudo` gestartet hat). +`tools/espdrop_cmd.py wake 60` / `tools/espdrop_cmd.py wake off` / +`tools/espdrop_cmd.py node` schicken einen Befehl und zeigen die Antwort. -1. Bridge wie beim Empfang starten; der C5 wählt den 5-GHz-Kanal automatisch. +1. Bridge wie beim Empfang starten (`sudo tools/awdl_bridge.py /dev/ttyACM0` + aus der eigenen Sitzung); der C5 wählt den 5-GHz-Kanal automatisch. 2. Senden (als normaler User), eine oder mehrere Dateien: ```sh tools/opendrop_send.sh foto.jpg ``` - Das Skript sendet das BLE-Wecksignal (`tools/airdrop_wake.py`), sucht den - Empfänger auf `awdl0` und überträgt dann. + Das Skript startet das BLE-Wecksignal, sucht den Empfänger auf `awdl0`, + hält das Signal für Annehmen und Übertragung aufrecht und schaltet es am + Ende ab. 3. Am iPhone/iPad: AirDrop auf „Für alle für 10 Minuten“ (fällt nach 10 Minuten auf „Nur Kontakte“ zurück, also kurz vor dem Senden neu wählen), entsperrt, **Teilen-Menü öffnen und offen lassen** (es hält AWDL wach; bei geschlossenem @@ -218,7 +243,8 @@ Pakete `python3-dbus` und `python3-gi` (Fedora: `python3-dbus python3-gobject`). Mehrere Apple-Geräte in Reichweite: mit `-r "Annas iPhone"` das Ziel am Namen wählen. „Nur Kontakte“ statt „Jeder“: Identifikator über -`ESPDROP_WAKE_ARGS="--appleid du@icloud.com"` an das Wecksignal geben. +`ESPDROP_WAKE_ARGS="--appleid du@icloud.com"` an das Wecksignal geben (gilt +für beide Quellen; `--phone`, `--email`, `--email2` ebenso). Ergebnis (2026-10-23, C5-Bridge auf Kanal 44, BLE-Wecksignal vom PC): Senden an ein iPad funktioniert, die Datei wird übertragen. Ping vom PC zum iPad bekommt diff --git a/docs/UEBERBLICK.md b/docs/UEBERBLICK.md index c99ce39..8437e94 100644 --- a/docs/UEBERBLICK.md +++ b/docs/UEBERBLICK.md @@ -199,8 +199,9 @@ danach mit dem gepatchten OpenDrop eine Datei **empfangen**. **M3 – BLE-Wake auf dem ESP:** Datei an das iPhone **senden**. Stand 2026-10-23: Senden funktioniert mit dem BLE-Wecksignal vom Bluetooth des PCs -(`tools/opendrop_send.sh`, siehe `docs/SPIKES.md`); das Wecksignal vom ESP selbst -ist noch offen. +(`tools/opendrop_send.sh`, siehe `docs/SPIKES.md`). Das Wecksignal vom ESP selbst +(Bridge `wake`, NimBLE) ist gebaut und auf dem Host getestet, aber noch nicht +auf der Hardware. **M4 – Windows (Option C):** lwIP + mDNS auf dem ESP, Socket-Proxy-Protokoll, Host-App (z. B. Python oder Rust) mit OpenDrop-Logik inkl. iOS-26-Patches. diff --git a/firmware/README.md b/firmware/README.md index 669d23c..7da9b88 100644 --- a/firmware/README.md +++ b/firmware/README.md @@ -33,6 +33,11 @@ idf.py -B build_c5_bridge -DSDKCONFIG=build_c5_bridge/sdkconfig -DIDF_TARGET=esp `sdkconfig.defaults.esp32c5` is picked up automatically for the C5. +The bridge firmware includes Bluetooth LE (NimBLE, for the AirDrop wake) and +uses the large single-app partition table. ESP-IDF does not apply changed +defaults to an existing `sdkconfig`, so after pulling such a change delete the +build directory (e.g. `rm -rf build_c5_bridge`) before building again. + ## Host unit test ```sh diff --git a/firmware/spikes/main/CMakeLists.txt b/firmware/spikes/main/CMakeLists.txt index 2de9d51..81bf543 100644 --- a/firmware/spikes/main/CMakeLists.txt +++ b/firmware/spikes/main/CMakeLists.txt @@ -25,8 +25,8 @@ endif() message(STATUS "EspDrop: target '${IDF_TARGET}', USB-Serial/JTAG via '${ESPDROP_USJ_COMPONENT}' (IDF ${IDF_VERSION_MAJOR}.${IDF_VERSION_MINOR})") idf_component_register( - SRCS "app_main.c" "radio.c" "console.c" "awdl_app.c" "bridge.c" + SRCS "app_main.c" "radio.c" "console.c" "awdl_app.c" "bridge.c" "ble_wake.c" INCLUDE_DIRS "." REQUIRES awdl esp_wifi esp_event esp_netif nvs_flash console esp_timer esp_hw_support - ${ESPDROP_USJ_COMPONENT} ${ESPDROP_USJ_LL_COMPONENT} + bt ${ESPDROP_USJ_COMPONENT} ${ESPDROP_USJ_LL_COMPONENT} ) diff --git a/firmware/spikes/main/app_main.c b/firmware/spikes/main/app_main.c index a05fc96..76739a4 100644 --- a/firmware/spikes/main/app_main.c +++ b/firmware/spikes/main/app_main.c @@ -37,6 +37,7 @@ static uint8_t boot_channel(void) { #include "bridge.h" #include "awdl_app.h" +#include "ble_wake.h" /* AWDL peer appeared/left -> tell the host so it can manage the awdl0 neighbour * table. Runs on the AWDL timing task, so it only enqueues (bridge_notify_peer @@ -65,6 +66,13 @@ void app_main(void) { return; } + /* Bluetooth for the AirDrop wake (bridge `wake`): after Wi-Fi so the two + * share the radio through coexistence, before bridge_start because the + * controller logs at init. Without it the bridge works on, minus `wake`. */ + err = ble_wake_init(); + if (err != ESP_OK) + ESP_LOGW("espdrop", "no Bluetooth wake: 0x%x", err); + if (bridge_start() != 0) { ESP_LOGE("espdrop", "bridge_start failed"); return; diff --git a/firmware/spikes/main/ble_wake.c b/firmware/spikes/main/ble_wake.c new file mode 100644 index 0000000..f9b253e --- /dev/null +++ b/firmware/spikes/main/ble_wake.c @@ -0,0 +1,151 @@ +/* + * EspDrop - AirDrop wake over the chip's own Bluetooth LE. See ble_wake.h. + * + * SPDX-License-Identifier: GPL-3.0-or-later + * Copyright (C) 2026 EspDrop contributors + */ +#include "ble_wake.h" + +#include + +#include "esp_err.h" +#include "esp_timer.h" +#include "sdkconfig.h" + +#if CONFIG_BT_NIMBLE_ENABLED + +#include "host/ble_hs.h" +#include "nimble/nimble_port.h" +#include "nimble/nimble_port_freertos.h" + +/* 200-250 ms: often enough that a phone scanning with its share sheet open sees + * the advert within a second, rare enough that BLE costs AWDL little: it shares + * the radio with Wi-Fi and, on the C5 parked on 5 GHz, needs a band switch for + * every advertising event. */ +#define WAKE_ITVL_MIN BLE_GAP_ADV_ITVL_MS(200) +#define WAKE_ITVL_MAX BLE_GAP_ADV_ITVL_MS(250) +#define WAKE_ADV_LEN 24 + +static volatile bool s_ready; /* host synced with the controller */ +static volatile bool s_active; /* our advert is running */ +static volatile int64_t s_end_us; /* when it stops by itself */ + +static void on_sync(void) { s_ready = true; } + +static void on_reset(int reason) { + (void)reason; + s_ready = false; + s_active = false; +} + +static void host_task(void *arg) { + (void)arg; + nimble_port_run(); /* returns only after nimble_port_stop() */ + nimble_port_freertos_deinit(); +} + +/* Host task context. The advert ends by itself at its duration. */ +static int on_gap(struct ble_gap_event *ev, void *arg) { + (void)arg; + if (ev->type == BLE_GAP_EVENT_ADV_COMPLETE) s_active = false; + return 0; +} + +int ble_wake_init(void) { + esp_err_t err = nimble_port_init(); /* controller + host */ + if (err != ESP_OK) return err; + ble_hs_cfg.sync_cb = on_sync; + ble_hs_cfg.reset_cb = on_reset; + nimble_port_freertos_init(host_task); + return ESP_OK; +} + +bool ble_wake_ready(void) { return s_ready; } + +/* One AD structure, manufacturer specific data: Apple (0x004C), Continuity + * type 0x05 "AirDrop" - byte for byte what tools/airdrop_wake.py advertises: + * 05 12 <8 zero bytes> 01 <4 x 2-byte hash> 00 */ +static int build_adv(uint8_t out[WAKE_ADV_LEN], const uint8_t hashes[8]) { + static const uint8_t zero[8]; + if (!hashes) hashes = zero; + int o = 0; + out[o++] = WAKE_ADV_LEN - 1; /* AD length: everything after this byte */ + out[o++] = 0xff; /* manufacturer specific data */ + out[o++] = 0x4c; /* company id 0x004C, little endian */ + out[o++] = 0x00; + out[o++] = 0x05; /* Continuity type: AirDrop */ + out[o++] = 0x12; /* 18 bytes follow */ + memset(out + o, 0, 8); /* zero prefix */ + o += 8; + out[o++] = 0x01; /* AirDrop version */ + memcpy(out + o, hashes, 8); /* Apple ID, phone, email, email2 */ + o += 8; + out[o++] = 0x00; + return o; +} + +int ble_wake_start(unsigned secs, const uint8_t hashes[8]) { + if (!s_ready) return BLE_HS_ENOTSYNCED; + if (secs < 1 || secs > BLE_WAKE_MAX_SECS) return BLE_HS_EINVAL; + + int rc; + if (ble_gap_adv_active()) { + /* Called from the bridge task, below the NimBLE host's priority, so a + * stop followed straight by a start is safe (see ble_gap_adv_stop). */ + rc = ble_gap_adv_stop(); + if (rc != 0 && rc != BLE_HS_EALREADY) return rc; + } else { + /* A fresh non-resolvable private address per wake, as Apple devices + * rotate theirs; it can only change while not advertising. */ + ble_addr_t a; + rc = ble_hs_id_gen_rnd(1, &a); + if (rc == 0) rc = ble_hs_id_set_rnd(a.val); + if (rc != 0) return rc; + } + + uint8_t adv[WAKE_ADV_LEN]; + rc = ble_gap_adv_set_data(adv, build_adv(adv, hashes)); + if (rc != 0) return rc; + + struct ble_gap_adv_params p = {0}; + p.conn_mode = BLE_GAP_CONN_MODE_NON; /* broadcast only: ADV_NONCONN_IND */ + p.disc_mode = BLE_GAP_DISC_MODE_NON; + p.itvl_min = WAKE_ITVL_MIN; + p.itvl_max = WAKE_ITVL_MAX; + rc = ble_gap_adv_start(BLE_OWN_ADDR_RANDOM, NULL, (int32_t)secs * 1000, &p, + on_gap, NULL); + if (rc != 0) return rc; + s_end_us = esp_timer_get_time() + (int64_t)secs * 1000000; + s_active = true; + return 0; +} + +int ble_wake_stop(void) { + if (!s_ready || !ble_gap_adv_active()) { + s_active = false; + return 0; + } + int rc = ble_gap_adv_stop(); + s_active = false; + return rc == BLE_HS_EALREADY ? 0 : rc; +} + +unsigned ble_wake_remaining(void) { + if (!s_active) return 0; + int64_t left = s_end_us - esp_timer_get_time(); + return left > 0 ? (unsigned)((left + 999999) / 1000000) : 0; +} + +#else /* !CONFIG_BT_NIMBLE_ENABLED: firmware built without Bluetooth */ + +int ble_wake_init(void) { return ESP_ERR_NOT_SUPPORTED; } +bool ble_wake_ready(void) { return false; } +int ble_wake_start(unsigned secs, const uint8_t hashes[8]) { + (void)secs; + (void)hashes; + return ESP_ERR_NOT_SUPPORTED; +} +int ble_wake_stop(void) { return 0; } +unsigned ble_wake_remaining(void) { return 0; } + +#endif diff --git a/firmware/spikes/main/ble_wake.h b/firmware/spikes/main/ble_wake.h new file mode 100644 index 0000000..522498b --- /dev/null +++ b/firmware/spikes/main/ble_wake.h @@ -0,0 +1,56 @@ +/* + * EspDrop - AirDrop wake over the chip's own Bluetooth LE. + * + * To be found as a SENDER's target, an iPhone/iPad must hear a Continuity + * "AirDrop" advertisement (Apple manufacturer data, type 0x05) while its share + * sheet is open; only then does it answer for _airdrop._tcp over AWDL + * (airdrop-mt7921 FINDINGS §27). This sends that advert from the ESP itself, + * the same bytes as tools/airdrop_wake.py sends from the PC's Bluetooth. + * + * The advert is always timed: it stops by itself after the requested time, so + * a host script that dies cannot leave nearby phones being woken forever. + * + * Built only with CONFIG_BT_NIMBLE_ENABLED (bridge firmware); otherwise every + * call reports Bluetooth as unavailable. + * + * SPDX-License-Identifier: GPL-3.0-or-later + * Copyright (C) 2026 EspDrop contributors + */ +#pragma once + +#include +#include + +#ifdef __cplusplus +extern "C" { +#endif + +#define BLE_WAKE_MAX_SECS 600 /* longest single wake, like the PC tool's default */ + +/* Bring up the BLE controller and NimBLE host. Call once at boot, after the + * Wi-Fi radio is up (coexistence) and before the bridge takes the USB port + * (the controller logs at init). Returns an esp_err_t. */ +int ble_wake_init(void); + +/* True once the BLE host has synced with the controller and can advertise. */ +bool ble_wake_ready(void); + +/* + * Advertise the AirDrop wake for secs seconds (1..BLE_WAKE_MAX_SECS). hashes: + * the four 2-byte SHA-256 prefixes (Apple ID, phone, email, email2) in that + * order, or NULL for all zero (target on "Everyone"). Calling it while a wake + * runs restarts it with the new time and hashes, keeping the BLE address; a + * wake started from off gets a fresh random address. Returns 0 or a NimBLE / + * esp_err_t code. + */ +int ble_wake_start(unsigned secs, const uint8_t hashes[8]); + +/* Stop the wake now (no-op if it is off). Returns 0 or an error code. */ +int ble_wake_stop(void); + +/* Seconds the running wake has left, 0 if it is off. */ +unsigned ble_wake_remaining(void); + +#ifdef __cplusplus +} +#endif diff --git a/firmware/spikes/main/bridge.c b/firmware/spikes/main/bridge.c index fa29a0d..c7beab1 100644 --- a/firmware/spikes/main/bridge.c +++ b/firmware/spikes/main/bridge.c @@ -24,6 +24,7 @@ #include "awdl_cobs.h" #include "awdl_app.h" /* awdl_app_resync_peers, for the `resync` command */ +#include "ble_wake.h" /* `wake`: the AirDrop wake over our own Bluetooth */ #include "radio.h" /* radio_get_stats, for the `radio` diagnostic command */ #define BRIDGE_MAX_PAYLOAD 1600 @@ -340,6 +341,48 @@ static void handle_cmd(const uint8_t *p, size_t n) { snprintf(out, sizeof(out), "seq %s", awdl_app_seq_mode() == AWDL_SEQ_VERBATIM ? "verbatim" : "mirror"); cmd_reply(out); + } else if (strcmp(cmd, "wake") == 0 || strncmp(cmd, "wake ", 5) == 0) { + /* AirDrop wake over our own Bluetooth (ble_wake.h): `wake` shows it, + * `wake [16 hex digits]` advertises for secs seconds (restarts a + * running one) with the four 2-byte contact hashes, `wake off` stops. */ + const char *arg = cmd[4] == '\0' ? "" : cmd + 5; + if (!ble_wake_ready()) { + snprintf(out, sizeof(out), "wake unavailable (no Bluetooth in this " + "firmware, or it is still starting)"); + } else if (arg[0] == '\0') { + unsigned left = ble_wake_remaining(); + if (left) snprintf(out, sizeof(out), "wake on %u s left", left); + else snprintf(out, sizeof(out), "wake off"); + } else if (strcmp(arg, "off") == 0) { + int rc = ble_wake_stop(); + if (rc) snprintf(out, sizeof(out), "wake error %d", rc); + else snprintf(out, sizeof(out), "wake off"); + } else { + char *end; + long secs = strtol(arg, &end, 10); + while (*end == ' ') end++; + uint8_t h[8]; + bool have_h = false, bad = end == arg || secs < 1 || secs > BLE_WAKE_MAX_SECS; + if (!bad && *end) { + bad = strlen(end) != 16; + for (int i = 0; !bad && i < 8; i++) { + char hx[3] = {end[2 * i], end[2 * i + 1], 0}; + char *e2; + h[i] = (uint8_t)strtoul(hx, &e2, 16); + bad = *e2 != '\0' || !hx[0] || !hx[1]; + } + have_h = !bad; + } + if (bad) { + snprintf(out, sizeof(out), "wake: use `wake`, `wake off` or " + "`wake <1..%d> [16 hex digits]`", BLE_WAKE_MAX_SECS); + } else { + int rc = ble_wake_start((unsigned)secs, have_h ? h : NULL); + if (rc) snprintf(out, sizeof(out), "wake error %d", rc); + else snprintf(out, sizeof(out), "wake on %u s left", ble_wake_remaining()); + } + } + cmd_reply(out); } else if (strcmp(cmd, "checks") == 0) { /* Bring-up checks on a new chip: is the frame length right (FCS where * we expect it, IPv6 packets neither cut short nor padded)? */ diff --git a/firmware/spikes/sdkconfig.bridge.defaults b/firmware/spikes/sdkconfig.bridge.defaults index 5a6b9c0..e99eed5 100644 --- a/firmware/spikes/sdkconfig.bridge.defaults +++ b/firmware/spikes/sdkconfig.bridge.defaults @@ -15,3 +15,19 @@ CONFIG_IDF_TARGET="esp32c3" CONFIG_ESPDROP_BRIDGE_MODE=y CONFIG_ESP_CONSOLE_NONE=y + +# AirDrop wake over the chip's own Bluetooth LE (main/ble_wake.c, bridge +# `wake`): NimBLE with only the broadcaster role it needs. Legacy advertising +# (ble_gap_adv_start) is only built without the BLE 5 extended-advertising API. +# Stack and controller push the image past the default 1 MB app partition, +# hence the large single-app table (1.5 MB, fits the 2 MB flash setting). +CONFIG_BT_ENABLED=y +CONFIG_BT_NIMBLE_ENABLED=y +CONFIG_BT_NIMBLE_50_FEATURE_SUPPORT=n +CONFIG_BT_NIMBLE_ROLE_BROADCASTER=y +CONFIG_BT_NIMBLE_ROLE_CENTRAL=n +CONFIG_BT_NIMBLE_ROLE_PERIPHERAL=n +CONFIG_BT_NIMBLE_ROLE_OBSERVER=n +CONFIG_BT_NIMBLE_SECURITY_ENABLE=n +CONFIG_BT_NIMBLE_LOG_LEVEL_WARNING=y +CONFIG_PARTITION_TABLE_SINGLE_APP_LARGE=y diff --git a/tools/awdl_bridge.py b/tools/awdl_bridge.py index 3c92189..5e2cd3e 100644 --- a/tools/awdl_bridge.py +++ b/tools/awdl_bridge.py @@ -26,7 +26,12 @@ # are only logged. Suppressed with --no-config. # # Interactive stdin commands (forwarded to the device): -# mac | stats | radio | tx | node | chan [n|auto] | seq [mirror|verbatim] | checks | usbtest [kib] | ping | testframe | resync +# mac | stats | radio | tx | node | chan [n|auto] | seq [mirror|verbatim] | wake [secs|off] | checks | usbtest [kib] | ping | testframe | resync +# +# Control socket: other tools (tools/espdrop_cmd.py, opendrop_send.sh for the +# Bluetooth wake) send device commands through /run/espdrop/.sock, one +# command per connection, and get the reply line back. It belongs to the user +# who ran sudo (mode 0600), so that user's scripts can use it and nobody else. # # Needs root: creating a TAP and running `ip link` both require CAP_NET_ADMIN. # The interface is not persistent - it disappears when this process exits. @@ -39,6 +44,7 @@ import os import re import select +import signal import socket import struct import subprocess @@ -57,6 +63,35 @@ MAC_RE = re.compile(r"^([0-9a-fA-F]{2}:){5}[0-9a-fA-F]{2}$") MAX_LEARNED = 64 # neighbour entries kept; the oldest is dropped beyond that +CTL_DIR = "/run/espdrop" +# Device commands the control socket passes on (no usbtest: it floods the link). +CTL_COMMANDS = {"wake", "mac", "stats", "radio", "tx", "node", "chan", "seq", "checks", "ping"} +CTL_TIMEOUT = 3.0 # seconds to wait for the device's reply to a socket command + + +def open_ctl(dev: str): + """Listening control socket /run/espdrop/.sock, or (None, None).""" + path = os.path.join(CTL_DIR, f"{dev}.sock") + try: + os.makedirs(CTL_DIR, mode=0o755, exist_ok=True) + try: + os.unlink(path) # left over from a bridge that did not exit cleanly + except FileNotFoundError: + pass + s = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) + s.bind(path) + os.chmod(path, 0o600) + uid, gid = os.environ.get("SUDO_UID"), os.environ.get("SUDO_GID") + if uid and uid.isdigit(): + os.chown(path, int(uid), int(gid) if gid and gid.isdigit() else -1) + s.listen(4) + s.setblocking(False) + return s, path + except OSError as e: + print(f"warning: no control socket ({e}); tools cannot reach the device", + file=sys.stderr) + return None, None + def open_tap(name: str) -> int: fd = os.open("/dev/net/tun", os.O_RDWR) @@ -161,11 +196,18 @@ def main() -> int: file=sys.stderr) return 1 + # kill / a closed terminal: leave like Ctrl-C, so the control socket goes too. + def _stop(*_): + raise KeyboardInterrupt + signal.signal(signal.SIGTERM, _stop) + signal.signal(signal.SIGHUP, _stop) + from_tap = 0 # host -> device frames read from awdl0 to_tap = 0 # device -> host frames written to awdl0 usb_fd = link.open_serial_raw(args.port) reader = link.Reader() tap_fd = None + ctl = ctl_path = None try: # Learn the device MAC (and drain boot chatter) before bringing the link up. @@ -214,8 +256,12 @@ def main() -> int: print(f"{args.dev} up (mtu {args.mtu}, mac {mac or 'kernel-assigned'}); " f"bridging {args.port}") - print("commands: mac | stats | radio | tx | node | chan [n|auto] | seq [mirror|verbatim] | checks | usbtest [kib] | ping | testframe | resync" + print("commands: mac | stats | radio | tx | node | chan [n|auto] | seq [mirror|verbatim] | wake [secs|off] | checks | usbtest [kib] | ping | testframe | resync" " (Ctrl-C to quit)") + ctl, ctl_path = open_ctl(args.dev) + # The one socket client whose command is with the device: its conn, + # partial input, whether the command went out, and when to give up. + ctl_client = None # The C3 keeps running across host sessions (its USB port does not reset # the chip), so peers it learned earlier would never be re-announced. Ask @@ -242,11 +288,27 @@ def main() -> int: # Single-threaded select pump. Writes use a bounded 1s timeout so one # direction stalling on USB backpressure cannot block the other for long # (M2.1 traffic is light; a queued non-blocking design is for M2.3+). + def ctl_finish(text: str) -> None: + nonlocal ctl_client + try: + ctl_client["conn"].sendall(text.encode("utf-8", "replace") + b"\n") + except OSError: + pass + ctl_client["conn"].close() + ctl_client = None + while True: watch = [usb_fd, tap_fd] if stdin_open: watch.append(stdin_fd) - r, _, _ = select.select(watch, [], [], 0.25 if probe_ll else None) + if ctl is not None: + watch.append(ctl) + if ctl_client is not None and not ctl_client["sent"]: + watch.append(ctl_client["conn"]) # until its command line is in + r, _, _ = select.select(watch, [], [], + 0.25 if probe_ll or ctl_client is not None else None) + if ctl_client is not None and time.monotonic() > ctl_client["deadline"]: + ctl_finish("error: no reply from the device") if probe_ll and time.monotonic() >= next_probe: next_probe = time.monotonic() + (1.0 if hinted else 0.25) ready_ll = usable_link_local(args.dev) @@ -308,7 +370,12 @@ def main() -> int: elif t == link.MSG_LOG: print(f"[device] {p.decode('utf-8', 'replace')}") elif t == link.MSG_CMD_REPLY: - print(f"[reply] {p.decode('utf-8', 'replace')}") + reply = p.decode('utf-8', 'replace') + print(f"[reply] {reply}") + # Unnumbered: the next reply after a socket command + # is its answer (a command typed meanwhile can race). + if ctl_client is not None and ctl_client["sent"]: + ctl_finish(reply) elif t == link.MSG_PEER_ADD and len(p) == 6: print(f"[peer+] {mac_str(p)}") elif t == link.MSG_PEER_REMOVE and len(p) == 6: @@ -329,6 +396,44 @@ def main() -> int: except (OSError, TimeoutError) as e: print(f"usb write failed: {e}", file=sys.stderr) + if ctl is not None and ctl in r: + try: + conn, _ = ctl.accept() + except OSError: + conn = None + if conn is not None and ctl_client is not None: + conn.sendall(b"error: busy, retry\n") + conn.close() + elif conn is not None: + conn.setblocking(False) + ctl_client = {"conn": conn, "buf": b"", "sent": False, + "deadline": time.monotonic() + CTL_TIMEOUT} + + if ctl_client is not None and not ctl_client["sent"] and ctl_client["conn"] in r: + try: + chunk = ctl_client["conn"].recv(256) + except (BlockingIOError, InterruptedError): + chunk = None + except OSError: + chunk = b"" + if chunk == b"": + ctl_client["conn"].close() # client gave up before sending + ctl_client = None + elif chunk: + ctl_client["buf"] += chunk + if b"\n" in ctl_client["buf"] or len(ctl_client["buf"]) > 200: + cmd = ctl_client["buf"].split(b"\n", 1)[0].strip() + word = cmd.split(b" ", 1)[0].decode("ascii", "replace") + if word not in CTL_COMMANDS: + ctl_finish(f"error: {word!r} is not a socket command") + else: + print(f"[ctl] {cmd.decode('ascii', 'replace')}") + try: + link.write_all(usb_fd, link.frame(link.MSG_CMD, cmd)) + ctl_client["sent"] = True + except (OSError, TimeoutError) as e: + ctl_finish(f"error: command send failed: {e}") + if stdin_open and stdin_fd in r: try: chunk = os.read(stdin_fd, 4096) @@ -350,6 +455,12 @@ def main() -> int: except KeyboardInterrupt: print() finally: + if ctl is not None: + ctl.close() + try: + os.unlink(ctl_path) + except OSError: + pass if tap_fd is not None: os.close(tap_fd) # removes the non-persistent interface os.close(usb_fd) diff --git a/tools/espdrop_cmd.py b/tools/espdrop_cmd.py new file mode 100755 index 0000000..be817d2 --- /dev/null +++ b/tools/espdrop_cmd.py @@ -0,0 +1,89 @@ +#!/usr/bin/env python3 +# EspDrop - send one command to the device through a running awdl_bridge.py. +# +# The bridge owns the serial port; other tools reach the device through its +# control socket /run/espdrop/.sock (owned by the user who started the +# bridge with sudo). One command per call, the device's reply is printed. +# +# tools/espdrop_cmd.py node +# tools/espdrop_cmd.py wake 120 # AirDrop wake from the ESP's Bluetooth +# tools/espdrop_cmd.py wake off +# tools/espdrop_cmd.py --appleid you@icloud.com wake 120 # contacts-only target +# +# For `wake `, --appleid / --phone / --email / --email2 are hashed as in +# tools/airdrop_wake.py (first 2 bytes of SHA-256 each) and sent along. +# Exit status: 0 when the device answered, 1 when it reported an error or could +# not be reached. +# +# SPDX-License-Identifier: GPL-3.0-or-later +# Copyright (C) 2026 EspDrop contributors + +import argparse +import hashlib +import os +import socket +import sys + +CTL_DIR = "/run/espdrop" + + +def hash2(identifier: str) -> bytes: + """First 2 bytes of SHA-256(identifier), or 0x0000 for an empty string + (the same as tools/airdrop_wake.py).""" + if not identifier: + return b"\x00\x00" + return hashlib.sha256(identifier.encode()).digest()[:2] + + +def main() -> int: + ap = argparse.ArgumentParser(description="send a command to the EspDrop device") + ap.add_argument("-i", "--iface", default="awdl0", help="bridge interface (default awdl0)") + ap.add_argument("-t", "--timeout", type=float, default=5.0, help="seconds to wait") + ap.add_argument("--appleid", default="", help="Apple ID to hash into `wake`") + ap.add_argument("--phone", default="", help="phone number to hash into `wake`") + ap.add_argument("--email", default="", help="email address to hash into `wake`") + ap.add_argument("--email2", default="", help="second email address to hash into `wake`") + ap.add_argument("command", nargs="+", help="device command, e.g. node, wake 120") + args = ap.parse_args() + + words = args.command + ids = (args.appleid, args.phone, args.email, args.email2) + if words[0] == "wake" and len(words) == 2 and words[1] != "off" and any(ids): + words = words + [b"".join(hash2(x) for x in ids).hex()] + cmd = " ".join(words) + + path = os.path.join(CTL_DIR, f"{args.iface}.sock") + s = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) + s.settimeout(args.timeout) + try: + s.connect(path) + s.sendall(cmd.encode() + b"\n") + reply = b"" + while not reply.endswith(b"\n"): + chunk = s.recv(512) + if not chunk: + break + reply += chunk + except FileNotFoundError: + print(f"error: no bridge control socket {path}; is " + f"'sudo tools/awdl_bridge.py /dev/ttyACM0' running?", file=sys.stderr) + return 1 + except PermissionError: + print(f"error: {path} belongs to another user; start the bridge with sudo " + f"from this user's session", file=sys.stderr) + return 1 + except OSError as e: # timeout, refused (bridge gone) + print(f"error: {path}: {e}", file=sys.stderr) + return 1 + finally: + s.close() + + text = reply.decode("utf-8", "replace").strip() + print(text) + failed = (not text or text.startswith("error") or text.startswith("wake error") + or text.startswith("wake unavailable") or text.startswith("wake: ")) + return 1 if failed else 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tools/opendrop_send.sh b/tools/opendrop_send.sh index dd11308..25d0007 100755 --- a/tools/opendrop_send.sh +++ b/tools/opendrop_send.sh @@ -12,19 +12,26 @@ # sheet and LEAVE IT OPEN: it keeps the device's AWDL awake. With the sheet # closed it never advertises a receiver, BLE wake or not; airdrop-mt7921's # working sends (FINDINGS §27, §37) had the sheet open plus the BLE wake. This -# script broadcasts that wake (tools/airdrop_wake.py), browses for the receiver -# on awdl0, and sends. The device shows an Accept prompt; tap Accept. +# script broadcasts that wake, browses for the receiver on awdl0, and sends. +# The device shows an Accept prompt; tap Accept. +# +# The wake comes from the ESP's own Bluetooth when its firmware has it (bridge +# `wake`, reached through the bridge's control socket), else from the PC's +# Bluetooth (tools/airdrop_wake.py). --wake picks one explicitly. # # Options: -n NAME sender name shown on the iPhone (default: EspDrop) # -i IFACE interface (default: awdl0) # -r RECV receiver: its name, 12-hex id, or index (default: the # first one found this run) # -t SECS how long to browse for a receiver (default: 60) -# --no-wake do not broadcast the BLE wake (if something else does) +# --wake=M BLE wake from: auto (default: esp if it can, else pc), +# esp, pc, or none (if something else broadcasts it) +# --no-wake the same as --wake=none # -h this help # Environment: OPENDROP_VENV (default ~/opendrop-venv), ESPDROP_TLS_KEYLOG=FILE, -# ESPDROP_FIND_LOG (debug log of the browse, default ~/espdrop-find.log). -# Contacts-only target: pass identifiers to the wake via +# ESPDROP_FIND_LOG (debug log of the browse, default ~/espdrop-find.log), +# ESPDROP_WAKE (default for --wake). +# Contacts-only target: pass identifiers to the wake (either source) via # ESPDROP_WAKE_ARGS="--appleid you@icloud.com" tools/opendrop_send.sh file # # SPDX-License-Identifier: GPL-3.0-or-later @@ -36,17 +43,18 @@ NAME=EspDrop IFACE=awdl0 RECV="" BROWSE=60 -WAKE=1 +WAKE_MODE="${ESPDROP_WAKE:-auto}" VENV="${OPENDROP_VENV:-$HOME/opendrop-venv}" HERE="$(cd "$(dirname "$0")" && pwd)" usage() { awk 'NR > 1 { if (/^# SPDX/) exit; sub(/^# ?/, ""); print }' "$0"; } -# getopts does not do long options; pull --no-wake out first. +# getopts does not do long options; pull --wake/--no-wake out first. ARGS=() for a in "$@"; do case "$a" in - --no-wake) WAKE=0 ;; + --no-wake) WAKE_MODE=none ;; + --wake=*) WAKE_MODE="${a#--wake=}" ;; --help) usage; exit 0 ;; *) ARGS+=("$a") ;; esac @@ -72,6 +80,7 @@ die() { echo "error: $*" >&2; exit 1; } [ -x "$VENV/bin/opendrop" ] || die "no OpenDrop in $VENV - run tools/opendrop_install.sh first" case "$NAME" in *"'"*|*'"'*) die "the name must not contain quotes (it goes into the TLS certificate)" ;; esac command -v ip >/dev/null || die "the 'ip' command (iproute2) is required" +case "$BROWSE" in ''|*[!0-9]*) die "-t needs a number of seconds" ;; esac files=() for f in "$@"; do @@ -86,10 +95,21 @@ usable_ll() { } [ -n "$(usable_ll)" ] || die "$IFACE has no usable link-local; is 'sudo tools/awdl_bridge.py /dev/ttyACM0' running?" +# The ESP's wake goes through the bridge's control socket (tools/espdrop_cmd.py). +esp_cmd() { "$HERE/espdrop_cmd.py" -i "$IFACE" "$@"; } +# shellcheck disable=SC2086 +esp_wake() { esp_cmd ${ESPDROP_WAKE_ARGS:-} wake "$1" >/dev/null 2>&1; } +case "$WAKE_MODE" in + auto) if esp_cmd wake >/dev/null 2>&1; then WAKE_MODE=esp; else WAKE_MODE=pc; fi ;; + esp|pc|none) ;; + *) die "--wake must be auto, esp, pc or none" ;; +esac + report="$HOME/.opendrop/discover.last.json" # Kept after the run (overwritten each time) so a failed browse can be read. findlog="${ESPDROP_FIND_LOG:-$HOME/espdrop-find.log}" wake_pid="" +wake_esp=0 find_pid="" # Ignore SIGPIPE so a closed stdout (| tee quit) cannot kill us mid-cleanup. trap '' PIPE @@ -109,6 +129,8 @@ stop_find() { cleanup() { stop_find [ -n "$wake_pid" ] && kill "$wake_pid" 2>/dev/null + # The ESP's wake also stops by itself when its time is up. + [ "$wake_esp" -eq 1 ] && { esp_cmd wake off >/dev/null 2>&1; wake_esp=0; } } trap 'cleanup; exit 130' INT TERM HUP trap cleanup EXIT @@ -125,14 +147,25 @@ env PYTHONUNBUFFERED=1 "$VENV/bin/opendrop" -n "$NAME" -d -i "$IFACE" find > "$f find_pid=$! sleep 2 -if [ "$WAKE" -eq 1 ]; then - echo "broadcasting the AirDrop BLE wake (device: AirDrop Everyone, unlocked, share sheet OPEN) ..." - # shellcheck disable=SC2086 - "$HERE/airdrop_wake.py" ${ESPDROP_WAKE_ARGS:-} >/dev/null 2>&1 & - wake_pid=$! - sleep 1 - kill -0 "$wake_pid" 2>/dev/null || { wake_pid=""; echo "warning: BLE wake did not start (python3-dbus / python3-gi installed, bluetooth on?); browsing anyway" >&2; } -fi +# Long enough for the browse; re-armed below for Accept and the upload. +wake_secs=$((BROWSE + 60)) +[ "$wake_secs" -gt 600 ] && wake_secs=600 +case "$WAKE_MODE" in + esp) + echo "broadcasting the AirDrop BLE wake from the ESP (device: AirDrop Everyone, unlocked, share sheet OPEN) ..." + if esp_wake "$wake_secs"; then + wake_esp=1 + else + echo "warning: the ESP did not start the BLE wake ($(esp_cmd wake 2>&1)); browsing anyway" >&2 + fi ;; + pc) + echo "broadcasting the AirDrop BLE wake from the PC (device: AirDrop Everyone, unlocked, share sheet OPEN) ..." + # shellcheck disable=SC2086 + "$HERE/airdrop_wake.py" ${ESPDROP_WAKE_ARGS:-} >/dev/null 2>&1 & + wake_pid=$! + sleep 1 + kill -0 "$wake_pid" 2>/dev/null || { wake_pid=""; echo "warning: BLE wake did not start (python3-dbus / python3-gi installed, bluetooth on?); browsing anyway" >&2; } ;; +esac # `opendrop find` never exits on its own; stop at the first receiver or timeout. recv_id="" @@ -185,8 +218,9 @@ if [ -z "$recv_id" ] && [ -z "$RECV" ]; then echo " in the bridge: auto=on and chan= the device's 5 GHz channel)." >&2 echo " - 'Everyone for 10 Minutes' falls back to Contacts Only after 10 minutes;" >&2 echo " select it again right before sending." >&2 - echo " - the BLE wake needs the PC's bluetooth on; without python3-dbus/python3-gi" >&2 - echo " it is skipped. Raise the browse time with -t if it is slow to appear." >&2 + echo " - the BLE wake (this run: $WAKE_MODE) comes from the ESP's Bluetooth (bridge" >&2 + echo " 'wake') or the PC's (bluetooth on, python3-dbus/python3-gi); try the other" >&2 + echo " with --wake=pc / --wake=esp. Raise the browse time with -t if it is slow." >&2 exit 1 fi @@ -194,6 +228,8 @@ target="${RECV:-$recv_id}" echo "sending to '${target}' ..." # send reads the receiver's address/port from $report, which find just wrote. [ -s "$report" ] || die "no discovery report at $report (find wrote nothing)" +# Keep the ESP's wake up through the Accept prompt and the upload. +[ "$wake_esp" -eq 1 ] && esp_wake 300 env PYTHONUNBUFFERED=1 ${keylog[@]+"${keylog[@]}"} \ "$VENV/bin/opendrop" -n "$NAME" -i "$IFACE" send -r "$target" "${files[@]}" rc=$? From a50ff8e505330fc59492e27c7519103535d0fa4a Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 23 Oct 2026 11:38:10 +0000 Subject: [PATCH 61/99] docs(m3): sending with the ESP's own BLE wake works on hardware Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01XzgaN4Bv1m8Ka9iqo7QVUN --- docs/UEBERBLICK.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/UEBERBLICK.md b/docs/UEBERBLICK.md index 8437e94..a8f6ff9 100644 --- a/docs/UEBERBLICK.md +++ b/docs/UEBERBLICK.md @@ -200,8 +200,8 @@ danach mit dem gepatchten OpenDrop eine Datei **empfangen**. **M3 – BLE-Wake auf dem ESP:** Datei an das iPhone **senden**. Stand 2026-10-23: Senden funktioniert mit dem BLE-Wecksignal vom Bluetooth des PCs (`tools/opendrop_send.sh`, siehe `docs/SPIKES.md`). Das Wecksignal vom ESP selbst -(Bridge `wake`, NimBLE) ist gebaut und auf dem Host getestet, aber noch nicht -auf der Hardware. +(Bridge `wake`, NimBLE) funktioniert ebenfalls: Senden an ein iPad mit +ausgeschaltetem PC-Bluetooth erfolgreich (2026-10-23). M3 ist damit erledigt. **M4 – Windows (Option C):** lwIP + mDNS auf dem ESP, Socket-Proxy-Protokoll, Host-App (z. B. Python oder Rust) mit OpenDrop-Logik inkl. iOS-26-Patches. From b432051dea782d78b38c41a04a9ecc5706ba0fb2 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 23 Oct 2026 11:58:28 +0000 Subject: [PATCH 62/99] fix(bridge): win the RX queue back from Bluetooth With the NimBLE defaults the bridge's RX queue fell from 48 to 16 slots (rxq=16, heap=67 KB) and a 38 MB AirDrop receive lost ~700 frames to the full queue (rx_drops), at 0.6 MB/s. - sdkconfig.bridge.defaults: advertising needs no connections or ACL data, so shrink MSYS pools (12/8), ACL-from-LL (4), event buffers (16) and the controller's reserved connection contexts (0). - bridge.c: heap reserve for Wi-Fi 64 -> 24 KB (measured: free heap fell only ~7 KB below idle during that receive), and a 40-slot step between 48/32. - SPIKES.md: the 0.6 MB/s beats the ~255 kB/s usbtest ceiling; recorded as open until bytes_to_host and duration are measured together. C5 and C3 bridge build. Not yet on hardware. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01XzgaN4Bv1m8Ka9iqo7QVUN --- docs/SPIKES.md | 8 ++++++++ firmware/spikes/main/bridge.c | 8 +++++--- firmware/spikes/sdkconfig.bridge.defaults | 9 +++++++++ 3 files changed, 22 insertions(+), 3 deletions(-) diff --git a/docs/SPIKES.md b/docs/SPIKES.md index f3dc72c..b1d44a2 100644 --- a/docs/SPIKES.md +++ b/docs/SPIKES.md @@ -190,6 +190,14 @@ Radarerkennung). Polling) schaffen beide ~255 kB/s. Die Grenze liegt also am USB-Serial/JTAG- Port selbst (ein 64-Byte-Paket je Abholung durch den PC), nicht am Treiber; AirDrop nutzt davon schon ~90 %. +- **Nachmessung (2026-10-23, mit Bluetooth in der Firmware):** eine 38-MB-Datei + mit ~0,6 MB/s laut OpenDrop, also mehr als die gemessenen ~255 kB/s. Die + 2,87 MB oben waren zu klein (Aufbau und Annehmen zählen mit), und `usbtest` + scheint die Grenze zu unterschätzen; offen, bis `bytes_to_host` und die + Dauer einer Übertragung zusammen gemessen sind. Mit den Bluetooth-Defaults + sank die Empfangs-Warteschlange auf 16 Plätze (`rxq=16`, ~700 `rx_drops`); + seitdem sind die NimBLE-Puffer verkleinert und die Heap-Reserve für Wi-Fi + auf 24 KB gesenkt (gemessen fiel der freie Heap im Betrieb nur ~7 KB). ## Senden an ein iPhone (M3, PC -> iPhone) diff --git a/firmware/spikes/main/bridge.c b/firmware/spikes/main/bridge.c index c7beab1..043e953 100644 --- a/firmware/spikes/main/bridge.c +++ b/firmware/spikes/main/bridge.c @@ -42,10 +42,12 @@ * an iPhone sends in 15 of 16 slots, so bursts are longer still (C5: 299 drops * at depth 32 in one transfer). Try the big depth first and fall back if the * heap is short. */ -static const uint8_t k_rx_q_depths[] = {64, 48, 32, 24, 16, 8}; +static const uint8_t k_rx_q_depths[] = {64, 48, 40, 32, 24, 16, 8}; /* Wi-Fi allocates its RX/TX buffers dynamically from the same heap, so never - * take the last of it: a larger depth is used only if this much stays free. */ -#define BRIDGE_HEAP_RESERVE (64u * 1024u) + * take the last of it: a larger depth is used only if this much stays free. + * Measured on the C5 with Bluetooth: over a 38 MB receive the free heap fell + * only ~7 KB below its idle value (stats heap vs heap_min). */ +#define BRIDGE_HEAP_RESERVE (24u * 1024u) typedef struct { uint16_t len; uint8_t eth[14 + BRIDGE_RX_PAYLOAD_MAX]; diff --git a/firmware/spikes/sdkconfig.bridge.defaults b/firmware/spikes/sdkconfig.bridge.defaults index e99eed5..58d61ec 100644 --- a/firmware/spikes/sdkconfig.bridge.defaults +++ b/firmware/spikes/sdkconfig.bridge.defaults @@ -30,4 +30,13 @@ CONFIG_BT_NIMBLE_ROLE_PERIPHERAL=n CONFIG_BT_NIMBLE_ROLE_OBSERVER=n CONFIG_BT_NIMBLE_SECURITY_ENABLE=n CONFIG_BT_NIMBLE_LOG_LEVEL_WARNING=y +# Advertising only, so no connections and no ACL data: shrink the buffer pools +# and the controller's reserved connection contexts. Their heap is what the +# bridge's RX queue needs (with the defaults it dropped from 48 to 16 slots and +# a 38 MB receive lost ~700 frames to a full queue). +CONFIG_BT_NIMBLE_MSYS_1_BLOCK_COUNT=12 +CONFIG_BT_NIMBLE_MSYS_2_BLOCK_COUNT=8 +CONFIG_BT_NIMBLE_TRANSPORT_ACL_FROM_LL_COUNT=4 +CONFIG_BT_NIMBLE_TRANSPORT_EVT_COUNT=16 +CONFIG_BT_LE_CONN_RESERVED_MEMORY_COUNT=0 CONFIG_PARTITION_TABLE_SINGLE_APP_LARGE=y From 98f70b4e78ae79dd187afa8e0a3d6b7c319c33c0 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 23 Oct 2026 12:10:12 +0000 Subject: [PATCH 63/99] docs(c5): 38 MB receive at ~0.6 MB/s, drops do not limit it The usbtest ceiling (~255 kB/s) underestimated the USB path: every received byte crosses it and a 38 MB transfer took ~60 s. Halving rx_drops (700 -> 425) by shrinking the NimBLE buffers left the speed unchanged, so the drops are TCP finding the bottleneck, most likely USB full speed. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01XzgaN4Bv1m8Ka9iqo7QVUN --- docs/SPIKES.md | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/docs/SPIKES.md b/docs/SPIKES.md index b1d44a2..0d5257b 100644 --- a/docs/SPIKES.md +++ b/docs/SPIKES.md @@ -191,13 +191,16 @@ Radarerkennung). Port selbst (ein 64-Byte-Paket je Abholung durch den PC), nicht am Treiber; AirDrop nutzt davon schon ~90 %. - **Nachmessung (2026-10-23, mit Bluetooth in der Firmware):** eine 38-MB-Datei - mit ~0,6 MB/s laut OpenDrop, also mehr als die gemessenen ~255 kB/s. Die - 2,87 MB oben waren zu klein (Aufbau und Annehmen zählen mit), und `usbtest` - scheint die Grenze zu unterschätzen; offen, bis `bytes_to_host` und die - Dauer einer Übertragung zusammen gemessen sind. Mit den Bluetooth-Defaults - sank die Empfangs-Warteschlange auf 16 Plätze (`rxq=16`, ~700 `rx_drops`); - seitdem sind die NimBLE-Puffer verkleinert und die Heap-Reserve für Wi-Fi - auf 24 KB gesenkt (gemessen fiel der freie Heap im Betrieb nur ~7 KB). + in etwa 60 s, ~0,6 MB/s laut OpenDrop. Da jedes empfangene Byte über USB + läuft, schafft der USB-Serial/JTAG-Weg mindestens das; `usbtest` (~255 kB/s) + hat die Grenze unterschätzt, und die 2,87 MB oben waren zu klein (Aufbau und + Annehmen zählen mit). Mit den Bluetooth-Defaults sank die Empfangs- + Warteschlange auf 16 Plätze (`rxq=16`, ~700 `rx_drops`); mit verkleinerten + NimBLE-Puffern und 24 KB Heap-Reserve für Wi-Fi waren es 425 Drops (~1,6 % + der Frames) bei **gleicher** Geschwindigkeit. Die Drops begrenzen also nicht, + sie sind TCPs Signal am Engpass; der liegt sehr wahrscheinlich am USB + (Full-Speed, praktisch rund 1 MB/s). Mehr ginge nur über einen anderen Weg + zum PC. ## Senden an ein iPhone (M3, PC -> iPhone) From 905090db88d2b2b8977c7872e11be181c7132aa4 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 23 Oct 2026 12:16:11 +0000 Subject: [PATCH 64/99] docs(m4): design for lwIP on the ESP, socket proxy and one host app Draft for review, no code yet: the ESP terminates IPv6/TCP/UDP on the AWDL link (lwIP netif with traffic-learned neighbours), USB carries connection bytes and datagrams with credit-based flow control, and a Python host app runs mDNS and the patched OpenDrop as a library on Windows and Linux alike. The L2 awdl0 mode stays in the same firmware for diagnosis. Covers the protocol, memory budget, testing without hardware, stages M4.1-M4.6, risks and the decisions needed. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01XzgaN4Bv1m8Ka9iqo7QVUN --- docs/M4_ENTWURF.md | 311 +++++++++++++++++++++++++++++++++++++++++++++ docs/UEBERBLICK.md | 1 + 2 files changed, 312 insertions(+) create mode 100644 docs/M4_ENTWURF.md diff --git a/docs/M4_ENTWURF.md b/docs/M4_ENTWURF.md new file mode 100644 index 0000000..33cea76 --- /dev/null +++ b/docs/M4_ENTWURF.md @@ -0,0 +1,311 @@ +# M4 – Entwurf: IP-Stack auf dem ESP, Socket-Proxy, eine Host-App für Windows und Linux + +Status: **Entwurf zur Abstimmung, noch kein Code.** Die offenen Entscheidungen +stehen in Abschnitt 9. + +## 1. Ziel und Grundidee + +**Heute (Option A, nur Linux):** Der ESP ist Funkteil und Ethernet-Brücke. Er +reicht ganze Ethernet-Frames über USB an das TAP-Interface `awdl0` weiter. +IPv6, TCP, UDP und mDNS macht der Linux-Kernel, darüber läuft das gepatchte +OpenDrop. Das braucht root (TAP, `ip`), einen Linux-Kernel und Firewall- +Ausnahmen; unter Windows gibt es kein Gegenstück zu `awdl0`. + +**M4 (Option C):** Der ESP bekommt einen eigenen IP-Stack (lwIP) auf der +AWDL-Verbindung. TCP-Verbindungen und UDP enden **auf dem ESP**; über USB gehen +nur noch die Nutzdaten der Verbindungen (Bytes) und Datagramme. TLS, HTTP und +die ganze AirDrop-Logik bleiben auf dem PC, in einer Host-App, die OpenDrop als +Bibliothek benutzt. + +**Dieselbe Firmware und dieselbe Host-App laufen unter Windows und Linux.** +Getestet wird zuerst unter Linux. Unter Linux fallen dabei TAP, root-Rechte und +Firewall-Regeln weg: Die Host-App braucht nur Zugriff auf die serielle +Schnittstelle. Der bisherige Weg über `awdl0` bleibt als Diagnose-Modus in +derselben Firmware erhalten (Abschnitt 3.2). + +``` + iPhone/iPad ESP32-C5 / C3 PC (Windows oder Linux) + ----------- AWDL -------------------------- USB ------------------------------ + AirDrop <~~~~~~~~~~~~> Radio + AWDL (wie heute) COBS espdrop (Python) + lwIP: IPv6, TCP, UDP <==> Socket-Proxy <-> lokale Sockets + Socket-Proxy mDNS (Browser + Responder) + BLE-Wecksignal (wie heute) OpenDrop: TLS, HTTP, AirDrop +``` + +## 2. Was wo läuft + +| Aufgabe | heute | M4 | +|---|---|---| +| AWDL (Sync, Election, Kanal, Slots, Daten-Frames) | ESP | ESP (unverändert) | +| BLE-Wecksignal (`wake`) | ESP | ESP (unverändert) | +| IPv6, Nachbarauflösung, TCP, UDP | Linux-Kernel | **ESP (lwIP)** | +| mDNS (`_airdrop._tcp`) | zeroconf auf dem PC | **Host-App**, über UDP-Proxy (Entscheidung 1) | +| TLS, HTTP, `/Discover`, `/Ask`, `/Upload`, Archive | OpenDrop | OpenDrop (unverändert, als Bibliothek) | +| Bedienung | `awdl_bridge.py` + `opendrop_*.sh` | `espdrop receive` / `espdrop send` | + +TLS bleibt bewusst auf dem PC: Zertifikate, Schlüssel, die iOS-26-Anpassungen +und das Entpacken der Archive (dvzip/cpio) stecken in OpenDrop und den 17 +Patches. Auf dem ESP wäre das schwer zu debuggen und würde RAM kosten, den +TCP dringender braucht. + +## 3. Firmware + +### 3.1 AWDL als lwIP-Netzwerkschnittstelle + +- Ein eigenes `esp_netif` mit selbst geschriebenem Treiber („awdl“): Senden geht + in den bestehenden Daten-Pfad (`awdl_app_tx_ethernet`, slot-gesteuert), der + Empfang kommt aus dem bestehenden RX-Pfad (`deliver_msdu`), der heute an USB + weiterreicht. +- MAC = die Wi-Fi-MAC des ESP; IPv6-Link-Local per EUI-64 daraus (wie heute + `addr_gen_mode=0` auf `awdl0`, das brauchen Apple-Geräte). MTU 1460. +- Mitglied in `ff02::fb` (MLD) für mDNS. +- **Nachbarauflösung ohne NDP über die Luft:** Die Host-Bridge lernt heute die + Zuordnung Link-Local ↔ MAC aus empfangenem Verkehr und trägt sie fest ein. + Dasselbe macht die Firmware: Aus jedem empfangenen IPv6-Frame wird + (Quell-IPv6, Quell-MAC) gelernt; ein eigener `output_ip6` der Netif schaut + dort zuerst nach (Multicast → `33:33:…`), und nur unbekannte Ziele gehen an + lwIPs normale ND. +- Neue Diagnosebefehle: `ip` (lwIP-Zähler, Nachbartabelle, offene + Verbindungen) und `ping6 ` direkt vom ESP. Damit lässt sich der + IP-Weg zum iPhone ohne Host-App prüfen. + +### 3.2 Zwei Betriebsarten in einer Firmware + +- **L2** (wie heute): Frames gehen an den Host, `awdl_bridge.py` und `awdl0` + funktionieren unverändert. Bleibt für Diagnose mit tcpdump/Wireshark. +- **IP** (neu): Frames gehen in lwIP; über USB laufen Proxy-Nachrichten. +- Der Host wählt die Betriebsart mit der ersten Nachricht (`HELLO`, Abschnitt + 4). Ohne `HELLO` bleibt der ESP in L2, damit ältere Host-Werkzeuge + weiterlaufen. Beide gleichzeitig geht nicht (zwei IP-Stacks mit derselben + Adresse würden sich stören). +- Ein neues `HELLO` beendet alle Proxy-Verbindungen; so räumt ein Neustart der + Host-App den ESP auf. + +### 3.3 Socket-Proxy + +- **TCP:** lauschen (Port 8771 für den Empfang), annehmen, verbinden (Port des + Empfängers beim Senden), Daten, Halb-Schließen (FIN) und Abbruch (RST). + Höchstens 4 gleichzeitige Verbindungen (iOS öffnet für `/Discover`, `/Ask` + und `/Upload` teils eigene Verbindungen; OpenDrops Server ist deshalb + mehrfädig). +- **UDP:** ein Socket auf Port 5353 mit Gruppe `ff02::fb`; empfangene + Datagramme gehen mit Absender und „war Multicast“ an den Host, der Host + schickt Datagramme an Multicast oder an eine Unicast-Adresse. +- **Flusskontrolle mit Krediten** je Verbindung und Richtung (Abschnitt 4.3). + Der ESP gibt TCP-Fenster erst frei (`tcp_recved`), wenn die Daten auf USB + sind. Wird USB eng, schließt sich also das TCP-Fenster, und das iPhone sendet + langsamer, statt dass wie heute Frames an der Warteschlange verworfen werden + (zuletzt ~1,6 %). + +### 3.4 Speicherbudget + +Gemessen auf dem C5 mit Bluetooth (2026-10-23): frei `heap` ≈ 67 KB bei einer +L2-Warteschlange von 16 Plätzen (≈ 24 KB), also ≈ 91 KB vor der Warteschlange. +In der IP-Betriebsart entfällt die L2-Warteschlange; der Platz geht an lwIP. + +| Posten (Vorschlag) | Bedarf | +|---|---| +| TCP-Empfangsfenster 8 × MSS = 11,7 KB, 2 Verbindungen mit Daten | ≈ 24 KB | +| TCP-Sendepuffer 6 × MSS = 8,8 KB, 2 Verbindungen | ≈ 18 KB | +| pbufs für Frames unterwegs, Proxy-Puffer, UDP | ≈ 12 KB | +| Reserve für Wi-Fi (gemessen ~7 KB Spitze) | ≈ 24 KB | +| **Summe** | **≈ 78 KB** von ≈ 91 KB | + +Das ist knapp, aber machbar. Falls nicht: Bluetooth erst bei `wake` starten und +danach wieder abbauen (Entscheidung 5). Die echten Zahlen kommen in M4.1 aus +`stats`/`ip`. + +### 3.5 Diagnose ohne tcpdump + +In der IP-Betriebsart gibt es kein `awdl0` mehr, an dem man mitschneiden kann. +Ersatz: Der Befehl `capture on|off` lässt den ESP jeden IP-Frame (in beide +Richtungen, mit Zeitstempel) zusätzlich als `CAPTURE`-Nachricht schicken; die +Host-App schreibt daraus eine `.pcapng`-Datei für Wireshark. Das kostet +USB-Bandbreite und ist nur zum Debuggen gedacht. + +## 4. USB-Protokoll (Erweiterung des bestehenden COBS-Links) + +Rahmen wie heute: COBS, Null-Byte als Trenner, erstes Byte = Nachrichtentyp. +Die Typen 1–8 (Echo, Log, Frame, Peer, Cmd) bleiben. Zahlen little-endian, +IPv6-Adressen als 16 Bytes in Netzwerk-Reihenfolge (immer AWDL-Link-Local, +die Zone ist implizit die AWDL-Netif). + +### 4.1 Nachrichten + +| Typ | Name | Richtung | Inhalt | +|---|---|---|---| +| 9 | `HELLO` | H→E | `u8 version, u8 mode (0 L2, 1 IP), u32 host_credit` | +| 10 | `HELLO_REPLY` | E→H | `u8 version, u8 mode, mac[6], u8 max_conns, u16 max_chunk, u8 caps (Bit 0 BLE)` | +| 16 | `TCP_LISTEN` | H→E | `u16 port` | +| 17 | `TCP_CONNECT` | H→E | `u16 conn, addr[16], u16 port` | +| 18 | `TCP_OPENED` | E→H | `u16 conn, addr[16] remote, u16 remote_port, u16 local_port, u8 accepted, u32 credit` | +| 19 | `TCP_DATA` | beide | `u16 conn, bytes` (höchstens `max_chunk`, Vorschlag 1400) | +| 20 | `TCP_CREDIT` | beide | `u16 conn, u32 bytes` (Empfänger gibt so viele Bytes zusätzlich frei) | +| 21 | `TCP_CLOSE` | beide | `u16 conn, u8 kind (0 FIN, 1 RST), i8 err` | +| 24 | `UDP_OPEN` | H→E | `u16 port, u8 flags (Bit 0 Gruppe ff02::fb)` | +| 25 | `UDP_RECV` | E→H | `u16 port, addr[16] src, u16 src_port, u8 flags (Bit 0 war Multicast), bytes` | +| 26 | `UDP_SEND` | H→E | `u16 port, addr[16] dst, u16 dst_port, bytes` | +| 28 | `RESULT` | E→H | `u8 request_type, u16 id, i8 err` (für `TCP_LISTEN`, `UDP_OPEN`, fehlgeschlagenes `TCP_CONNECT`) | +| 32 | `CAPTURE` | E→H | `u8 dir, u32 time_us, frame` (nur nach `capture on`) | + +Verbindungsnummern: Der Host vergibt sie für ausgehende Verbindungen +(`0x0001–0x7fff`), der ESP für angenommene (`0x8000–0xffff`). So gibt es keine +Kollision ohne Rückfrage. + +### 4.2 Abläufe + +**Senden an ein iPhone:** +``` +H→E CMD "wake 120" +H→E UDP_OPEN 5353 (Gruppe) E→H RESULT ok +H→E UDP_SEND ff02::fb PTR? _airdrop (alle 1,5 s, wie der OpenDrop-Patch) +E→H UDP_RECV fe80::… PTR/SRV/TXT/AAAA → Host kennt Adresse und Port +H→E TCP_CONNECT 1, fe80::…, 8770 E→H TCP_OPENED 1, credit 8760 +H↔E TCP_DATA / TCP_CREDIT … (TLS, POST /Discover, /Ask, /Upload) +H→E TCP_CLOSE 1 FIN E→H TCP_CLOSE 1 FIN +H→E CMD "wake off" +``` + +**Empfangen vom iPhone:** +``` +H→E TCP_LISTEN 8771 E→H RESULT ok +H→E UDP_OPEN 5353 (Gruppe) E→H RESULT ok +H→E UDP_SEND … Ankündigung _airdrop (alle 5 s) und Antworten auf Anfragen +E→H TCP_OPENED 0x8001, accepted=1 → Host verbindet zum lokalen OpenDrop-Server +H↔E TCP_DATA / TCP_CREDIT … +``` + +### 4.3 Flusskontrolle + +- Jede Seite darf je Verbindung nur so viele Bytes schicken, wie die andere + freigegeben hat. Startkredit ESP→Host aus `HELLO` (Vorschlag 64 KB), Host→ESP + aus `TCP_OPENED` (= freier TCP-Sendepuffer auf dem ESP). +- Der ESP gibt Kredit an den Host zurück, sobald lwIP Daten bestätigt bekommen + hat (`tcp_sent`); der Host gibt Kredit zurück, sobald die lokale Anwendung + die Daten gelesen hat. +- Der ESP verteilt die USB-Bandbreite reihum auf die Verbindungen; `CMD`, + `UDP_*` und Steuer-Nachrichten haben Vorrang vor `TCP_DATA`. + +### 4.4 Version + +`HELLO.version` = 1. Passt die Version nicht, antwortet der ESP mit seiner +eigenen und bleibt in L2; die Host-App meldet dann „Firmware zu alt/neu“. + +## 5. Host-App `espdrop` (Python) + +### 5.1 Aufbau + +Ein Python-Paket unter `host/espdrop/`, lauffähig unter Windows und Linux: + +| Modul | Aufgabe | +|---|---| +| `link` | COBS-Rahmen (aus `tools/espdrop_link.py`), serielle Schnittstelle über pyserial (`COM5`, `/dev/ttyACM0`) | +| `device` | Protokoll aus Abschnitt 4, `HELLO`, Befehle (`wake`, `node`, `stats` …) | +| `proxy` | Verbindungstabelle, Kredite; macht jede ESP-Verbindung zu einem normalen lokalen Socket | +| `mdns` | Browser und Responder für `_airdrop._tcp` über den UDP-Proxy | +| `airdrop` | Anbindung an das gepatchte OpenDrop (Server, Client, Konfiguration) | +| `cli` | `espdrop receive`, `espdrop send DATEI…`, `espdrop find`, `espdrop status`, `espdrop capture` | + +Ein Prozess je Aufruf, wie heute `awdl_bridge.py` plus Skript zusammen. Ein +Dienst im Hintergrund oder ein Tray-Symbol kann später darauf aufsetzen. + +### 5.2 OpenDrop als Bibliothek + +OpenDrop 0.13.0 mit den 17 Patches aus airdrop-mt7921 bleibt die Grundlage +(gleiche Installation wie heute, `tools/opendrop_install.sh`, für Windows +angepasst). Ersetzt wird nur die Netzwerk-Anbindung, ohne die OpenDrop-Dateien +weiter zu patchen: + +- **Empfangen:** Eine Unterklasse von `AirDropServer` lässt den HTTPS-Server + auf `127.0.0.1` an einem freien Port lauschen, statt auf `awdl0`. Für jede + vom ESP angenommene Verbindung öffnet `proxy` eine Verbindung dorthin. TLS, + HTTP-Handler, Annehmen-Abfrage und Entpacken laufen unverändert. Die + Dienst-Anmeldung geht an unser `mdns` statt an zeroconf. +- **Senden:** `AirDropClient` baut seine Verbindung über + `HTTPSConnectionAWDL` auf. Die Host-App ersetzt diese Klasse durch eine + Variante, deren `connect()` beim Proxy eine Verbindung zu `[fe80::…]:Port` + bestellt und den lokalen Socket in TLS verpackt. Alle iOS-26-Anpassungen in + `/Ask` und `/Upload` bleiben erhalten. +- **Suchen:** Unser `mdns`-Browser liefert dieselben Angaben wie zeroconfs + `ServiceInfo` (Name, Adresse, Port, TXT-Flags), damit OpenDrops + `/Discover`-Logik weiterläuft. + +Lokale Sockets nur auf `127.0.0.1`: keine Firewall-Abfrage unter Windows, +keine Regel unter Linux. + +### 5.3 mDNS auf dem Host + +Was die letzten Wochen gezeigt haben, wird hier eingebaut statt nachgebessert: +- Browser: PTR-Anfrage für `_airdrop._tcp.local.` alle 1,5 s (wie + `opendrop-mdns-repeat`), SRV/TXT/AAAA aus Antwort und Zusatz-Abschnitt, + Goodbye (TTL 0) beachten, Antworten auf QU-Fragen auch per Unicast annehmen. +- Responder: unser Dienst mit TXT `flags`, SRV auf 8771, AAAA = Link-Local des + ESP (aus `HELLO_REPLY`), Ankündigung alle 5 s (wie + `opendrop-mdns-reannounce`), Antworten auf Anfragen, Goodbye beim Beenden. +- Kodierung/Dekodierung mit den Nachrichten-Klassen von python-zeroconf + (`DNSIncoming`/`DNSOutgoing`, in OpenDrops Installation schon vorhanden); den + Socket-Teil von zeroconf brauchen wir nicht. +- Jede gesehene Antwort lässt sich mit `-v` protokollieren, so wie + `tools/zeroconf_probe.py` es jetzt tut. + +### 5.4 Linux und Windows + +- **Linux:** kein root, kein TAP, keine Firewall-Regel. Nur Zugriff auf + `/dev/ttyACM0` (Gruppe `dialout` bzw. `uucp`). +- **Windows 10/11:** Der USB-Serial/JTAG-Port des ESP meldet sich als + CDC-Gerät, Windows bringt den Treiber mit (COM-Port). Python 3.12+, + pyserial und OpenDrops Abhängigkeiten; heikel ist `libarchive` (braucht eine + DLL). Ein fertiges Paket (z. B. PyInstaller) kommt erst, wenn alles läuft. + +## 6. Testen ohne Hardware + +- **Simulierter ESP:** Ein Python-Modul spricht das Protokoll aus Abschnitt 4 + und beendet TCP/UDP selbst auf Loopback-Sockets. Zwei Host-Apps mit je einem + simulierten ESP, die eine gemeinsame „Luft“ teilen, ergeben einen vollen + AirDrop-Durchlauf OpenDrop → OpenDrop. So lassen sich Proxy, Kredite, mDNS + und die OpenDrop-Anbindung hier im Container testen, bevor Sie flashen. +- **mDNS-Kodierung** gegen Pakete in der Form, die Ihr iPad tatsächlich + schickt (14 Antworten mit NSEC/OPT, 209-Byte-Antworten, Goodbye), wie im + Nachbau aus der Fehlersuche bei M3. +- **Firmware:** Die Proxy-Logik (Verbindungstabelle, Kredite, Nachrichten + zerlegen) kommt in eine plattformunabhängige Komponente mit Host-Tests, wie + `components/awdl`. Der lwIP-Teil wird auf der Hardware getestet. + +## 7. Etappen + +Alle Etappen werden zuerst unter Linux auf Ihrer Hardware abgenommen. + +| Etappe | Inhalt | abgenommen, wenn | +|---|---|---| +| M4.1 | AWDL-Netif in lwIP, Betriebsart IP, `HELLO`, `ip`, `ping6` auf dem ESP | `ping6` vom ESP an das iPad bekommt Antworten; L2 mit `awdl_bridge.py` geht weiter; RAM-Zahlen bekannt | +| M4.2 | UDP-Proxy, Host-App-Grundgerüst, mDNS-Browser | `espdrop find` listet das iPad (Wecksignal vom ESP) | +| M4.3 | TCP verbinden, Kredite, OpenDrop-Client | `espdrop send datei` kommt am iPad an; Senderate gemessen | +| M4.4 | TCP lauschen, mDNS-Responder, OpenDrop-Server | iPad sieht „EspDrop“, Datei kommt an; Rate ≥ heute (~0,6 MB/s) | +| M4.5 | Windows | dieselben zwei Tests unter Windows | +| M4.6 | `capture` → pcapng, Puffer-Feinabstimmung, Anleitung | Mitschnitt in Wireshark lesbar; Doku fertig | + +## 8. Risiken und offene Fragen + +- **RAM** (Abschnitt 3.4): lwIP-Fenster gegen Bluetooth und Wi-Fi. +- **Durchsatz:** Ein kleines TCP-Fenster auf dem ESP kann bremsen, wenn AWDL + in Schüben liefert (airdrop-mt7921 FINDINGS §18: ~68 ms Schub, ~450 ms Pause + auf Kanal 149 mit dünner Sequenz). Auf dem C5 mit 15 von 16 Slots auf 44 ist + das weniger ausgeprägt; gemessen wird in M4.3/M4.4. +- **Nachbarauflösung** mit Apple-Geräten in lwIP: Lern-Tabelle wie heute in + der Bridge (3.1); ob iOS auf Neighbor Solicitations antwortet, zeigt M4.1. +- **CPU:** Der C5 hat einen Kern. AWDL-Zeitsteuerung, lwIP, USB und BLE teilen + ihn; die AWDL-Task muss Vorrang behalten. +- **OpenDrop unter Windows:** `libarchive`-DLL, Pfade, Zertifikat-Erzeugung + (OpenDrop ruft `openssl` auf). +- **Diagnose:** ohne `awdl0` kein tcpdump; dafür `capture` (3.5) und die + L2-Betriebsart. + +## 9. Entscheidungen, die ich von Ihnen brauche + +| # | Frage | Vorschlag | +|---|---|---| +| 1 | mDNS auf dem Host (über UDP-Proxy) oder auf dem ESP? | **Host:** flexibler, auf dem PC zu debuggen, Firmware bleibt allgemein | +| 2 | Host-App in Python oder Rust? | **Python:** OpenDrop und die 17 Patches sind Python und bleiben unverändert nutzbar | +| 3 | L2-Betriebsart (`awdl0`) in der Firmware behalten? | **Ja**, als Diagnose-Modus; kostet nur die vorhandene Logik | +| 4 | OpenDrop als Bibliothek anbinden oder AirDrop selbst schreiben? | **Anbinden** (5.2); selbst schreiben erst, wenn OpenDrop im Weg steht | +| 5 | Bluetooth immer an oder nur für `wake` starten? | **Erst messen** (M4.1), dann entscheiden | diff --git a/docs/UEBERBLICK.md b/docs/UEBERBLICK.md index a8f6ff9..cc5694a 100644 --- a/docs/UEBERBLICK.md +++ b/docs/UEBERBLICK.md @@ -205,6 +205,7 @@ ausgeschaltetem PC-Bluetooth erfolgreich (2026-10-23). M3 ist damit erledigt. **M4 – Windows (Option C):** lwIP + mDNS auf dem ESP, Socket-Proxy-Protokoll, Host-App (z. B. Python oder Rust) mit OpenDrop-Logik inkl. iOS-26-Patches. +Entwurf (auch für Linux, mit derselben Firmware): `docs/M4_ENTWURF.md`. **M5 – C5 / 5 GHz und Durchsatz:** Kanäle 44/149, Strategie für die Kanalsequenz (verbatim/widen), höhere TX-Rate (HT-MCS statt 12 Mbit/s OFDM). From 1bd1c52f5250edac1b2830b2dcc6622cf2ad2583 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 23 Oct 2026 12:47:08 +0000 Subject: [PATCH 65/99] feat(m4.1): the AWDL link as an lwIP interface, IP mode, HELLO, ping6 First stage of docs/M4_ENTWURF.md: the ESP can terminate IPv6 itself. - main/awdl_netif.c: raw lwIP netif on the AWDL data path (input from the radio deliver path, output into awdl_app_tx_ethernet), the device MAC with its EUI-64 link-local (no DAD, no router solicitations), ff02::fb and the solicited-node group joined, peers resolved from a table learned from received traffic with lwIP's ND only as the fallback, and an ICMPv6 echo client whose replies come back as log lines. - bridge: modes l2 (as before) and ip, chosen by HELLO / HELLO_REPLY (types 9/10, protocol 1) or `mode`; in ip, received frames go to lwIP and host frames are dropped so two stacks never send with one address. New `ip` and `ping6 [n]` commands. - awdl_app_tx_ethernet: mutex around its staging buffer, now that the lwIP thread calls it too. - awdl_bridge.py sends HELLO l2 at start (a device left in ip returns to l2) and shows the HELLO_REPLY; espdrop_link.py gains the HELLO helpers; the control socket passes mode/ip/ping6; espdrop_cmd.py reads a busy reply even after the bridge closed early. C5 and C3 bridge and C5 console build; host side tested against a fake device. The lwIP path is untested on hardware. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01XzgaN4Bv1m8Ka9iqo7QVUN --- docs/SPIKES.md | 25 ++ firmware/spikes/main/CMakeLists.txt | 3 +- firmware/spikes/main/awdl_app.c | 16 +- firmware/spikes/main/awdl_netif.c | 384 ++++++++++++++++++++++++++++ firmware/spikes/main/awdl_netif.h | 52 ++++ firmware/spikes/main/bridge.c | 84 +++++- firmware/spikes/main/bridge.h | 11 + tools/awdl_bridge.py | 22 +- tools/espdrop_cmd.py | 5 +- tools/espdrop_link.py | 22 ++ 10 files changed, 610 insertions(+), 14 deletions(-) create mode 100644 firmware/spikes/main/awdl_netif.c create mode 100644 firmware/spikes/main/awdl_netif.h diff --git a/docs/SPIKES.md b/docs/SPIKES.md index 0d5257b..75025b1 100644 --- a/docs/SPIKES.md +++ b/docs/SPIKES.md @@ -287,6 +287,31 @@ Fehlersuche, wenn kein Empfänger gefunden wird (Gerät bereit, Wecksignal läuf Gesendet wird in beiden Fällen nur in den Slots auf unserem Kanal. `node` zeigt den Modus (`mode=`) und beide Sequenzen. +## M4.1: eigener IP-Stack auf dem ESP (IP-Betriebsart) + +Entwurf: `docs/M4_ENTWURF.md`. Die Bridge-Firmware kennt zwei Betriebsarten: +`l2` (wie bisher: Frames an `awdl0` auf dem PC) und `ip` (der ESP beendet IPv6 +selbst, lwIP auf der AWDL-Verbindung). Nach dem Start ist sie in `l2`; +`awdl_bridge.py` schaltet beim Start ausdrücklich auf `l2` (Meldung +`[hello] protocol 1, mode l2, caps 0x03`). + +Test im Bridge-Fenster (`awdl0` bleibt dabei bestehen, bekommt in `ip` aber +nichts; Frames vom PC werden in `ip` verworfen, damit nicht zwei Stacks mit +derselben Adresse senden): +1. `mode ip`: Antwort `mode ip`. +2. `ip`: `up fe80::…` (unsere Link-Local, wie bisher auf `awdl0`), Zähler und + die aus dem Verkehr gelernten Peers (`peers: fe80::…=mac(Alter)`). + iPhone/iPad dafür wach halten (Teilen-Menü offen, gegebenenfalls `wake 60`). +3. `ping6 10`: zehn Echo-Requests vom ESP selbst; die + Antworten erscheinen als `[device] ping6: reply …`, am Ende die Bilanz. +4. `ip` und `stats` noch einmal: Zähler, `heap=`. +5. `mode l2`: zurück; `awdl0` funktioniert wieder wie zuvor. + +`ip`-Zähler: `rx` an lwIP übergeben, `not_ours` (an andere Stationen), +`nomem`/`full` (verworfen: kein Speicher / lwIP-Warteschlange voll), `tx` +gesendet, `fail` (AWDL-Sendewarteschlange voll), `learned` (Ziel aus der +gelernten Tabelle), `nd` (Ziel unbekannt, lwIPs Nachbarsuche), `mcast`. + ## Danach Ergebnisse (auch Fehlschläge) in `docs/UEBERBLICK.md` unter „Offene Fragen" diff --git a/firmware/spikes/main/CMakeLists.txt b/firmware/spikes/main/CMakeLists.txt index 81bf543..2f5b485 100644 --- a/firmware/spikes/main/CMakeLists.txt +++ b/firmware/spikes/main/CMakeLists.txt @@ -26,7 +26,8 @@ message(STATUS "EspDrop: target '${IDF_TARGET}', USB-Serial/JTAG via '${ESPDROP_ idf_component_register( SRCS "app_main.c" "radio.c" "console.c" "awdl_app.c" "bridge.c" "ble_wake.c" + "awdl_netif.c" INCLUDE_DIRS "." REQUIRES awdl esp_wifi esp_event esp_netif nvs_flash console esp_timer esp_hw_support - bt ${ESPDROP_USJ_COMPONENT} ${ESPDROP_USJ_LL_COMPONENT} + bt lwip ${ESPDROP_USJ_COMPONENT} ${ESPDROP_USJ_LL_COMPONENT} ) diff --git a/firmware/spikes/main/awdl_app.c b/firmware/spikes/main/awdl_app.c index 1da79cb..9a21a0a 100644 --- a/firmware/spikes/main/awdl_app.c +++ b/firmware/spikes/main/awdl_app.c @@ -57,7 +57,8 @@ typedef struct { static awdl_node_t s_node; static SemaphoreHandle_t s_lock; /* guards s_node */ static QueueHandle_t s_rx_q; -static QueueHandle_t s_tx_q; /* tx_data_t: bridge RX task -> awdl task */ +static QueueHandle_t s_tx_q; /* tx_data_t: bridge RX task / lwIP -> awdl task */ +static SemaphoreHandle_t s_tx_stage_lock; /* guards the staging buffer in tx_ethernet */ static TaskHandle_t s_task; static bool s_started; static char s_name[AWDL_NAME_MAX + 1]; /* kept for awdl_app_set_self */ @@ -254,18 +255,22 @@ static void awdl_task(void *arg) { int awdl_app_tx_ethernet(const uint8_t *eth, size_t len, void *ctx) { (void)ctx; - /* Called only from the single bridge RX task, so a static staging buffer is - * safe and keeps this large struct off that task's stack. */ + /* Called from the bridge RX task (L2 mode) and the lwIP thread (IP mode); + * a static staging buffer keeps this large struct off both stacks, the + * mutex keeps the two from writing it at once around a mode switch. */ static tx_data_t td; - if (!s_started || !s_tx_q || len < 14) return -1; + if (!s_started || !s_tx_q || !s_tx_stage_lock || len < 14) return -1; uint16_t et = (uint16_t)(((uint16_t)eth[12] << 8) | eth[13]); if (et != ETHERTYPE_IPV6) return 0; /* awdl0 is IPv6-only; ignore the rest */ size_t plen = len - 14; if (plen > TX_PAYLOAD_MAX) return -1; + xSemaphoreTake(s_tx_stage_lock, portMAX_DELAY); memcpy(td.dst, eth, 6); td.len = (uint16_t)plen; if (plen) memcpy(td.ip, eth + 14, plen); - if (xQueueSend(s_tx_q, &td, 0) != pdTRUE) { s_tx_q_drop++; return -1; } /* full */ + BaseType_t ok = xQueueSend(s_tx_q, &td, 0); + xSemaphoreGive(s_tx_stage_lock); + if (ok != pdTRUE) { s_tx_q_drop++; return -1; } /* full */ s_tx_queued++; xTaskNotifyGive(s_task); /* flush now if we are inside a present slot */ return 0; @@ -295,6 +300,7 @@ int awdl_app_start(const uint8_t self_mac[6], const char *name, uint8_t chan) { s_lock = xSemaphoreCreateMutex(); s_rx_q = xQueueCreate(RX_Q_LEN, sizeof(rx_event_t)); s_tx_q = xQueueCreate(TX_Q_LEN, sizeof(tx_data_t)); + s_tx_stage_lock = xSemaphoreCreateMutex(); if (!s_lock || !s_rx_q || !s_tx_q) return -1; if (name) strncpy(s_name, name, sizeof(s_name) - 1); diff --git a/firmware/spikes/main/awdl_netif.c b/firmware/spikes/main/awdl_netif.c new file mode 100644 index 0000000..67776da --- /dev/null +++ b/firmware/spikes/main/awdl_netif.c @@ -0,0 +1,384 @@ +/* + * EspDrop - the AWDL link as an lwIP network interface. See awdl_netif.h. + * + * Threads: awdl_netif_input runs on the Wi-Fi task and only copies the frame + * into a pbuf and posts it to lwIP (tcpip_input). Everything that touches lwIP + * state (bring-up, output, the ping) runs on the tcpip thread. The learned + * neighbour table is shared between the two and guarded by a spinlock. + * + * SPDX-License-Identifier: GPL-3.0-or-later + * Copyright (C) 2026 EspDrop contributors + */ +#include "awdl_netif.h" + +#include +#include + +#include "esp_timer.h" +#include "freertos/FreeRTOS.h" +#include "freertos/semphr.h" +#include "lwip/ethip6.h" +#include "lwip/ip6.h" +#include "lwip/ip6_addr.h" +#include "lwip/mld6.h" +#include "lwip/netif.h" +#include "lwip/pbuf.h" +#include "lwip/prot/icmp6.h" +#include "lwip/raw.h" +#include "lwip/tcpip.h" +#include "lwip/timeouts.h" +#include "netif/ethernet.h" + +#include "awdl_app.h" /* awdl_app_tx_ethernet: the AWDL data TX path */ +#include "bridge.h" /* bridge_log: ping results go to the host log */ + +#ifndef ETHERTYPE_IPV6 +#define ETHERTYPE_IPV6 0x86dd +#endif +#define NB_SLOTS 16 /* learned peers; AirDrop talks to one or two */ + +static struct netif s_nif; +static bool s_created; +static volatile bool s_up; +static uint8_t s_mac[6]; +static char s_ll_str[48]; /* our link-local, for `ip` */ + +/* Counters, read by `ip`. Aligned 32-bit, stats only. */ +static volatile uint32_t s_rx_in, s_rx_not_ours, s_rx_nomem, s_rx_full; +static volatile uint32_t s_tx_out, s_tx_fail, s_tx_learned, s_tx_nd, s_tx_mcast; + +/* Learned neighbours: source link-local -> source MAC of received frames. */ +typedef struct { + bool used; + uint8_t ip[16]; + uint8_t mac[6]; + int64_t seen_us; +} nb_entry_t; +static nb_entry_t s_nb[NB_SLOTS]; +static portMUX_TYPE s_nb_lock = portMUX_INITIALIZER_UNLOCKED; + +static void nb_learn(const uint8_t ip[16], const uint8_t mac[6], int64_t now) { + portENTER_CRITICAL(&s_nb_lock); + int slot = -1, oldest = 0; + for (int i = 0; i < NB_SLOTS; i++) { + if (s_nb[i].used && memcmp(s_nb[i].ip, ip, 16) == 0) { slot = i; break; } + if (!s_nb[i].used && slot < 0) slot = i; + if (s_nb[i].used && s_nb[i].seen_us < s_nb[oldest].seen_us) oldest = i; + } + if (slot < 0) slot = oldest; /* full: replace the least recently heard */ + s_nb[slot].used = true; + memcpy(s_nb[slot].ip, ip, 16); + memcpy(s_nb[slot].mac, mac, 6); + s_nb[slot].seen_us = now; + portEXIT_CRITICAL(&s_nb_lock); +} + +static bool nb_lookup(const uint8_t ip[16], uint8_t mac[6]) { + bool found = false; + portENTER_CRITICAL(&s_nb_lock); + for (int i = 0; i < NB_SLOTS && !found; i++) + if (s_nb[i].used && memcmp(s_nb[i].ip, ip, 16) == 0) { + memcpy(mac, s_nb[i].mac, 6); + found = true; + } + portEXIT_CRITICAL(&s_nb_lock); + return found; +} + +/* ---- lwIP side (tcpip thread) ------------------------------------------ */ + +static err_t linkoutput(struct netif *nif, struct pbuf *p) { + (void)nif; + static uint8_t frame[14 + AWDL_NETIF_MTU]; /* tcpip thread only */ + if (!s_up || p->tot_len > sizeof(frame)) { s_tx_fail++; return ERR_IF; } + pbuf_copy_partial(p, frame, p->tot_len, 0); + if (awdl_app_tx_ethernet(frame, p->tot_len, NULL) != 0) { + s_tx_fail++; /* AWDL TX queue full: TCP retransmits, ICMP/UDP is lost */ + return ERR_IF; + } + s_tx_out++; + return ERR_OK; +} + +static err_t output_ip6(struct netif *nif, struct pbuf *p, const ip6_addr_t *dst) { + struct eth_addr to; + if (ip6_addr_ismulticast(dst)) { + to.addr[0] = 0x33; /* RFC 2464: 33:33 + low 32 bits of the group */ + to.addr[1] = 0x33; + memcpy(&to.addr[2], &dst->addr[3], 4); + s_tx_mcast++; + } else if (nb_lookup((const uint8_t *)dst->addr, to.addr)) { + s_tx_learned++; + } else { + s_tx_nd++; /* never heard from it: lwIP's neighbour discovery */ + return ethip6_output(nif, p, dst); + } + return ethernet_output(nif, p, (const struct eth_addr *)nif->hwaddr, &to, ETHTYPE_IPV6); +} + +#if LWIP_IPV4 +static err_t output_ip4(struct netif *nif, struct pbuf *p, const ip4_addr_t *dst) { + (void)nif; (void)p; (void)dst; + return ERR_RTE; /* AWDL carries IPv6 only */ +} +#endif + +static err_t netif_init_cb(struct netif *nif) { + nif->name[0] = 'a'; + nif->name[1] = 'w'; + nif->hwaddr_len = ETH_HWADDR_LEN; + memcpy(nif->hwaddr, s_mac, 6); + nif->mtu = AWDL_NETIF_MTU; +#if LWIP_ND6_ALLOW_RA_UPDATES + nif->mtu6 = AWDL_NETIF_MTU; +#endif + nif->flags = NETIF_FLAG_BROADCAST | NETIF_FLAG_ETHERNET | NETIF_FLAG_MLD6; + nif->linkoutput = linkoutput; + nif->output_ip6 = output_ip6; +#if LWIP_IPV4 + nif->output = output_ip4; +#endif + return ERR_OK; +} + +/* One bring-up/down request at a time (only the bridge task asks), static so a + * late-running callback after a timeout never writes into a dead stack frame. */ +typedef struct { + bool up; + int err; + SemaphoreHandle_t done; +} updown_t; +static updown_t s_req; + +static void join(struct netif *nif, const ip6_addr_t *group) { + ip6_addr_t g; + ip6_addr_copy(g, *group); + ip6_addr_assign_zone(&g, IP6_MULTICAST, nif); + mld6_joingroup_netif(nif, &g); +} + +static void do_updown(void *arg) { + updown_t *u = arg; + u->err = 0; + if (!s_created) { + if (!netif_add_noaddr(&s_nif, NULL, netif_init_cb, tcpip_input)) { + u->err = -1; + xSemaphoreGive(u->done); + return; + } + /* EUI-64 link-local from the MAC, as Apple's awdl0 and our host bridge + * (addr_gen_mode=0) use; valid at once: no DAD on this link. */ + netif_create_ip6_linklocal_address(&s_nif, 1); + netif_ip6_addr_set_state(&s_nif, 0, IP6_ADDR_PREFERRED); + ip6addr_ntoa_r(netif_ip6_addr(&s_nif, 0), s_ll_str, sizeof(s_ll_str)); + s_created = true; + } + if (u->up && !netif_is_up(&s_nif)) { + netif_set_link_up(&s_nif); + netif_set_up(&s_nif); +#if LWIP_IPV6_SEND_ROUTER_SOLICIT + s_nif.rs_count = 0; /* no routers on AWDL; save the airtime */ +#endif + ip6_addr_t g; + ip6_addr_set_solicitednode(&g, netif_ip6_addr(&s_nif, 0)->addr[3]); + join(&s_nif, &g); /* our solicited-node group, for NS from peers */ + IP6_ADDR(&g, PP_HTONL(0xff020000UL), 0, 0, PP_HTONL(0x000000fbUL)); + join(&s_nif, &g); /* ff02::fb, mDNS */ + } else if (!u->up && netif_is_up(&s_nif)) { + netif_set_down(&s_nif); + netif_set_link_down(&s_nif); + } + s_up = u->up; + xSemaphoreGive(u->done); +} + +int awdl_netif_set_up(const uint8_t mac[6], bool up) { + if (!s_created) memcpy(s_mac, mac, 6); + if (!s_req.done && !(s_req.done = xSemaphoreCreateBinary())) return -1; + xSemaphoreTake(s_req.done, 0); /* drop a give left over from a timed-out call */ + s_req.up = up; + s_req.err = 0; + if (tcpip_callback(do_updown, &s_req) != ERR_OK || + xSemaphoreTake(s_req.done, pdMS_TO_TICKS(2000)) != pdTRUE) + return -1; + return s_req.err; +} + +bool awdl_netif_is_up(void) { return s_up; } + +/* ---- receive (Wi-Fi task) ---------------------------------------------- */ + +void awdl_netif_input(const uint8_t dst[6], const uint8_t src[6], uint16_t ethertype, + const uint8_t *payload, size_t plen) { + if (!s_up || ethertype != ETHERTYPE_IPV6 || plen < 40 || plen > AWDL_NETIF_MTU) + return; + /* The radio hears every AWDL data frame; keep only ours and group frames. */ + if (!(dst[0] & 0x01) && memcmp(dst, s_mac, 6) != 0) { + s_rx_not_ours++; + return; + } + /* Learn the sender: unicast MAC, link-local source (fe80::/10). */ + const uint8_t *sip = payload + 8; + if (!(src[0] & 0x01) && sip[0] == 0xfe && (sip[1] & 0xc0) == 0x80) + nb_learn(sip, src, esp_timer_get_time()); + + struct pbuf *p = pbuf_alloc(PBUF_RAW, (u16_t)(14 + plen), PBUF_RAM); + if (!p) { s_rx_nomem++; return; } + uint8_t *q = p->payload; + memcpy(q, dst, 6); + memcpy(q + 6, src, 6); + q[12] = (uint8_t)(ethertype >> 8); + q[13] = (uint8_t)(ethertype & 0xff); + memcpy(q + 14, payload, plen); + if (s_nif.input(p, &s_nif) != ERR_OK) { /* tcpip_input: mailbox full */ + pbuf_free(p); + s_rx_full++; + return; + } + s_rx_in++; +} + +/* ---- status --------------------------------------------------------------- */ + +int awdl_netif_format_status(char *buf, size_t cap) { + int o = snprintf(buf, cap, + "%s %s rx=%u not_ours=%u nomem=%u full=%u " + "tx=%u fail=%u learned=%u nd=%u mcast=%u peers:", + s_up ? "up" : "down", s_created ? s_ll_str : "-", + (unsigned)s_rx_in, (unsigned)s_rx_not_ours, (unsigned)s_rx_nomem, + (unsigned)s_rx_full, (unsigned)s_tx_out, (unsigned)s_tx_fail, + (unsigned)s_tx_learned, (unsigned)s_tx_nd, (unsigned)s_tx_mcast); + nb_entry_t nb[NB_SLOTS]; + portENTER_CRITICAL(&s_nb_lock); + memcpy(nb, s_nb, sizeof(nb)); + portEXIT_CRITICAL(&s_nb_lock); + int64_t now = esp_timer_get_time(); + int shown = 0; + for (int i = 0; i < NB_SLOTS && o > 0 && (size_t)o < cap; i++) { + if (!nb[i].used) continue; + if (++shown > 4) { + o += snprintf(buf + o, cap - (size_t)o, " ..."); + break; + } + ip6_addr_t a; + memcpy(a.addr, nb[i].ip, 16); + char s[48]; + ip6addr_ntoa_r(&a, s, sizeof(s)); + o += snprintf(buf + o, cap - (size_t)o, " %s=%02x:%02x:%02x:%02x:%02x:%02x(%us)", s, + nb[i].mac[0], nb[i].mac[1], nb[i].mac[2], nb[i].mac[3], + nb[i].mac[4], nb[i].mac[5], (unsigned)((now - nb[i].seen_us) / 1000000)); + } + if (!shown && o > 0 && (size_t)o < cap) o += snprintf(buf + o, cap - (size_t)o, " none"); + return o; +} + +/* ---- ping6 (tcpip thread) ---------------------------------------------- */ + +#define PING_ID 0xed04 +#define PING_DATA 32 /* bytes after the 8-byte echo header */ +#define PING_WAIT_MS 2000 /* after the last request, for late replies */ + +static struct raw_pcb *s_ping_pcb; +static volatile bool s_ping_busy; +static ip6_addr_t s_ping_dst; +static int s_ping_left, s_ping_sent, s_ping_recv; +static uint16_t s_ping_seq; +static char s_ping_dst_str[48]; + +static u8_t ping_recv(void *arg, struct raw_pcb *pcb, struct pbuf *p, const ip_addr_t *addr) { + (void)arg; (void)pcb; (void)addr; + uint8_t h[8 + 8]; /* echo header + our send timestamp */ + if (p->tot_len < IP6_HLEN + sizeof(h) || + pbuf_copy_partial(p, h, sizeof(h), IP6_HLEN) != sizeof(h)) + return 0; + if (h[0] != ICMP6_TYPE_EREP || ((h[4] << 8) | h[5]) != PING_ID) return 0; + int64_t sent; + memcpy(&sent, h + 8, sizeof(sent)); + int64_t rtt = esp_timer_get_time() - sent; + uint8_t hlim = 0; + pbuf_copy_partial(p, &hlim, 1, 7); + s_ping_recv++; + bridge_log("ping6: reply from %s seq %u time %u.%u ms hlim %u", s_ping_dst_str, + (unsigned)((h[6] << 8) | h[7]), (unsigned)(rtt / 1000), + (unsigned)((rtt % 1000) / 100), hlim); + pbuf_free(p); + return 1; /* ours: eaten */ +} + +static void ping_done(void *arg) { + (void)arg; + bridge_log("ping6: %s %d sent, %d replies, %d%% loss", s_ping_dst_str, s_ping_sent, + s_ping_recv, s_ping_sent ? 100 * (s_ping_sent - s_ping_recv) / s_ping_sent : 0); + s_ping_busy = false; +} + +static void ping_send(void *arg) { + (void)arg; + if (s_ping_left <= 0) { + sys_timeout(PING_WAIT_MS, ping_done, NULL); + return; + } + struct pbuf *p = pbuf_alloc(PBUF_IP, 8 + PING_DATA, PBUF_RAM); + if (p) { + uint8_t *q = p->payload; + memset(q, 0, 8 + PING_DATA); + q[0] = ICMP6_TYPE_EREQ; + q[4] = PING_ID >> 8; + q[5] = PING_ID & 0xff; + q[6] = (uint8_t)(s_ping_seq >> 8); + q[7] = (uint8_t)s_ping_seq; + int64_t now = esp_timer_get_time(); + memcpy(q + 8, &now, sizeof(now)); + ip_addr_t to; + ip_addr_copy_from_ip6(to, s_ping_dst); + err_t e = raw_sendto(s_ping_pcb, p, &to); /* lwIP fills the checksum */ + pbuf_free(p); + if (e != ERR_OK) bridge_log("ping6: send seq %u failed (%d)", s_ping_seq, e); + } + s_ping_sent++; + s_ping_seq++; + s_ping_left--; + sys_timeout(1000, ping_send, NULL); +} + +static void ping_start(void *arg) { + (void)arg; + if (!s_ping_pcb) { + s_ping_pcb = raw_new_ip_type(IPADDR_TYPE_V6, IP6_NEXTH_ICMP6); + if (!s_ping_pcb) { + bridge_log("ping6: no memory for the ICMPv6 socket"); + s_ping_busy = false; + return; + } + s_ping_pcb->chksum_reqd = 1; /* ICMPv6 checksum covers a pseudo-header */ + s_ping_pcb->chksum_offset = 2; + raw_recv(s_ping_pcb, ping_recv, NULL); + raw_bind_netif(s_ping_pcb, &s_nif); + } + ping_send(NULL); +} + +int awdl_netif_ping6(const char *addr, int count, char *err, size_t errcap) { + if (!s_up) { snprintf(err, errcap, "interface down (mode l2?)"); return -1; } + if (s_ping_busy) { snprintf(err, errcap, "a ping is still running"); return -1; } + char tmp[48]; + snprintf(tmp, sizeof(tmp), "%s", addr); + char *pct = strchr(tmp, '%'); + if (pct) *pct = '\0'; + ip6_addr_t a; + if (!ip6addr_aton(tmp, &a)) { snprintf(err, errcap, "not an IPv6 address: %s", tmp); return -1; } + ip6_addr_assign_zone(&a, IP6_UNICAST, &s_nif); /* link-local: this interface */ + if (count < 1) count = 1; + if (count > 100) count = 100; + s_ping_busy = true; + ip6_addr_copy(s_ping_dst, a); + snprintf(s_ping_dst_str, sizeof(s_ping_dst_str), "%s", tmp); + s_ping_left = count; + s_ping_sent = s_ping_recv = 0; + if (tcpip_callback(ping_start, NULL) != ERR_OK) { + s_ping_busy = false; + snprintf(err, errcap, "lwIP busy"); + return -1; + } + return 0; +} diff --git a/firmware/spikes/main/awdl_netif.h b/firmware/spikes/main/awdl_netif.h new file mode 100644 index 0000000..cde4caf --- /dev/null +++ b/firmware/spikes/main/awdl_netif.h @@ -0,0 +1,52 @@ +/* + * EspDrop - the AWDL link as an lwIP network interface (M4, IP mode). + * + * In IP mode the ESP terminates IPv6 itself instead of bridging Ethernet frames + * to the host's awdl0 (docs/M4_ENTWURF.md, section 3.1): + * - the interface carries the device's Wi-Fi MAC and the EUI-64 link-local + * derived from it, as Apple peers expect, and joins ff02::fb for mDNS; + * - received AWDL data frames (radio deliver path) go into lwIP, frames lwIP + * sends go to the slot-gated AWDL data TX path (awdl_app_tx_ethernet); + * - peers are resolved from a table learned from received traffic (source + * link-local -> source MAC), like the host bridge's permanent neighbour + * entries; only unknown destinations fall back to lwIP's neighbour discovery. + * + * SPDX-License-Identifier: GPL-3.0-or-later + * Copyright (C) 2026 EspDrop contributors + */ +#pragma once + +#include +#include +#include + +#ifdef __cplusplus +extern "C" { +#endif + +#define AWDL_NETIF_MTU 1460 /* the most one AWDL data frame carries (awdl0 MTU) */ + +/* Create the interface (once) with this MAC and bring it up or down. While it + * is down it neither sends nor accepts anything. Returns 0 or an error code. */ +int awdl_netif_set_up(const uint8_t mac[6], bool up); + +/* True while the interface is up (IP mode). */ +bool awdl_netif_is_up(void); + +/* Feed one received AWDL MSDU (radio data handler signature, Wi-Fi task + * context; copies). Frames to other stations are dropped, non-IPv6 too. */ +void awdl_netif_input(const uint8_t dst[6], const uint8_t src[6], uint16_t ethertype, + const uint8_t *payload, size_t plen); + +/* One-line status for the `ip` command: address, counters, learned peers. */ +int awdl_netif_format_status(char *buf, size_t cap); + +/* Start `count` ICMPv6 echo requests to addr (text, e.g. "fe80::1"; a "%zone" + * suffix is ignored, the zone is always this interface), one per second. + * Replies and a summary are reported as device log lines. Returns 0, or -1 + * with a reason in err (bad address, interface down, a ping still running). */ +int awdl_netif_ping6(const char *addr, int count, char *err, size_t errcap); + +#ifdef __cplusplus +} +#endif diff --git a/firmware/spikes/main/bridge.c b/firmware/spikes/main/bridge.c index 043e953..1d69b36 100644 --- a/firmware/spikes/main/bridge.c +++ b/firmware/spikes/main/bridge.c @@ -25,6 +25,7 @@ #include "awdl_cobs.h" #include "awdl_app.h" /* awdl_app_resync_peers, for the `resync` command */ #include "ble_wake.h" /* `wake`: the AirDrop wake over our own Bluetooth */ +#include "awdl_netif.h" /* IP mode: the AWDL link as an lwIP interface */ #include "radio.h" /* radio_get_stats, for the `radio` diagnostic command */ #define BRIDGE_MAX_PAYLOAD 1600 @@ -105,9 +106,45 @@ static void send_test_frame(void) { /* Wi-Fi task context: build the Ethernet frame into a scratch slot and enqueue * it for the forwarding task. rx_cb is serialized (one Wi-Fi task), so a single * static scratch buffer is safe and keeps this off the Wi-Fi task's stack. */ +/* Current mode (enum bridge_mode). Read by the Wi-Fi task for every frame. */ +static volatile uint8_t s_mode = BRIDGE_MODE_L2; + +/* Switch L2 <-> IP. Into IP: bring the interface up first, then route frames to + * it. Out of IP: route frames to the host first, then take it down. */ +static int set_mode(uint8_t mode) { + if (mode == s_mode) return 0; + uint8_t mac[6]; + radio_get_self_mac(mac); + if (mode == BRIDGE_MODE_IP) { + if (awdl_netif_set_up(mac, true) != 0) return -1; + s_mode = BRIDGE_MODE_IP; + } else { + s_mode = BRIDGE_MODE_L2; + if (awdl_netif_is_up()) awdl_netif_set_up(mac, false); + } + return 0; +} + +static void send_hello_reply(void) { + uint8_t r[12]; + r[0] = BRIDGE_PROTO_VERSION; + r[1] = s_mode; + radio_get_self_mac(r + 2); + r[8] = 0; /* proxy connections: none yet (M4.3) */ + r[9] = 1400 & 0xff; /* max TCP_DATA chunk, little endian */ + r[10] = 1400 >> 8; + r[11] = (ble_wake_ready() ? 0x01 : 0) /* caps: BLE wake */ + | 0x02; /* IP mode */ + bridge_send(BRIDGE_MSG_HELLO_REPLY, r, sizeof(r)); +} + void bridge_rx_frame(const uint8_t dst[6], const uint8_t src[6], uint16_t ethertype, const uint8_t *payload, size_t plen, void *ctx) { (void)ctx; + if (s_mode == BRIDGE_MODE_IP) { /* the device's own IP stack takes it */ + awdl_netif_input(dst, src, ethertype, payload, plen); + return; + } static rx_frame_t scratch; if (!s_rx_fwd_q) return; if (plen > BRIDGE_RX_PAYLOAD_MAX) { s_rx_drops++; return; } @@ -385,6 +422,39 @@ static void handle_cmd(const uint8_t *p, size_t n) { } } cmd_reply(out); + } else if (strcmp(cmd, "mode") == 0 || strncmp(cmd, "mode ", 5) == 0) { + /* `mode` shows L2 (frames to the host's awdl0) or IP (the device's own + * IPv6 stack); `mode l2` / `mode ip` switch, as HELLO does. */ + const char *arg = cmd[4] == '\0' ? "" : cmd + 5; + int err = 0; + if (strcmp(arg, "ip") == 0) err = set_mode(BRIDGE_MODE_IP); + else if (strcmp(arg, "l2") == 0) err = set_mode(BRIDGE_MODE_L2); + else if (arg[0]) err = 1; + if (err == 1) snprintf(out, sizeof(out), "mode: use `mode`, `mode l2` or `mode ip`"); + else if (err) snprintf(out, sizeof(out), "mode ip: the interface did not come up"); + else snprintf(out, sizeof(out), "mode %s", s_mode == BRIDGE_MODE_IP ? "ip" : "l2"); + cmd_reply(out); + } else if (strcmp(cmd, "ip") == 0) { + /* IP mode: our link-local, RX/TX counters, peers learned from traffic. */ + static char ibuf[480]; /* bridge RX task only */ + int o = snprintf(ibuf, sizeof(ibuf), "ip mode=%s ", + s_mode == BRIDGE_MODE_IP ? "ip" : "l2"); + if (o > 0 && (size_t)o < sizeof(ibuf)) + awdl_netif_format_status(ibuf + o, sizeof(ibuf) - (size_t)o); + cmd_reply(ibuf); + } else if (strncmp(cmd, "ping6 ", 6) == 0) { + /* `ping6 [count]`: ICMPv6 echo from the device's own stack (IP + * mode). Replies come back as log lines, one per second. */ + char addr[48] = ""; + int count = 5; + sscanf(cmd + 6, "%47s %d", addr, &count); + char err[64]; + if (awdl_netif_ping6(addr, count, err, sizeof(err)) == 0) + snprintf(out, sizeof(out), "ping6 %s: %d requests, replies follow as log lines", + addr, count < 1 ? 1 : count > 100 ? 100 : count); + else + snprintf(out, sizeof(out), "ping6: %s", err); + cmd_reply(out); } else if (strcmp(cmd, "checks") == 0) { /* Bring-up checks on a new chip: is the frame length right (FCS where * we expect it, IPv6 packets neither cut short nor padded)? */ @@ -460,12 +530,24 @@ static void handle_msg(const uint8_t *msg, size_t len) { * TX sink (the AWDL data-TX path) if one is registered. */ s_frames_from_host++; s_bytes_from_host += (uint32_t)plen; - if (s_frame_sink) + /* In IP mode the device owns the address; the host's awdl0 must not + * send on it too (ND, MLD, mDNS of a second stack). */ + if (s_frame_sink && s_mode == BRIDGE_MODE_L2) s_frame_sink(payload, plen, s_frame_sink_ctx); break; case BRIDGE_MSG_CMD: handle_cmd(payload, plen); break; + case BRIDGE_MSG_HELLO: + /* Unknown version: answer with ours and stay in L2 (docs/M4_ENTWURF.md + * 4.4); the host reports the mismatch. */ + if (plen >= 2 && payload[0] == BRIDGE_PROTO_VERSION && + payload[1] <= BRIDGE_MODE_IP) + set_mode(payload[1]); + else + set_mode(BRIDGE_MODE_L2); + send_hello_reply(); + break; /* PEER_* are device->host only (see bridge_notify_peer). */ default: break; diff --git a/firmware/spikes/main/bridge.h b/firmware/spikes/main/bridge.h index 887c101..21178ca 100644 --- a/firmware/spikes/main/bridge.h +++ b/firmware/spikes/main/bridge.h @@ -38,8 +38,19 @@ enum bridge_msg_type { BRIDGE_MSG_PEER_REMOVE = 6, /* device -> host: 6-byte peer MAC gone */ BRIDGE_MSG_CMD = 7, /* host -> device: console-style command text */ BRIDGE_MSG_CMD_REPLY = 8, /* device -> host: command output text */ + BRIDGE_MSG_HELLO = 9, /* host -> device: u8 version, u8 mode (M4) */ + BRIDGE_MSG_HELLO_REPLY = 10,/* device -> host: version, mode, mac, limits, caps */ }; +/* docs/M4_ENTWURF.md: L2 bridges Ethernet frames to the host's awdl0 (as up to + * M3); IP terminates IPv6 on the device (awdl_netif). Selected by HELLO or the + * `mode` command; L2 after boot, so older host tools keep working. */ +enum bridge_mode { + BRIDGE_MODE_L2 = 0, + BRIDGE_MODE_IP = 1, +}; +#define BRIDGE_PROTO_VERSION 1 + /* Start the bridge: install the USB driver, silence ESP_LOG (to reduce * corruption of the binary stream; the host resynchronises at the next frame * delimiter after any stray bytes), and spawn the RX task. Returns 0 on success. */ diff --git a/tools/awdl_bridge.py b/tools/awdl_bridge.py index 5e2cd3e..467620a 100644 --- a/tools/awdl_bridge.py +++ b/tools/awdl_bridge.py @@ -26,7 +26,7 @@ # are only logged. Suppressed with --no-config. # # Interactive stdin commands (forwarded to the device): -# mac | stats | radio | tx | node | chan [n|auto] | seq [mirror|verbatim] | wake [secs|off] | checks | usbtest [kib] | ping | testframe | resync +# mac | stats | radio | tx | node | chan [n|auto] | seq [mirror|verbatim] | wake [secs|off] | mode [l2|ip] | ip | ping6 [n] | checks | usbtest [kib] | ping | testframe | resync # # Control socket: other tools (tools/espdrop_cmd.py, opendrop_send.sh for the # Bluetooth wake) send device commands through /run/espdrop/.sock, one @@ -65,7 +65,8 @@ CTL_DIR = "/run/espdrop" # Device commands the control socket passes on (no usbtest: it floods the link). -CTL_COMMANDS = {"wake", "mac", "stats", "radio", "tx", "node", "chan", "seq", "checks", "ping"} +CTL_COMMANDS = {"wake", "mac", "stats", "radio", "tx", "node", "chan", "seq", "checks", "ping", + "mode", "ip", "ping6"} CTL_TIMEOUT = 3.0 # seconds to wait for the device's reply to a socket command @@ -256,17 +257,20 @@ def _stop(*_): print(f"{args.dev} up (mtu {args.mtu}, mac {mac or 'kernel-assigned'}); " f"bridging {args.port}") - print("commands: mac | stats | radio | tx | node | chan [n|auto] | seq [mirror|verbatim] | wake [secs|off] | checks | usbtest [kib] | ping | testframe | resync" + print("commands: mac | stats | radio | tx | node | chan [n|auto] | seq [mirror|verbatim] | wake [secs|off] | mode [l2|ip] | ip | ping6 [n] | checks | usbtest [kib] | ping | testframe | resync" " (Ctrl-C to quit)") ctl, ctl_path = open_ctl(args.dev) # The one socket client whose command is with the device: its conn, # partial input, whether the command went out, and when to give up. ctl_client = None - # The C3 keeps running across host sessions (its USB port does not reset - # the chip), so peers it learned earlier would never be re-announced. Ask - # it to re-emit its current peer set so the [peer+] log is complete. + # The device keeps running across host sessions (its USB port does not + # reset the chip): put it back into L2 in case an M4 host app (or `mode + # ip`) left it in IP mode, where no frames would reach awdl0. Firmware + # without HELLO ignores it. Then ask it to re-emit its current peer set + # so the [peer+] log is complete. try: + link.write_all(usb_fd, link.hello(link.MODE_L2)) link.write_all(usb_fd, link.frame(link.MSG_CMD, b"resync")) except (OSError, TimeoutError) as e: print(f"warning: could not request peer resync ({e})", file=sys.stderr) @@ -369,6 +373,12 @@ def ctl_finish(text: str) -> None: print(f"tap write failed: {e}", file=sys.stderr) elif t == link.MSG_LOG: print(f"[device] {p.decode('utf-8', 'replace')}") + elif t == link.MSG_HELLO_REPLY: + h = link.parse_hello_reply(p) + if h: + print(f"[hello] protocol {h['version']}, mode " + f"{'ip' if h['mode'] == link.MODE_IP else 'l2'}, caps " + f"{h['caps']:#04x}") elif t == link.MSG_CMD_REPLY: reply = p.decode('utf-8', 'replace') print(f"[reply] {reply}") diff --git a/tools/espdrop_cmd.py b/tools/espdrop_cmd.py index be817d2..d92af44 100755 --- a/tools/espdrop_cmd.py +++ b/tools/espdrop_cmd.py @@ -57,7 +57,10 @@ def main() -> int: s.settimeout(args.timeout) try: s.connect(path) - s.sendall(cmd.encode() + b"\n") + try: + s.sendall(cmd.encode() + b"\n") + except (BrokenPipeError, ConnectionResetError): + pass # a busy bridge answers and closes at once; its reply is still readable reply = b"" while not reply.endswith(b"\n"): chunk = s.recv(512) diff --git a/tools/espdrop_link.py b/tools/espdrop_link.py index ef63a47..5a9d21f 100644 --- a/tools/espdrop_link.py +++ b/tools/espdrop_link.py @@ -17,6 +17,7 @@ import os import select +import struct import sys import termios import time @@ -31,6 +32,27 @@ MSG_PEER_REMOVE = 6 # device -> host: 6-byte peer MAC gone MSG_CMD = 7 # host -> device: command text MSG_CMD_REPLY = 8 # device -> host: command output text +MSG_HELLO = 9 # host -> device: u8 version, u8 mode (0 L2, 1 IP), u32 credit +MSG_HELLO_REPLY = 10 # device -> host: version, mode, mac[6], max_conns, max_chunk, caps + +PROTO_VERSION = 1 # BRIDGE_PROTO_VERSION +MODE_L2 = 0 # frames to/from the host's awdl0 (this bridge) +MODE_IP = 1 # the device's own IPv6 stack (M4 host app) + + +def hello(mode: int, credit: int = 0) -> bytes: + """A HELLO message (docs/M4_ENTWURF.md, section 4).""" + return frame(MSG_HELLO, struct.pack(" bytes: From 67a1769afeca4747ac4f1f27e22451eeb1077dfe Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 23 Oct 2026 14:40:36 +0000 Subject: [PATCH 66/99] fix(m4.1): send the first MLD reports, record the first IP-mode ping The interface only counted as up after the multicast joins, so their first MLD reports met a linkoutput that still dropped everything (ip: fail=2). Mark it up before netif_set_up. SPIKES.md: ping6 from the ESP's own stack to the iPad 10/10 at 4-460 ms, the peer learned from traffic; heap in ip mode only ~30 KB because the unused L2 RX queue (40 slots) stays allocated - to be freed in ip mode before TCP. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01XzgaN4Bv1m8Ka9iqo7QVUN --- docs/SPIKES.md | 7 +++++++ firmware/spikes/main/awdl_netif.c | 5 ++++- 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/docs/SPIKES.md b/docs/SPIKES.md index 75025b1..a06d9bc 100644 --- a/docs/SPIKES.md +++ b/docs/SPIKES.md @@ -307,6 +307,13 @@ derselben Adresse senden): 4. `ip` und `stats` noch einmal: Zähler, `heap=`. 5. `mode l2`: zurück; `awdl0` funktioniert wieder wie zuvor. +Ergebnis (2026-10-23, C5, iPad als Master auf Kanal 44): `ping6` vom ESP +10/10 beantwortet, 4–460 ms (Sägezahn: 1 s Ping-Abstand gegen 1,048 s +AWDL-Periode, jede Anfrage wartet auf den nächsten gemeinsamen Slot). Das iPad +wurde aus dem Verkehr gelernt (`learned=10`, `nd=0`). Speicher in `ip`: +`heap=30564`, `heap_min=24016`, weil die L2-Warteschlange (`rxq=40`, ~60 KB) +auch in `ip` belegt bleibt; für TCP (M4.3) muss sie in `ip` frei werden. + `ip`-Zähler: `rx` an lwIP übergeben, `not_ours` (an andere Stationen), `nomem`/`full` (verworfen: kein Speicher / lwIP-Warteschlange voll), `tx` gesendet, `fail` (AWDL-Sendewarteschlange voll), `learned` (Ziel aus der diff --git a/firmware/spikes/main/awdl_netif.c b/firmware/spikes/main/awdl_netif.c index 67776da..75ecfe3 100644 --- a/firmware/spikes/main/awdl_netif.c +++ b/firmware/spikes/main/awdl_netif.c @@ -174,6 +174,9 @@ static void do_updown(void *arg) { s_created = true; } if (u->up && !netif_is_up(&s_nif)) { + /* Up before netif_set_up: the MLD reports for the joins below must not + * meet a linkoutput that still drops everything. */ + s_up = true; netif_set_link_up(&s_nif); netif_set_up(&s_nif); #if LWIP_IPV6_SEND_ROUTER_SOLICIT @@ -188,7 +191,7 @@ static void do_updown(void *arg) { netif_set_down(&s_nif); netif_set_link_down(&s_nif); } - s_up = u->up; + s_up = u->up && netif_is_up(&s_nif); xSemaphoreGive(u->done); } From 86670075e6e1dc2aee1f8c3074a5985c6f99d6c0 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 23 Oct 2026 15:00:57 +0000 Subject: [PATCH 67/99] feat(m4.2): UDP through the ESP, host app skeleton, espdrop find Second stage of docs/M4_ENTWURF.md: the PC finds AirDrop receivers through the ESP's own IP stack, without awdl0, root or a firewall rule. Firmware - bridge: the L2 RX queue (~60 KB at 40 slots) exists only in L2 mode; in IP mode the forward task frees it (it alone deletes it, a mutex keeps the Wi-Fi task out) and it is rebuilt when a host asks for L2 again. - sockproxy.c: UDP_OPEN / UDP_SEND / UDP_CLOSE from the host, UDP_RECV and RESULT to it (types 24-28), raw lwIP UDP on the AWDL interface with hop limit 255, all socket state on the lwIP thread; reset on HELLO and when leaving IP mode. `ip` shows open ports and counters. Host (host/espdrop, Python, Linux and Windows) - link.py: framing and message formats, now shared by tools/espdrop_link.py. - transport.py: pyserial, or a raw POSIX tty without it; finds the ESP by its Espressif USB id. - device.py: HELLO, text commands, UDP ports with per-port handlers. - mdns.py: AirDrop browser - PTR query every 1.5 s, PTR/SRV/TXT/AAAA assembled into receivers, goodbye handled; zeroconf's message classes only. - cli.py + tools/espdrop.py: find (with the ESP's BLE wake), status, cmd. - tests: a simulated ESP answering with iPad-shaped mDNS responses (made-up names and addresses); 18 unit tests, plus find run over a real pty both with the POSIX transport and with pyserial. C5 and C3 bridge and C5 console build. Not yet on hardware. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01XzgaN4Bv1m8Ka9iqo7QVUN --- docs/SPIKES.md | 34 +++++ firmware/spikes/main/CMakeLists.txt | 2 +- firmware/spikes/main/awdl_netif.c | 2 + firmware/spikes/main/awdl_netif.h | 5 + firmware/spikes/main/bridge.c | 97 +++++++++++-- firmware/spikes/main/bridge.h | 6 + firmware/spikes/main/sockproxy.c | 211 ++++++++++++++++++++++++++++ firmware/spikes/main/sockproxy.h | 48 +++++++ host/README.md | 21 +++ host/espdrop/__init__.py | 5 + host/espdrop/__main__.py | 9 ++ host/espdrop/cli.py | 144 +++++++++++++++++++ host/espdrop/device.py | 151 ++++++++++++++++++++ host/espdrop/link.py | 178 +++++++++++++++++++++++ host/espdrop/mdns.py | 207 +++++++++++++++++++++++++++ host/espdrop/transport.py | 116 +++++++++++++++ host/tests/apple_mdns.py | 126 +++++++++++++++++ host/tests/fake_device.py | 107 ++++++++++++++ host/tests/test_host.py | 200 ++++++++++++++++++++++++++ tools/espdrop.py | 18 +++ tools/espdrop_link.py | 121 ++-------------- 21 files changed, 1684 insertions(+), 124 deletions(-) create mode 100644 firmware/spikes/main/sockproxy.c create mode 100644 firmware/spikes/main/sockproxy.h create mode 100644 host/README.md create mode 100644 host/espdrop/__init__.py create mode 100644 host/espdrop/__main__.py create mode 100644 host/espdrop/cli.py create mode 100644 host/espdrop/device.py create mode 100644 host/espdrop/link.py create mode 100644 host/espdrop/mdns.py create mode 100644 host/espdrop/transport.py create mode 100644 host/tests/apple_mdns.py create mode 100644 host/tests/fake_device.py create mode 100644 host/tests/test_host.py create mode 100755 tools/espdrop.py diff --git a/docs/SPIKES.md b/docs/SPIKES.md index a06d9bc..3becb9d 100644 --- a/docs/SPIKES.md +++ b/docs/SPIKES.md @@ -319,6 +319,40 @@ auch in `ip` belegt bleibt; für TCP (M4.3) muss sie in `ip` frei werden. gesendet, `fail` (AWDL-Sendewarteschlange voll), `learned` (Ziel aus der gelernten Tabelle), `nd` (Ziel unbekannt, lwIPs Nachbarsuche), `mcast`. +## M4.2: Host-App, UDP über den ESP, `espdrop find` + +Die Host-App liegt in `host/espdrop/` (Python, Linux und Windows). Sie übernimmt +die serielle Schnittstelle selbst; `tools/awdl_bridge.py` darf dabei **nicht** +laufen. Sie schaltet den ESP in die IP-Betriebsart (`HELLO`), öffnet über ihn +UDP-Port 5353 und sucht per mDNS nach AirDrop-Empfängern. Unter Linux braucht +sie kein root, nur Zugriff auf die Schnittstelle (Gruppe `dialout`, unter Arch +`uucp`), und kein Zusatzpaket; unter Windows `pip install pyserial`. Die +OpenDrop-venv wird für python-zeroconf (Lesen/Schreiben der mDNS-Nachrichten) +gebraucht. + +In der IP-Betriebsart gibt die Firmware die L2-Warteschlange frei (`stats`: +`rxq=0`, `heap=` entsprechend höher); `awdl_bridge.py` legt sie beim Start +wieder an. + +Test (Bridge beenden, Teilen-Menü am iPhone/iPad offen): +```sh +~/opendrop-venv/bin/python tools/espdrop.py status +~/opendrop-venv/bin/python tools/espdrop.py find -t 30 +~/opendrop-venv/bin/python tools/espdrop.py find -t 30 -v # jede AirDrop-mDNS-Nachricht +~/opendrop-venv/bin/python tools/espdrop.py cmd ip +``` +`find` startet das BLE-Wecksignal des ESP für die Suchdauer, fragt alle 1,5 s +nach `_airdrop._tcp.local.` und meldet jeden Empfänger mit Kennung, Adresse, +Port und TXT-`flags`; am Ende schaltet es das Wecksignal ab. Der Port wird +automatisch gefunden (USB-Kennung von Espressif), sonst `-p /dev/ttyACM0` +bzw. `-p COM5`. Danach geht `awdl_bridge.py` wie gewohnt (es schaltet zurück +auf L2). + +Tests ohne Hardware (simulierter ESP mit iPad-Antworten): +```sh +~/opendrop-venv/bin/python -m unittest discover -s host/tests +``` + ## Danach Ergebnisse (auch Fehlschläge) in `docs/UEBERBLICK.md` unter „Offene Fragen" diff --git a/firmware/spikes/main/CMakeLists.txt b/firmware/spikes/main/CMakeLists.txt index 2f5b485..03ab48c 100644 --- a/firmware/spikes/main/CMakeLists.txt +++ b/firmware/spikes/main/CMakeLists.txt @@ -26,7 +26,7 @@ message(STATUS "EspDrop: target '${IDF_TARGET}', USB-Serial/JTAG via '${ESPDROP_ idf_component_register( SRCS "app_main.c" "radio.c" "console.c" "awdl_app.c" "bridge.c" "ble_wake.c" - "awdl_netif.c" + "awdl_netif.c" "sockproxy.c" INCLUDE_DIRS "." REQUIRES awdl esp_wifi esp_event esp_netif nvs_flash console esp_timer esp_hw_support bt lwip ${ESPDROP_USJ_COMPONENT} ${ESPDROP_USJ_LL_COMPONENT} diff --git a/firmware/spikes/main/awdl_netif.c b/firmware/spikes/main/awdl_netif.c index 75ecfe3..45892e9 100644 --- a/firmware/spikes/main/awdl_netif.c +++ b/firmware/spikes/main/awdl_netif.c @@ -209,6 +209,8 @@ int awdl_netif_set_up(const uint8_t mac[6], bool up) { bool awdl_netif_is_up(void) { return s_up; } +struct netif *awdl_netif_get(void) { return s_created ? &s_nif : NULL; } + /* ---- receive (Wi-Fi task) ---------------------------------------------- */ void awdl_netif_input(const uint8_t dst[6], const uint8_t src[6], uint16_t ethertype, diff --git a/firmware/spikes/main/awdl_netif.h b/firmware/spikes/main/awdl_netif.h index cde4caf..427709c 100644 --- a/firmware/spikes/main/awdl_netif.h +++ b/firmware/spikes/main/awdl_netif.h @@ -33,6 +33,11 @@ int awdl_netif_set_up(const uint8_t mac[6], bool up); /* True while the interface is up (IP mode). */ bool awdl_netif_is_up(void); +/* The lwIP interface, NULL before the first awdl_netif_set_up. For code on the + * tcpip thread (sockets bind to it). */ +struct netif; +struct netif *awdl_netif_get(void); + /* Feed one received AWDL MSDU (radio data handler signature, Wi-Fi task * context; copies). Frames to other stations are dropped, non-IPv6 too. */ void awdl_netif_input(const uint8_t dst[6], const uint8_t src[6], uint16_t ethertype, diff --git a/firmware/spikes/main/bridge.c b/firmware/spikes/main/bridge.c index 1d69b36..1554f07 100644 --- a/firmware/spikes/main/bridge.c +++ b/firmware/spikes/main/bridge.c @@ -26,6 +26,7 @@ #include "awdl_app.h" /* awdl_app_resync_peers, for the `resync` command */ #include "ble_wake.h" /* `wake`: the AirDrop wake over our own Bluetooth */ #include "awdl_netif.h" /* IP mode: the AWDL link as an lwIP interface */ +#include "sockproxy.h" /* IP mode: host sockets on that interface */ #include "radio.h" /* radio_get_stats, for the `radio` diagnostic command */ #define BRIDGE_MAX_PAYLOAD 1600 @@ -63,7 +64,14 @@ typedef struct { } peer_evt_t; static SemaphoreHandle_t s_tx_lock; -static QueueHandle_t s_rx_fwd_q; /* rx_frame_t: Wi-Fi task -> fwd task */ +/* rx_frame_t: Wi-Fi task -> fwd task. Exists only in L2 mode: in IP mode its + * ~60 KB go back to the heap for lwIP. The fwd task alone deletes it (it may be + * waiting on it); s_l2q_lock keeps the Wi-Fi task out of a queue being freed. */ +static QueueHandle_t s_rx_fwd_q; +static SemaphoreHandle_t s_l2q_lock; +static SemaphoreHandle_t s_l2q_freed; /* given by the fwd task after a release */ +static volatile bool s_l2q_release; /* asks the fwd task to free the queue */ +static TaskHandle_t s_fwd_task; static QueueHandle_t s_peer_q; /* peer_evt_t: AWDL task -> peer task */ static uint8_t s_rx_q_depth; /* depth actually allocated */ static bool s_started; @@ -109,6 +117,9 @@ static void send_test_frame(void) { /* Current mode (enum bridge_mode). Read by the Wi-Fi task for every frame. */ static volatile uint8_t s_mode = BRIDGE_MODE_L2; +static bool l2q_alloc(void); +static void l2q_release(void); + /* Switch L2 <-> IP. Into IP: bring the interface up first, then route frames to * it. Out of IP: route frames to the host first, then take it down. */ static int set_mode(uint8_t mode) { @@ -118,8 +129,11 @@ static int set_mode(uint8_t mode) { if (mode == BRIDGE_MODE_IP) { if (awdl_netif_set_up(mac, true) != 0) return -1; s_mode = BRIDGE_MODE_IP; + l2q_release(); /* its memory is lwIP's now */ } else { + if (!l2q_alloc()) return -1; /* the host's awdl0 needs it */ s_mode = BRIDGE_MODE_L2; + sockproxy_reset(); if (awdl_netif_is_up()) awdl_netif_set_up(mac, false); } return 0; @@ -154,8 +168,44 @@ void bridge_rx_frame(const uint8_t dst[6], const uint8_t src[6], uint16_t ethert scratch.eth[13] = (uint8_t)(ethertype & 0xff); if (plen) memcpy(scratch.eth + 14, payload, plen); scratch.len = (uint16_t)(14 + plen); - if (xQueueSend(s_rx_fwd_q, &scratch, 0) != pdTRUE) + /* Never wait: the lock is only held around a mode switch. */ + if (xSemaphoreTake(s_l2q_lock, 0) != pdTRUE) { s_rx_drops++; return; } + if (s_rx_fwd_q && xQueueSend(s_rx_fwd_q, &scratch, 0) != pdTRUE) s_rx_drops++; /* host not keeping up: drop rather than stall Wi-Fi */ + xSemaphoreGive(s_l2q_lock); +} + +/* Create the L2 RX queue, as deep as the heap allows with BRIDGE_HEAP_RESERVE + * left over (the smallest depth is always tried). No-op if it exists. */ +static bool l2q_alloc(void) { + if (s_rx_fwd_q) return true; + QueueHandle_t q = NULL; + uint8_t depth = 0; + for (size_t i = 0; i < sizeof(k_rx_q_depths) && !q; i++) { + size_t need = (size_t)k_rx_q_depths[i] * sizeof(rx_frame_t); + bool last = (i + 1 == sizeof(k_rx_q_depths)); + if (!last && esp_get_free_heap_size() < need + BRIDGE_HEAP_RESERVE) + continue; /* keep headroom for Wi-Fi */ + q = xQueueCreate(k_rx_q_depths[i], sizeof(rx_frame_t)); + if (q) depth = k_rx_q_depths[i]; + } + if (!q) return false; + xSemaphoreTake(s_l2q_lock, portMAX_DELAY); + s_rx_fwd_q = q; + s_rx_q_depth = depth; + xSemaphoreGive(s_l2q_lock); + if (s_fwd_task) xTaskNotifyGive(s_fwd_task); + return true; +} + +/* Hand the L2 RX queue's memory back (IP mode). The fwd task frees it within + * one receive timeout; returns once it has, or after 1 s. */ +static void l2q_release(void) { + if (!s_rx_fwd_q) return; + xSemaphoreTake(s_l2q_freed, 0); /* drop a give left over from a timeout */ + s_l2q_release = true; + if (s_fwd_task) xTaskNotifyGive(s_fwd_task); + xSemaphoreTake(s_l2q_freed, pdMS_TO_TICKS(1000)); } /* Drain the RX-forward queue to the host. A dedicated task so the (bounded but @@ -164,7 +214,22 @@ static void bridge_fwd_task(void *arg) { (void)arg; static rx_frame_t fr; for (;;) { - if (xQueueReceive(s_rx_fwd_q, &fr, portMAX_DELAY) == pdTRUE) { + if (s_l2q_release) { + xSemaphoreTake(s_l2q_lock, portMAX_DELAY); + if (s_rx_fwd_q) vQueueDelete(s_rx_fwd_q); /* frames still in it are dropped */ + s_rx_fwd_q = NULL; + s_rx_q_depth = 0; + xSemaphoreGive(s_l2q_lock); + s_l2q_release = false; + xSemaphoreGive(s_l2q_freed); + } + QueueHandle_t q = s_rx_fwd_q; /* only this task deletes it */ + if (!q) { + ulTaskNotifyTake(pdTRUE, pdMS_TO_TICKS(100)); /* IP mode: nothing to do */ + continue; + } + /* A timeout, not forever, so a release request is seen promptly. */ + if (xQueueReceive(q, &fr, pdMS_TO_TICKS(50)) == pdTRUE) { if (bridge_send(BRIDGE_MSG_FRAME, fr.eth, fr.len) == 0) { s_frames_to_host++; s_bytes_to_host += fr.len; @@ -440,7 +505,12 @@ static void handle_cmd(const uint8_t *p, size_t n) { int o = snprintf(ibuf, sizeof(ibuf), "ip mode=%s ", s_mode == BRIDGE_MODE_IP ? "ip" : "l2"); if (o > 0 && (size_t)o < sizeof(ibuf)) - awdl_netif_format_status(ibuf + o, sizeof(ibuf) - (size_t)o); + o += awdl_netif_format_status(ibuf + o, sizeof(ibuf) - (size_t)o); + if (o > 0 && (size_t)o + 2 < sizeof(ibuf)) { + ibuf[o++] = ' '; + ibuf[o] = '\0'; + sockproxy_format_status(ibuf + o, sizeof(ibuf) - (size_t)o); + } cmd_reply(ibuf); } else if (strncmp(cmd, "ping6 ", 6) == 0) { /* `ping6 [count]`: ICMPv6 echo from the device's own stack (IP @@ -546,8 +616,14 @@ static void handle_msg(const uint8_t *msg, size_t len) { set_mode(payload[1]); else set_mode(BRIDGE_MODE_L2); + sockproxy_reset(); /* a new host session starts with no sockets */ send_hello_reply(); break; + case BRIDGE_MSG_UDP_OPEN: + case BRIDGE_MSG_UDP_SEND: + case BRIDGE_MSG_UDP_CLOSE: + sockproxy_handle(type, payload, plen); + break; /* PEER_* are device->host only (see bridge_notify_peer). */ default: break; @@ -590,14 +666,9 @@ int bridge_start(void) { return e; } - for (size_t i = 0; i < sizeof(k_rx_q_depths) && !s_rx_fwd_q; i++) { - size_t need = (size_t)k_rx_q_depths[i] * sizeof(rx_frame_t); - bool last = (i + 1 == sizeof(k_rx_q_depths)); - if (!last && esp_get_free_heap_size() < need + BRIDGE_HEAP_RESERVE) - continue; /* keep headroom for Wi-Fi; the smallest depth is always tried */ - s_rx_fwd_q = xQueueCreate(k_rx_q_depths[i], sizeof(rx_frame_t)); - if (s_rx_fwd_q) s_rx_q_depth = k_rx_q_depths[i]; - } + s_l2q_lock = xSemaphoreCreateMutex(); + s_l2q_freed = xSemaphoreCreateBinary(); + if (s_l2q_lock && s_l2q_freed) l2q_alloc(); /* L2 after boot */ s_peer_q = xQueueCreate(BRIDGE_PEER_Q_LEN, sizeof(peer_evt_t)); if (!s_rx_fwd_q || !s_peer_q) { if (s_rx_fwd_q) vQueueDelete(s_rx_fwd_q); @@ -635,7 +706,7 @@ int bridge_start(void) { } /* The forwarding task is best-effort: if it fails to start, RX frames are * dropped (queue fills) but the control link stays up, so do not fail. */ - if (xTaskCreate(bridge_fwd_task, "bridge_fwd", 3072, NULL, 5, NULL) != pdPASS) + if (xTaskCreate(bridge_fwd_task, "bridge_fwd", 3072, NULL, 5, &s_fwd_task) != pdPASS) bridge_log("warning: rx-forward task did not start"); if (xTaskCreate(bridge_peer_task, "bridge_peer", 2560, NULL, 4, NULL) != pdPASS) bridge_log("warning: peer-notify task did not start"); diff --git a/firmware/spikes/main/bridge.h b/firmware/spikes/main/bridge.h index 21178ca..de322de 100644 --- a/firmware/spikes/main/bridge.h +++ b/firmware/spikes/main/bridge.h @@ -40,6 +40,12 @@ enum bridge_msg_type { BRIDGE_MSG_CMD_REPLY = 8, /* device -> host: command output text */ BRIDGE_MSG_HELLO = 9, /* host -> device: u8 version, u8 mode (M4) */ BRIDGE_MSG_HELLO_REPLY = 10,/* device -> host: version, mode, mac, limits, caps */ + /* Socket proxy, IP mode (docs/M4_ENTWURF.md section 4, main/sockproxy.c) */ + BRIDGE_MSG_UDP_OPEN = 24, /* host -> device: u16 port, u8 flags */ + BRIDGE_MSG_UDP_RECV = 25, /* device -> host: u16 port, src[16], u16 sport, u8 flags, data */ + BRIDGE_MSG_UDP_SEND = 26, /* host -> device: u16 port, dst[16], u16 dport, data */ + BRIDGE_MSG_UDP_CLOSE = 27, /* host -> device: u16 port */ + BRIDGE_MSG_RESULT = 28, /* device -> host: u8 request type, u16 id, i8 err */ }; /* docs/M4_ENTWURF.md: L2 bridges Ethernet frames to the host's awdl0 (as up to diff --git a/firmware/spikes/main/sockproxy.c b/firmware/spikes/main/sockproxy.c new file mode 100644 index 0000000..5d6fae8 --- /dev/null +++ b/firmware/spikes/main/sockproxy.c @@ -0,0 +1,211 @@ +/* + * EspDrop - socket proxy between the host and the device's own IP stack. See + * sockproxy.h. Wire formats: main/bridge.h, docs/M4_ENTWURF.md section 4.1. + * + * SPDX-License-Identifier: GPL-3.0-or-later + * Copyright (C) 2026 EspDrop contributors + */ +#include "sockproxy.h" + +#include +#include +#include + +#include "freertos/FreeRTOS.h" +#include "freertos/semphr.h" +#include "lwip/ip6_addr.h" +#include "lwip/pbuf.h" +#include "lwip/tcpip.h" +#include "lwip/udp.h" + +#include "awdl_netif.h" +#include "bridge.h" + +#define UDP_SLOTS 2 /* mDNS (5353) and one spare */ +#define UDP_HDR_RECV (2 + 16 + 2 + 1) +#define UDP_HDR_SEND (2 + 16 + 2) +#define UDP_MAX_DATA AWDL_NETIF_MTU /* nothing bigger crosses AWDL anyway */ + +typedef struct { + struct udp_pcb *pcb; + uint16_t port; +} udp_slot_t; +static udp_slot_t s_udp[UDP_SLOTS]; /* lwIP thread only */ + +/* Counters for `ip`. Aligned 32-bit, stats only. */ +static volatile uint32_t s_udp_rx, s_udp_tx, s_udp_err; + +/* A host message, copied for the lwIP thread. */ +typedef struct { + uint8_t type; + size_t len; + uint8_t data[]; +} job_t; + +static void send_result(uint8_t req, uint16_t id, int8_t err) { + uint8_t r[4] = { req, (uint8_t)(id & 0xff), (uint8_t)(id >> 8), (uint8_t)err }; + bridge_send(BRIDGE_MSG_RESULT, r, sizeof(r)); +} + +static udp_slot_t *slot_for(uint16_t port) { + for (int i = 0; i < UDP_SLOTS; i++) + if (s_udp[i].pcb && s_udp[i].port == port) return &s_udp[i]; + return NULL; +} + +static void close_slot(udp_slot_t *s) { + if (s->pcb) udp_remove(s->pcb); + s->pcb = NULL; + s->port = 0; +} + +/* lwIP thread: a datagram for one of our ports -> UDP_RECV to the host. */ +static void udp_rx(void *arg, struct udp_pcb *pcb, struct pbuf *p, + const ip_addr_t *addr, u16_t port) { + (void)pcb; + udp_slot_t *s = arg; + static uint8_t msg[UDP_HDR_RECV + UDP_MAX_DATA]; /* lwIP thread only */ + if (!IP_IS_V6(addr) || p->tot_len > UDP_MAX_DATA) { + s_udp_err++; + pbuf_free(p); + return; + } + msg[0] = (uint8_t)(s->port & 0xff); + msg[1] = (uint8_t)(s->port >> 8); + memcpy(msg + 2, ip_2_ip6(addr)->addr, 16); + msg[18] = (uint8_t)(port & 0xff); + msg[19] = (uint8_t)(port >> 8); + msg[20] = ip6_addr_ismulticast(ip_2_ip6(ip_current_dest_addr())) ? 0x01 : 0x00; + pbuf_copy_partial(p, msg + UDP_HDR_RECV, p->tot_len, 0); + size_t n = UDP_HDR_RECV + p->tot_len; + pbuf_free(p); + if (bridge_send(BRIDGE_MSG_UDP_RECV, msg, n) == 0) s_udp_rx++; + else s_udp_err++; +} + +static void do_open(const uint8_t *d, size_t n) { + if (n < 3) { send_result(BRIDGE_MSG_UDP_OPEN, 0, SOCKPROXY_EBADREQ); return; } + uint16_t port = (uint16_t)(d[0] | (d[1] << 8)); + struct netif *nif = awdl_netif_get(); + if (!nif || !awdl_netif_is_up()) { send_result(BRIDGE_MSG_UDP_OPEN, port, SOCKPROXY_EDOWN); return; } + if (slot_for(port)) { send_result(BRIDGE_MSG_UDP_OPEN, port, SOCKPROXY_OK); return; } + udp_slot_t *s = NULL; + for (int i = 0; i < UDP_SLOTS && !s; i++) + if (!s_udp[i].pcb) s = &s_udp[i]; + if (!s) { send_result(BRIDGE_MSG_UDP_OPEN, port, SOCKPROXY_ENOSLOT); return; } + struct udp_pcb *pcb = udp_new_ip_type(IPADDR_TYPE_V6); + if (!pcb) { send_result(BRIDGE_MSG_UDP_OPEN, port, SOCKPROXY_ENOMEM); return; } + udp_bind_netif(pcb, nif); + if (udp_bind(pcb, IP6_ADDR_ANY, port) != ERR_OK) { + udp_remove(pcb); + send_result(BRIDGE_MSG_UDP_OPEN, port, SOCKPROXY_EFAIL); + return; + } + /* mDNS wants hop limit 255 both ways (RFC 6762 11). The ff02::fb + * membership (flags bit 0) is held by the interface itself. */ + pcb->ttl = 255; + udp_set_multicast_ttl(pcb, 255); + s->pcb = pcb; + s->port = port; + udp_recv(pcb, udp_rx, s); + send_result(BRIDGE_MSG_UDP_OPEN, port, SOCKPROXY_OK); +} + +static void do_send(const uint8_t *d, size_t n) { + if (n < UDP_HDR_SEND || n - UDP_HDR_SEND > UDP_MAX_DATA) { + s_udp_err++; + send_result(BRIDGE_MSG_UDP_SEND, 0, SOCKPROXY_EBADREQ); + return; + } + uint16_t port = (uint16_t)(d[0] | (d[1] << 8)); + udp_slot_t *s = slot_for(port); + if (!s) { /* failures only: a RESULT per datagram would double the traffic */ + s_udp_err++; + send_result(BRIDGE_MSG_UDP_SEND, port, awdl_netif_is_up() ? SOCKPROXY_ENOTOPEN : SOCKPROXY_EDOWN); + return; + } + ip_addr_t to; + IP_SET_TYPE_VAL(to, IPADDR_TYPE_V6); + memcpy(ip_2_ip6(&to)->addr, d + 2, 16); + ip6_addr_assign_zone(ip_2_ip6(&to), IP6_UNKNOWN, awdl_netif_get()); /* link scope: AWDL */ + uint16_t dport = (uint16_t)(d[18] | (d[19] << 8)); + size_t len = n - UDP_HDR_SEND; + struct pbuf *p = pbuf_alloc(PBUF_TRANSPORT, (u16_t)len, PBUF_RAM); + if (!p) { + s_udp_err++; + send_result(BRIDGE_MSG_UDP_SEND, port, SOCKPROXY_ENOMEM); + return; + } + memcpy(p->payload, d + UDP_HDR_SEND, len); + err_t e = udp_sendto(s->pcb, p, &to, dport); + pbuf_free(p); + if (e == ERR_OK) { + s_udp_tx++; + } else { + s_udp_err++; + send_result(BRIDGE_MSG_UDP_SEND, port, SOCKPROXY_EFAIL); + } +} + +static void job_run(void *arg) { + job_t *j = arg; + switch (j->type) { + case BRIDGE_MSG_UDP_OPEN: + do_open(j->data, j->len); + break; + case BRIDGE_MSG_UDP_SEND: + do_send(j->data, j->len); + break; + case BRIDGE_MSG_UDP_CLOSE: + if (j->len >= 2) { + udp_slot_t *s = slot_for((uint16_t)(j->data[0] | (j->data[1] << 8))); + if (s) close_slot(s); + } + break; + default: + break; + } + free(j); +} + +void sockproxy_handle(uint8_t type, const uint8_t *payload, size_t len) { + job_t *j = malloc(sizeof(*j) + len); + if (!j) { + s_udp_err++; + send_result(type, 0, SOCKPROXY_ENOMEM); + return; + } + j->type = type; + j->len = len; + memcpy(j->data, payload, len); + if (tcpip_callback(job_run, j) != ERR_OK) { + free(j); + s_udp_err++; + send_result(type, 0, SOCKPROXY_ENOMEM); + } +} + +static SemaphoreHandle_t s_reset_done; + +static void do_reset(void *arg) { + (void)arg; + for (int i = 0; i < UDP_SLOTS; i++) close_slot(&s_udp[i]); + xSemaphoreGive(s_reset_done); +} + +void sockproxy_reset(void) { + if (!s_reset_done && !(s_reset_done = xSemaphoreCreateBinary())) return; + xSemaphoreTake(s_reset_done, 0); + if (tcpip_callback(do_reset, NULL) == ERR_OK) + xSemaphoreTake(s_reset_done, pdMS_TO_TICKS(1000)); +} + +int sockproxy_format_status(char *buf, size_t cap) { + int o = snprintf(buf, cap, "udp:"); + for (int i = 0; i < UDP_SLOTS && o > 0 && (size_t)o < cap; i++) + if (s_udp[i].pcb) o += snprintf(buf + o, cap - (size_t)o, " %u", s_udp[i].port); + if (o > 0 && (size_t)o < cap) + o += snprintf(buf + o, cap - (size_t)o, " rx=%u tx=%u err=%u", + (unsigned)s_udp_rx, (unsigned)s_udp_tx, (unsigned)s_udp_err); + return o; +} diff --git a/firmware/spikes/main/sockproxy.h b/firmware/spikes/main/sockproxy.h new file mode 100644 index 0000000..573a956 --- /dev/null +++ b/firmware/spikes/main/sockproxy.h @@ -0,0 +1,48 @@ +/* + * EspDrop - socket proxy between the host and the device's own IP stack (M4). + * + * In IP mode the host does not see packets but sockets (docs/M4_ENTWURF.md, + * sections 3.3 and 4): it opens a UDP port on the AWDL interface, sends + * datagrams to an address and gets the ones that arrive, with sender and + * whether they came to a multicast group. That is all mDNS needs (M4.2); TCP + * follows in M4.3. + * + * Host messages are handed in from the bridge RX task, copied and processed on + * the lwIP thread, which owns all socket state; device messages go out with + * bridge_send from there. + * + * SPDX-License-Identifier: GPL-3.0-or-later + * Copyright (C) 2026 EspDrop contributors + */ +#pragma once + +#include +#include + +#ifdef __cplusplus +extern "C" { +#endif + +/* Error codes in RESULT (i8), shared with the host app. */ +enum sockproxy_err { + SOCKPROXY_OK = 0, + SOCKPROXY_EFAIL = -1, /* lwIP refused */ + SOCKPROXY_EDOWN = -2, /* not in IP mode / interface down */ + SOCKPROXY_ENOSLOT = -3, /* no free socket */ + SOCKPROXY_ENOMEM = -4, + SOCKPROXY_EBADREQ = -5, /* malformed message */ + SOCKPROXY_ENOTOPEN = -6, /* port not opened with UDP_OPEN */ +}; + +/* A host message of type UDP_OPEN / UDP_SEND / UDP_CLOSE (bridge RX task). */ +void sockproxy_handle(uint8_t type, const uint8_t *payload, size_t len); + +/* Close every socket (new HELLO, leaving IP mode). Waits for the lwIP thread. */ +void sockproxy_reset(void); + +/* One-line status for the `ip` command. */ +int sockproxy_format_status(char *buf, size_t cap); + +#ifdef __cplusplus +} +#endif diff --git a/host/README.md b/host/README.md new file mode 100644 index 0000000..a904b52 --- /dev/null +++ b/host/README.md @@ -0,0 +1,21 @@ +# EspDrop host app (M4) + +AirDrop on Windows and Linux through an ESP32-C5/C3 that runs its own IPv6 +stack on the AWDL link. Design: [`docs/M4_ENTWURF.md`](../docs/M4_ENTWURF.md); +how to test each stage: [`docs/SPIKES.md`](../docs/SPIKES.md). + +| module | role | +|---|---| +| `espdrop/link.py` | USB framing and message formats (shared with `tools/espdrop_link.py`) | +| `espdrop/transport.py` | serial port: pyserial, or a raw POSIX tty without it | +| `espdrop/device.py` | HELLO, commands, UDP sockets on the device | +| `espdrop/mdns.py` | AirDrop browser over the device's UDP | +| `espdrop/cli.py` | `find`, `status`, `cmd` | +| `tests/` | simulated device with iPad-shaped mDNS answers | + +Run from a checkout with the OpenDrop venv's python (it has python-zeroconf): + +```sh +~/opendrop-venv/bin/python tools/espdrop.py find +~/opendrop-venv/bin/python -m unittest discover -s host/tests +``` diff --git a/host/espdrop/__init__.py b/host/espdrop/__init__.py new file mode 100644 index 0000000..034bdbb --- /dev/null +++ b/host/espdrop/__init__.py @@ -0,0 +1,5 @@ +# EspDrop host app (M4): AirDrop over an ESP32-C5/C3 with its own IP stack. +# See docs/M4_ENTWURF.md. +# +# SPDX-License-Identifier: GPL-3.0-or-later +# Copyright (C) 2026 EspDrop contributors diff --git a/host/espdrop/__main__.py b/host/espdrop/__main__.py new file mode 100644 index 0000000..bfe7691 --- /dev/null +++ b/host/espdrop/__main__.py @@ -0,0 +1,9 @@ +# python -m espdrop ... (with host/ on the path; tools/espdrop.py does that) +# +# SPDX-License-Identifier: GPL-3.0-or-later +# Copyright (C) 2026 EspDrop contributors +import sys + +from .cli import main + +sys.exit(main()) diff --git a/host/espdrop/cli.py b/host/espdrop/cli.py new file mode 100644 index 0000000..e5caea4 --- /dev/null +++ b/host/espdrop/cli.py @@ -0,0 +1,144 @@ +# EspDrop - command line of the M4 host app (docs/M4_ENTWURF.md, section 5). +# +# espdrop find [-t SECS] [--no-wake] [-v] AirDrop receivers on the AWDL link +# espdrop status device, interface, counters +# espdrop cmd TEXT... one device command (as in the bridge) +# +# The device is switched into IP mode (HELLO); it stays there until the next +# session (tools/awdl_bridge.py switches it back to L2 itself). +# +# SPDX-License-Identifier: GPL-3.0-or-later +# Copyright (C) 2026 EspDrop contributors + +import argparse +import sys +import time + +from . import link +from .device import Device, DeviceError + + +def _open(args, transport): + from .transport import open_port + port = transport or open_port(args.port) + verbose = getattr(args, "verbose", False) + + def on_log(text): + if verbose or not text.startswith("ping6:"): + print(f"[device] {text}", flush=True) + return Device(port, on_log=on_log) + + +def _hello(dev): + h = dev.hello(link.MODE_IP) + print(f"device {h['mac']}, protocol {h['version']}, IP mode, Bluetooth wake: " + f"{'yes' if h['caps'] & link.CAP_BLE else 'no'}", flush=True) + return h + + +def cmd_find(args, transport=None): + from .mdns import AIRDROP, Browser + dev = _open(args, transport) + woke = False + try: + h = _hello(dev) + if not args.no_wake: + if h["caps"] & link.CAP_BLE: + secs = min(600, int(args.time) + 10) + reply = dev.cmd(f"wake {secs}") + woke = reply.startswith("wake on") + print(f"BLE wake from the ESP: {reply}", flush=True) + else: + print("no Bluetooth wake in this firmware: the target must already be " + "awake (or use --no-wake and wake it otherwise)", flush=True) + + def found(r): + print(f" found {r.id} [{r.parsed_addresses()[0]}]:{r.port} flags {r.flags}" + f" host {r.server}", flush=True) + + def gone(r): + print(f" gone {r.id}", flush=True) + + def packet(src, mcast, what): + print(f" mdns {src or '-'} {'multicast' if mcast else 'unicast'}: {what}", + flush=True) + + b = Browser(dev, on_found=found, on_gone=gone, + on_packet=packet if args.verbose else None) + print(f"browsing {AIRDROP} on the ESP's AWDL link for {args.time:.0f}s " + f"(target: AirDrop Everyone, unlocked, share sheet OPEN) ...", flush=True) + b.start() + try: + end = time.monotonic() + args.time + while time.monotonic() < end: + time.sleep(0.2) + if dev.error: + raise DeviceError(f"link to the device lost: {dev.error}") + except KeyboardInterrupt: + print() + b.stop() + rs = b.receivers() + print(f"{len(rs)} receiver(s); {b.queries_sent} queries sent, " + f"{b.packets} mDNS messages received, {b.invalid} unreadable", flush=True) + if not rs: + print(" - nothing: is the target on AirDrop Everyone with its share sheet open?\n" + " `espdrop status` shows whether the ESP hears it at all (peers, rx).", + flush=True) + return 0 if rs else 1 + finally: + if woke: + try: + dev.cmd("wake off") + except DeviceError: + pass + dev.close() + + +def cmd_status(args, transport=None): + dev = _open(args, transport) + try: + _hello(dev) + for c in ("node", "ip", "stats", "wake"): + print(f"{c}: {dev.cmd(c)}", flush=True) + return 0 + finally: + dev.close() + + +def cmd_cmd(args, transport=None): + dev = _open(args, transport) + try: + _hello(dev) + print(dev.cmd(" ".join(args.text), timeout=args.timeout), flush=True) + if args.text and args.text[0] == "ping6": # replies come as log lines + time.sleep(args.wait) + return 0 + finally: + dev.close() + + +def main(argv=None, transport=None): + ap = argparse.ArgumentParser(prog="espdrop", description="EspDrop host app (M4)") + ap.add_argument("-p", "--port", help="serial port (default: the only ESP found; " + "Linux /dev/ttyACM0, Windows COM5)") + sub = ap.add_subparsers(dest="action", required=True) + f = sub.add_parser("find", help="list AirDrop receivers on the AWDL link") + f.add_argument("-t", "--time", type=float, default=30.0, help="seconds to browse") + f.add_argument("--no-wake", action="store_true", help="do not send the BLE wake") + f.add_argument("-v", "--verbose", action="store_true", help="show every AirDrop mDNS message") + sub.add_parser("status", help="device, interface and counters") + c = sub.add_parser("cmd", help="send one device command") + c.add_argument("text", nargs="+") + c.add_argument("--timeout", type=float, default=3.0) + c.add_argument("--wait", type=float, default=12.0, help="ping6: seconds to show replies") + c.set_defaults(verbose=True) + args = ap.parse_args(argv) + handlers = {"find": cmd_find, "status": cmd_status, "cmd": cmd_cmd} + try: + return handlers[args.action](args, transport) + except (DeviceError, OSError) as e: + print(f"error: {e}", file=sys.stderr) + if isinstance(e, PermissionError): + print(" (Linux: add yourself to the 'dialout' group - 'uucp' on Arch - and " + "log in again)", file=sys.stderr) + return 1 diff --git a/host/espdrop/device.py b/host/espdrop/device.py new file mode 100644 index 0000000..2caead8 --- /dev/null +++ b/host/espdrop/device.py @@ -0,0 +1,151 @@ +# EspDrop - talking to the device over the USB link (docs/M4_ENTWURF.md, 4). +# +# A Device owns the serial port: a reader thread decodes the COBS stream and +# hands each message to whoever waits for it (a command's reply, the HELLO +# reply, a RESULT) or to a handler (UDP datagrams per port, device log lines). +# +# SPDX-License-Identifier: GPL-3.0-or-later +# Copyright (C) 2026 EspDrop contributors + +import threading + +from . import link + + +class DeviceError(Exception): + pass + + +class Device: + def __init__(self, port, on_log=None): + """port: a transport (transport.open_port) with read/write/close.""" + self.port = port + self.on_log = on_log or (lambda text: None) + self.hello_info = None + self._wlock = threading.Lock() # one writer at a time + self._cmd_lock = threading.Lock() # one command in flight (replies are unnumbered) + self._cmd_reply = None + self._cmd_event = threading.Event() + self._hello_event = threading.Event() + self._results = {} # (request type, id) -> err, for waiters + self._result_cond = threading.Condition() + self._udp = {} # port -> handler(src16, sport, multicast, data) + self._stop = threading.Event() + self.error = None # set if the reader thread died + self._reader = threading.Thread(target=self._read_loop, name="espdrop-link", + daemon=True) + self._reader.start() + # A lone delimiter ends whatever partial frame boot chatter left behind. + self._write(b"\x00") + + # ---- plumbing ---------------------------------------------------------- + + def _write(self, data: bytes) -> None: + with self._wlock: + self.port.write(data) + + def _read_loop(self): + reader = link.Reader() + try: + while not self._stop.is_set(): + data = self.port.read(0.2) + if data: + for t, p in reader.feed(data): + self._dispatch(t, p) + except Exception as e: # unplugged, port closed: wake every waiter + self.error = e + self._cmd_event.set() + self._hello_event.set() + with self._result_cond: + self._result_cond.notify_all() + + def _dispatch(self, t, p): + if t == link.MSG_CMD_REPLY: + self._cmd_reply = p.decode("utf-8", "replace") + self._cmd_event.set() + elif t == link.MSG_LOG: + self.on_log(p.decode("utf-8", "replace")) + elif t == link.MSG_HELLO_REPLY: + self.hello_info = link.parse_hello_reply(p) + self._hello_event.set() + elif t == link.MSG_UDP_RECV: + u = link.parse_udp_recv(p) + if u: + handler = self._udp.get(u[0]) + if handler: + handler(*u[1:]) + elif t == link.MSG_RESULT: + r = link.parse_result(p) + if r: + req, ident, err = r + with self._result_cond: + self._results[(req, ident)] = err + self._result_cond.notify_all() + if err and req == link.MSG_UDP_SEND: + self.on_log(f"udp send on port {ident} failed: {link.ERRORS.get(err, err)}") + + def _check(self): + if self.error: + raise DeviceError(f"link to the device lost: {self.error}") + + # ---- API --------------------------------------------------------------- + + def hello(self, mode=link.MODE_IP, credit=65536, timeout=2.0): + """Start a session in the given mode; returns the HELLO_REPLY dict.""" + self._hello_event.clear() + self._write(link.hello(mode, credit)) + if not self._hello_event.wait(timeout): + raise DeviceError("the device does not answer HELLO; is the firmware older " + "than M4.1, or is tools/awdl_bridge.py holding the port?") + self._check() + h = self.hello_info + if not h or h["version"] != link.PROTO_VERSION: + raise DeviceError(f"device speaks protocol {h and h['version']}, " + f"this host app {link.PROTO_VERSION}: update one of them") + if h["mode"] != mode: + raise DeviceError("the device did not switch mode (IP interface failed?)") + return h + + def cmd(self, text: str, timeout=3.0) -> str: + """Run a text command (as typed in the bridge window); returns the reply.""" + with self._cmd_lock: + self._cmd_event.clear() + self._cmd_reply = None + self._write(link.frame(link.MSG_CMD, text.encode())) + if not self._cmd_event.wait(timeout): + raise DeviceError(f"no reply to {text!r}") + self._check() + return self._cmd_reply + + def _wait_result(self, req, ident, timeout): + with self._result_cond: + ok = self._result_cond.wait_for( + lambda: (req, ident) in self._results or self.error, timeout) + self._check() + if not ok: + raise DeviceError(f"no answer to request {req} for {ident}") + return self._results.pop((req, ident)) + + def udp_open(self, port, handler, join_mdns=True, timeout=2.0): + """Open a UDP port on the AWDL interface; handler(src16, sport, mcast, data).""" + self._udp[port] = handler + with self._result_cond: + self._results.pop((link.MSG_UDP_OPEN, port), None) + self._write(link.udp_open(port, join_mdns)) + err = self._wait_result(link.MSG_UDP_OPEN, port, timeout) + if err: + del self._udp[port] + raise DeviceError(f"UDP port {port}: {link.ERRORS.get(err, err)}") + + def udp_send(self, port, dst16: bytes, dst_port, data: bytes): + self._check() + self._write(link.udp_send(port, dst16, dst_port, data)) + + def udp_close(self, port): + self._udp.pop(port, None) + self._write(link.udp_close(port)) + + def close(self): + self._stop.set() + self._reader.join(timeout=1.0) + self.port.close() diff --git a/host/espdrop/link.py b/host/espdrop/link.py new file mode 100644 index 0000000..876f0c9 --- /dev/null +++ b/host/espdrop/link.py @@ -0,0 +1,178 @@ +# EspDrop - the USB link to the device: framing and message formats. +# +# One COBS-framed, type-tagged message stream over the device's USB-Serial/JTAG, +# mirroring firmware/spikes/main/bridge.{h,c} and the COBS codec in +# firmware/spikes/components/awdl/awdl_cobs.c. Device and host must agree byte +# for byte, so keep this in sync with those files (and docs/M4_ENTWURF.md, +# section 4). +# +# Wire format per message: COBS(type_byte || payload) followed by a 0x00 +# delimiter. COBS guarantees the body carries no 0x00, so the delimiter is +# unambiguous and the stream resynchronises after any stray bytes. +# +# Pure Python, no OS dependencies (the serial port lives in transport.py), so +# it works on Windows and Linux and is shared by tools/espdrop_link.py. +# +# SPDX-License-Identifier: GPL-3.0-or-later +# Copyright (C) 2026 EspDrop contributors + +import struct + +# Message types - must match enum bridge_msg_type in firmware/spikes/main/bridge.h. +MSG_ECHO = 1 # host -> device: payload echoed back +MSG_ECHO_REPLY = 2 # device -> host: the echoed payload +MSG_LOG = 3 # device -> host: UTF-8 log text +MSG_FRAME = 4 # both: one Ethernet frame for awdl0 (L2 mode) +MSG_PEER_ADD = 5 # device -> host: 6-byte peer MAC appeared +MSG_PEER_REMOVE = 6 # device -> host: 6-byte peer MAC gone +MSG_CMD = 7 # host -> device: command text +MSG_CMD_REPLY = 8 # device -> host: command output text +MSG_HELLO = 9 # host -> device: u8 version, u8 mode (0 L2, 1 IP), u32 credit +MSG_HELLO_REPLY = 10 # device -> host: version, mode, mac[6], max_conns, max_chunk, caps +MSG_UDP_OPEN = 24 # host -> device: u16 port, u8 flags (bit 0: ff02::fb) +MSG_UDP_RECV = 25 # device -> host: u16 port, src[16], u16 src_port, u8 flags, data +MSG_UDP_SEND = 26 # host -> device: u16 port, dst[16], u16 dst_port, data +MSG_UDP_CLOSE = 27 # host -> device: u16 port +MSG_RESULT = 28 # device -> host: u8 request type, u16 id, i8 err + +PROTO_VERSION = 1 # BRIDGE_PROTO_VERSION +MODE_L2 = 0 # frames to/from the host's awdl0 (tools/awdl_bridge.py) +MODE_IP = 1 # the device's own IPv6 stack (this host app) + +CAP_BLE = 0x01 # HELLO_REPLY caps: the BLE AirDrop wake (`wake`) works +CAP_IP = 0x02 # IP mode is available + +# RESULT error codes - enum sockproxy_err in firmware/spikes/main/sockproxy.h. +ERRORS = { + 0: "ok", -1: "lwIP refused", -2: "not in IP mode / interface down", + -3: "no free socket", -4: "no memory on the device", -5: "malformed request", + -6: "port not open", +} + + +def cobs_encode(data: bytes) -> bytes: + out = bytearray() + code_i = len(out) + out.append(0) # placeholder for the first code byte + code = 1 + for b in data: + if b == 0: + out[code_i] = code + code_i = len(out) + out.append(0) + code = 1 + else: + out.append(b) + code += 1 + if code == 0xFF: + out[code_i] = code + code_i = len(out) + out.append(0) + code = 1 + out[code_i] = code + return bytes(out) + + +def cobs_decode(data: bytes) -> bytes: + out = bytearray() + i = 0 + n = len(data) + while i < n: + code = data[i] + i += 1 + if code == 0: + raise ValueError("zero byte inside frame") + for _ in range(code - 1): + if i >= n: + raise ValueError("truncated") + out.append(data[i]) + i += 1 + if code != 0xFF and i < n: + out.append(0) + return bytes(out) + + +def frame(msg_type: int, payload: bytes = b"") -> bytes: + """Encode one message to its on-the-wire bytes (COBS body + 0x00 delimiter).""" + return cobs_encode(bytes([msg_type]) + payload) + b"\x00" + + +class Reader: + """Accumulates bytes; feed() returns completed (type, payload) frames. + + feed() consumes the whole chunk and returns a list (it must NOT be a + generator): callers break out of their read loop as soon as they see the + frame they want, and a suspended generator would (a) never run a buffer + reset placed after a yield, so every later frame would decode merged onto + the previous ones, and (b) drop any bytes after the delimiter in the + current chunk. Returning a list keeps self.buf correct no matter when the + caller stops reading. + """ + def __init__(self): + self.buf = bytearray() + + def feed(self, data: bytes): + frames = [] + for b in data: + if b == 0: + if self.buf: + frame_bytes = bytes(self.buf) + self.buf = bytearray() # reset BEFORE handing the frame out + try: + dec = cobs_decode(frame_bytes) + if dec: + frames.append((dec[0], dec[1:])) + except ValueError: + pass + else: + self.buf.append(b) + return frames + + +# ---- message payloads ---------------------------------------------------- + +def hello(mode: int, credit: int = 0) -> bytes: + """A HELLO message (docs/M4_ENTWURF.md, section 4).""" + return frame(MSG_HELLO, struct.pack(" bytes: + return frame(MSG_UDP_OPEN, struct.pack(" bytes: + if len(dst) != 16: + raise ValueError("dst must be a 16-byte IPv6 address") + return frame(MSG_UDP_SEND, struct.pack(" bytes: + return frame(MSG_UDP_CLOSE, struct.pack(" instance, SRV -> host and port, TXT -> flags, AAAA -> address from the +# answer and additional sections of every response. A goodbye (TTL 0 PTR) +# removes the receiver again. +# +# Messages are read and written with python-zeroconf's DNSIncoming/DNSOutgoing +# (installed with OpenDrop); its socket layer is not used, the datagrams go +# through the device. +# +# SPDX-License-Identifier: GPL-3.0-or-later +# Copyright (C) 2026 EspDrop contributors + +import ipaddress +import threading +import time + +try: + from zeroconf import DNSIncoming, DNSOutgoing, DNSQuestion, const +except ImportError as e: # pragma: no cover - depends on the environment + raise ImportError("python-zeroconf is missing; run espdrop with the OpenDrop venv's " + "python (~/opendrop-venv/bin/python)") from e + +AIRDROP = "_airdrop._tcp.local." +MDNS_PORT = 5353 +MDNS_GROUP = ipaddress.IPv6Address("ff02::fb").packed + +T_PTR, T_TXT, T_AAAA, T_SRV = const._TYPE_PTR, const._TYPE_TXT, const._TYPE_AAAA, const._TYPE_SRV + + +def txt_properties(raw: bytes) -> dict: + """b'\\x0cflags=111611...' -> {b'flags': b'111611', ...}""" + props, i = {}, 0 + while i < len(raw): + n = raw[i] + item = raw[i + 1:i + 1 + n] + i += 1 + n + if item: + k, _, v = item.partition(b"=") + props[k] = v + return props + + +class Receiver: + """One AirDrop receiver; quacks like zeroconf's ServiceInfo where OpenDrop + looks (name, server, port, properties, parsed_addresses()).""" + + def __init__(self, instance: str): + self.name = instance # 0123456789ab._airdrop._tcp.local. + self.id = instance.split(".", 1)[0] # 0123456789ab + self.server = None + self.port = None + self.properties = {} + self.addresses = [] + self.source = None # who sent the records + self.first_seen = self.last_seen = time.monotonic() + + @property + def flags(self): + try: + return int(self.properties.get(b"flags", b"")) + except ValueError: + return None + + def parsed_addresses(self): + return list(self.addresses) or ([self.source] if self.source else []) + + def complete(self): + return self.port is not None and bool(self.parsed_addresses()) + + def __repr__(self): + return (f"Receiver({self.id}, [{(self.parsed_addresses() or ['?'])[0]}]:{self.port}, " + f"flags={self.flags})") + + +class Browser: + def __init__(self, device, on_found=None, on_gone=None, on_packet=None, interval=1.5): + self.device = device + self.on_found = on_found or (lambda r: None) + self.on_gone = on_gone or (lambda r: None) + self.on_packet = on_packet # (src, multicast, summary) for -v + self.interval = interval + self._lock = threading.Lock() + self._recv = {} # lower-case instance name -> Receiver + self._reported = set() + self._hosts = {} # lower-case server name -> [addresses] + self._stop = threading.Event() + self._thread = None + self.queries_sent = 0 + self.packets = 0 + self.invalid = 0 + + # ---- lifecycle --------------------------------------------------------- + + def start(self): + self.device.udp_open(MDNS_PORT, self._on_udp, join_mdns=True) + self._thread = threading.Thread(target=self._query_loop, name="espdrop-mdns", + daemon=True) + self._thread.start() + + def stop(self): + self._stop.set() + if self._thread: + self._thread.join(timeout=2) + try: + self.device.udp_close(MDNS_PORT) + except Exception: + pass + + def receivers(self): + with self._lock: + return [r for r in self._recv.values() if r.complete()] + + # ---- queries ----------------------------------------------------------- + + @staticmethod + def query() -> bytes: + out = DNSOutgoing(const._FLAGS_QR_QUERY) + out.add_question(DNSQuestion(AIRDROP, T_PTR, const._CLASS_IN)) + return out.packets()[0] + + def _query_loop(self): + q = self.query() + while not self._stop.is_set(): + try: + self.device.udp_send(MDNS_PORT, MDNS_GROUP, MDNS_PORT, q) + self.queries_sent += 1 + except Exception as e: + if self.on_packet: + self.on_packet(None, False, f"query not sent: {e}") + self._stop.wait(self.interval) + + # ---- responses --------------------------------------------------------- + + def _on_udp(self, src16, sport, multicast, data): + self.handle(str(ipaddress.IPv6Address(src16)), data, multicast) + + def handle(self, src: str, data: bytes, multicast=True): + """Fold one received mDNS message in (public for tests).""" + self.packets += 1 + msg = DNSIncoming(data, (src, MDNS_PORT)) + if not msg.valid: + self.invalid += 1 + if self.on_packet: + self.on_packet(src, multicast, f"invalid mDNS message ({len(data)} bytes)") + return + if msg.is_query(): + return + records = msg.answers() + found, gone = [], [] + with self._lock: + # Addresses first: an SRV in the same message may need them. + for r in records: + if r.type == T_AAAA and len(r.address) == 16: + addrs = self._hosts.setdefault(r.name.lower(), []) + a = str(ipaddress.IPv6Address(r.address)) + if r.ttl == 0: + if a in addrs: + addrs.remove(a) + elif a not in addrs: + addrs.append(a) + for r in records: + name = r.name.lower() + if r.type == T_PTR and name == AIRDROP: + inst = r.alias.lower() + if r.ttl == 0: + old = self._recv.pop(inst, None) + if old and inst in self._reported: + self._reported.discard(inst) + gone.append(old) + else: + self._get(inst, r.alias, src) + elif name.endswith("." + AIRDROP) and r.type in (T_SRV, T_TXT): + if r.ttl == 0: + continue + rec = self._get(name, r.name, src) + if r.type == T_SRV: + rec.server, rec.port = r.server, r.port + else: + rec.properties = txt_properties(r.text) + for inst, rec in self._recv.items(): + if rec.server: + rec.addresses = list(self._hosts.get(rec.server.lower(), [])) + if rec.complete() and inst not in self._reported: + self._reported.add(inst) + found.append(rec) + if self.on_packet: + mine = sorted({f"{const._TYPES.get(r.type, r.type)}" for r in records + if AIRDROP in r.name.lower() or + (r.type == T_PTR and AIRDROP in getattr(r, "alias", "").lower())}) + if mine: + self.on_packet(src, multicast, "AirDrop " + ",".join(mine)) + for r in found: + self.on_found(r) + for r in gone: + self.on_gone(r) + + def _get(self, key, shown, src): + rec = self._recv.get(key) + if rec is None: + rec = self._recv[key] = Receiver(shown) + rec.source = src + rec.last_seen = time.monotonic() + return rec diff --git a/host/espdrop/transport.py b/host/espdrop/transport.py new file mode 100644 index 0000000..f30bf53 --- /dev/null +++ b/host/espdrop/transport.py @@ -0,0 +1,116 @@ +# EspDrop - the serial port to the device, on Windows and Linux. +# +# pyserial when it is installed (needed on Windows: COM ports); otherwise a raw +# POSIX tty, so Linux needs no extra package. Both give the same small API. +# +# SPDX-License-Identifier: GPL-3.0-or-later +# Copyright (C) 2026 EspDrop contributors + +import glob +import os +import sys + +ESPRESSIF_VID = 0x303A # USB-Serial/JTAG of the ESP32-C3/C5 + + +class PosixPort: + """A tty in raw mode (no echo, no CR/LF translation).""" + + def __init__(self, name: str): + import termios + import tty + self.name = name + self.fd = os.open(name, os.O_RDWR | os.O_NOCTTY | os.O_NONBLOCK) + try: + tty.setraw(self.fd) + except termios.error as e: + os.close(self.fd) + raise OSError(f"cannot put {name} into raw mode: {e}") from e + + def read(self, timeout: float) -> bytes: + import select + r, _, _ = select.select([self.fd], [], [], timeout) + if not r: + return b"" + try: + data = os.read(self.fd, 65536) + except BlockingIOError: + return b"" + if data == b"": + raise EOFError(f"{self.name} closed (device unplugged?)") + return data + + def write(self, data: bytes, timeout: float = 5.0) -> None: + import select + import time + mv = memoryview(data) + deadline = time.monotonic() + timeout + while mv: + try: + n = os.write(self.fd, mv) + mv = mv[n:] + except BlockingIOError: + left = deadline - time.monotonic() + if left <= 0: + raise TimeoutError(f"write to {self.name} stalled for {timeout}s") + select.select([], [self.fd], [], left) + + def close(self) -> None: + os.close(self.fd) + + +class PySerialPort: + """pyserial, for Windows (COMx) and wherever it is installed.""" + + def __init__(self, name: str): + import serial + self.name = name + self.ser = serial.Serial(name, timeout=0, write_timeout=5) + + def read(self, timeout: float) -> bytes: + self.ser.timeout = timeout + n = max(1, self.ser.in_waiting) + return self.ser.read(n) + + def write(self, data: bytes, timeout: float = 5.0) -> None: + self.ser.write_timeout = timeout + self.ser.write(data) + + def close(self) -> None: + self.ser.close() + + +def candidates(): + """Serial ports that look like an ESP USB-Serial/JTAG, best first.""" + try: + from serial.tools import list_ports + found = [p.device for p in list_ports.comports() if p.vid == ESPRESSIF_VID] + if found: + return found + except ImportError: + pass + if sys.platform.startswith("linux"): + by_id = sorted(glob.glob("/dev/serial/by-id/*Espressif*")) + if by_id: + return [os.path.realpath(p) for p in by_id] + return sorted(glob.glob("/dev/ttyACM*")) + return [] + + +def open_port(name=None): + """Open the given port, or the only ESP found. Raises OSError with advice.""" + if not name: + found = candidates() + if not found: + raise OSError("no ESP found; plug it in or name the port with -p " + "(Linux /dev/ttyACM0, Windows COM5)") + if len(found) > 1: + raise OSError(f"several ports found ({', '.join(found)}); pick one with -p") + name = found[0] + try: + import serial # noqa: F401 + return PySerialPort(name) + except ImportError: + if os.name != "posix": + raise OSError("pyserial is needed on this system: pip install pyserial") + return PosixPort(name) diff --git a/host/tests/apple_mdns.py b/host/tests/apple_mdns.py new file mode 100644 index 0000000..26677aa --- /dev/null +++ b/host/tests/apple_mdns.py @@ -0,0 +1,126 @@ +# EspDrop tests - mDNS responses shaped like the ones an iPad sends for AirDrop. +# +# Modelled on a real capture (docs/SPIKES.md, M3 debugging): one big response +# with 14 answers (TXT flags, PTRs for _airdrop and two pairing services, AAAA, +# reverse PTR, three SRVs) plus NSEC and an EDNS0 OPT record, and the small +# ~209-byte answer to a PTR query. Names and addresses here are made up. +# +# SPDX-License-Identifier: GPL-3.0-or-later +# Copyright (C) 2026 EspDrop contributors + +import ipaddress +import struct + +PAD_IP = ipaddress.IPv6Address("fe80::1234:56ff:fe78:9abc") +PAD_ID = "0123456789ab" +PAD_NAME = "TestPad" +HOST = "00000000-1111-2222-3333-444444444444.local." + + +class Msg: + """Tiny DNS encoder with name compression, enough for these fixtures.""" + + def __init__(self): + self.buf = bytearray(12) + self.names = {} + + def name(self, n): + labels = n.rstrip(".").split(".") + for i in range(len(labels)): + suffix = ".".join(labels[i:]).lower() + if suffix in self.names: + self.buf += struct.pack("!H", 0xC000 | self.names[suffix]) + return + self.names[suffix] = len(self.buf) + b = labels[i].encode() + self.buf += bytes([len(b)]) + b + self.buf += b"\0" + + def rr(self, owner, typ, flush, ttl, rdata): + self.name(owner) + self.buf += struct.pack("!HHI", typ, 1 | (0x8000 if flush else 0), ttl) + at = len(self.buf) + self.buf += b"\0\0" + rdata(self) + struct.pack_into("!H", self.buf, at, len(self.buf) - at - 2) + + +def _txt(*items): + def f(m): + for it in items: + b = it.encode() + m.buf.extend(bytes([len(b)]) + b) + return f + + +def _name(n): + return lambda m: m.name(n) + + +def _srv(port): + def f(m): + m.buf.extend(struct.pack("!HHH", 0, 0, port)) + m.name(HOST) + return f + + +def _aaaa(m): + m.buf.extend(PAD_IP.packed) + + +def _nsec(owner, types): + def f(m): + m.name(owner) + bitmap = bytearray(32) + for t in types: + bitmap[t // 8] |= 0x80 >> (t % 8) + n = max(i for i, b in enumerate(bitmap) if b) + 1 + m.buf.extend(bytes([0, n]) + bitmap[:n]) + return f + + +def response(kind="small", pad_id=PAD_ID, flags="111611", ttl_ptr=4500): + """kind: 'small' (PTR answer + TXT/SRV/AAAA/NSEC additionals), 'full' (the big + announcement), 'goodbye' (PTR with TTL 0), 'other' (no AirDrop at all).""" + ad = f"{pad_id}._airdrop._tcp.local." + asp = f"{PAD_NAME}._applicationServicePairing._tcp.local." + m = Msg() + if kind == "small": + ans = [("_airdrop._tcp.local.", 12, False, ttl_ptr, _name(ad))] + add = [(ad, 16, True, 4500, _txt(f"flags={flags}")), + (ad, 33, True, 120, _srv(8770)), + (HOST, 28, True, 120, _aaaa), + (ad, 47, True, 4500, _nsec(ad, [16, 33])), + (HOST, 47, True, 120, _nsec(HOST, [28]))] + elif kind == "full": + ans = [(ad, 16, True, 4500, _txt(f"flags={flags}")), + ("_services._dns-sd._udp.local.", 12, False, 4500, _name("_airdrop._tcp.local.")), + ("_airdrop._tcp.local.", 12, False, ttl_ptr, _name(ad)), + (asp, 16, True, 4500, _txt("sn=com.apple.sharingd.AirDrop", "at=000000000000")), + ("_services._dns-sd._udp.local.", 12, False, 4500, + _name("_applicationServicePairing._tcp.local.")), + ("_applicationServicePairing._tcp.local.", 12, False, 4500, _name(asp)), + (HOST, 28, True, 120, _aaaa), + (ad, 33, True, 120, _srv(8770)), + (asp, 33, True, 120, _srv(49842))] + add = [(ad, 47, True, 4500, _nsec(ad, [16, 33])), + (asp, 47, True, 4500, _nsec(asp, [16, 33])), + (HOST, 47, True, 120, _nsec(HOST, [28]))] + elif kind == "goodbye": + ans = [("_airdrop._tcp.local.", 12, False, 0, _name(ad))] + add = [] + elif kind == "other": + ans = [("_companion-link._tcp.local.", 12, False, 4500, + _name(f"{PAD_NAME}._companion-link._tcp.local."))] + add = [] + else: + raise ValueError(kind) + for r in ans + add: + m.rr(*r) + n_add = len(add) + if kind == "full": # EDNS0 OPT with an Apple "Owner" option, as sleep-proxy hosts add + m.buf += b"\0" + struct.pack("!HHIH", 41, 1440, 0x1194, 14) + struct.pack("!HH", 4, 10) \ + + bytes(10) + n_add += 1 + struct.pack_into("!6H", m.buf, 0, 0, 0x8400, 0, len(ans), 0, n_add) + return bytes(m.buf) diff --git a/host/tests/fake_device.py b/host/tests/fake_device.py new file mode 100644 index 0000000..11f3791 --- /dev/null +++ b/host/tests/fake_device.py @@ -0,0 +1,107 @@ +# EspDrop tests - a simulated ESP for the host app, no hardware needed. +# +# Behaves like the bridge firmware's IP mode at the USB-message level +# (docs/M4_ENTWURF.md, section 4): answers HELLO and text commands, opens UDP +# ports and, when the host sends mDNS queries for _airdrop._tcp, answers with +# canned iPad responses (apple_mdns.py) as UDP_RECV messages. +# +# SPDX-License-Identifier: GPL-3.0-or-later +# Copyright (C) 2026 EspDrop contributors + +import queue +import struct +import threading + +from espdrop import link + +try: + from apple_mdns import PAD_IP, response +except ImportError: # imported as tests.fake_device + from .apple_mdns import PAD_IP, response + + +class FakeDevice: + """A transport (read/write/close) with a device behind it.""" + + def __init__(self, answers=None, caps=link.CAP_BLE | link.CAP_IP, version=link.PROTO_VERSION, + ip_ok=True, mac=bytes.fromhex("020000000001")): + # answers: {n: [kind, ...]} - after the n-th AirDrop query (1-based) the + # device delivers these apple_mdns.response() kinds from the iPad. + self.answers = answers or {} + self.caps, self.version, self.ip_ok, self.mac = caps, version, ip_ok, mac + self.mode = link.MODE_L2 + self.udp_ports = set() + self.commands = [] + self.queries = [] # payloads of AirDrop queries the host sent + self.sent = [] # (dst16, dport, data) of every UDP_SEND + self._reader = link.Reader() + self._out = queue.Queue() + self._lock = threading.Lock() + self.closed = False + + # ---- transport API ----------------------------------------------------- + + def read(self, timeout): + try: + return self._out.get(timeout=timeout) + except queue.Empty: + return b"" + + def write(self, data, timeout=5.0): + with self._lock: + for t, p in self._reader.feed(data): + self._handle(t, p) + + def close(self): + self.closed = True + + # ---- device behaviour -------------------------------------------------- + + def _emit(self, t, payload): + self._out.put(link.frame(t, payload)) + + def _handle(self, t, p): + if t == link.MSG_HELLO: + if len(p) >= 2 and p[0] == self.version and (p[1] == link.MODE_L2 or self.ip_ok): + self.mode = p[1] + else: + self.mode = link.MODE_L2 + self.udp_ports.clear() + self._emit(link.MSG_HELLO_REPLY, bytes([self.version, self.mode]) + self.mac + + struct.pack("= 12 and not data[2] & 0x80 + if is_query and b"_airdrop" in data: + self.queries.append(data) + for kind in self.answers.get(len(self.queries), []): + self.deliver(response(kind)) + + def deliver(self, data, src=PAD_IP.packed, sport=5353, multicast=True, port=5353): + """Hand the host a datagram as if it had arrived over AWDL.""" + self._emit(link.MSG_UDP_RECV, struct.pack(" device: payload echoed back -MSG_ECHO_REPLY = 2 # device -> host: the echoed payload -MSG_LOG = 3 # device -> host: UTF-8 log text -MSG_FRAME = 4 # both: one Ethernet frame for awdl0 -MSG_PEER_ADD = 5 # device -> host: 6-byte peer MAC appeared -MSG_PEER_REMOVE = 6 # device -> host: 6-byte peer MAC gone -MSG_CMD = 7 # host -> device: command text -MSG_CMD_REPLY = 8 # device -> host: command output text -MSG_HELLO = 9 # host -> device: u8 version, u8 mode (0 L2, 1 IP), u32 credit -MSG_HELLO_REPLY = 10 # device -> host: version, mode, mac[6], max_conns, max_chunk, caps - -PROTO_VERSION = 1 # BRIDGE_PROTO_VERSION -MODE_L2 = 0 # frames to/from the host's awdl0 (this bridge) -MODE_IP = 1 # the device's own IPv6 stack (M4 host app) - - -def hello(mode: int, credit: int = 0) -> bytes: - """A HELLO message (docs/M4_ENTWURF.md, section 4).""" - return frame(MSG_HELLO, struct.pack(" bytes: - out = bytearray() - code_i = len(out) - out.append(0) # placeholder for the first code byte - code = 1 - for b in data: - if b == 0: - out[code_i] = code - code_i = len(out) - out.append(0) - code = 1 - else: - out.append(b) - code += 1 - if code == 0xFF: - out[code_i] = code - code_i = len(out) - out.append(0) - code = 1 - out[code_i] = code - return bytes(out) - - -def cobs_decode(data: bytes) -> bytes: - out = bytearray() - i = 0 - n = len(data) - while i < n: - code = data[i] - i += 1 - if code == 0: - raise ValueError("zero byte inside frame") - for _ in range(code - 1): - if i >= n: - raise ValueError("truncated") - out.append(data[i]) - i += 1 - if code != 0xFF and i < n: - out.append(0) - return bytes(out) - - -def frame(msg_type: int, payload: bytes = b"") -> bytes: - """Encode one message to its on-the-wire bytes (COBS body + 0x00 delimiter).""" - return cobs_encode(bytes([msg_type]) + payload) + b"\x00" - - -class Reader: - """Accumulates bytes; feed() returns completed (type, payload) frames. - - feed() consumes the whole chunk and returns a list (it must NOT be a - generator): callers break out of their read loop as soon as they see the - frame they want, and a suspended generator would (a) never run a buffer - reset placed after a yield, so every later frame would decode merged onto - the previous ones, and (b) drop any bytes after the delimiter in the - current chunk. Returning a list keeps self.buf correct no matter when the - caller stops reading. - """ - def __init__(self): - self.buf = bytearray() - - def feed(self, data: bytes): - frames = [] - for b in data: - if b == 0: - if self.buf: - frame_bytes = bytes(self.buf) - self.buf = bytearray() # reset BEFORE handing the frame out - try: - dec = cobs_decode(frame_bytes) - if dec: - frames.append((dec[0], dec[1:])) - except ValueError: - pass - else: - self.buf.append(b) - return frames +# Framing and message formats live in host/espdrop/link.py (shared with the M4 +# host app, which must also run on Windows); this module adds the POSIX serial +# helpers the Linux tools use. +sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "host")) +from espdrop.link import ( # noqa: E402 (path set up just above) + CAP_BLE, CAP_IP, MODE_IP, MODE_L2, MSG_CMD, MSG_CMD_REPLY, MSG_ECHO, + MSG_ECHO_REPLY, MSG_FRAME, MSG_HELLO, MSG_HELLO_REPLY, MSG_LOG, MSG_PEER_ADD, + MSG_PEER_REMOVE, PROTO_VERSION, Reader, cobs_decode, cobs_encode, frame, hello, + parse_hello_reply, +) def open_serial_raw(port: str) -> int: From 5e284c479853081145d2200f4d0329bf0b45b5f7 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 23 Oct 2026 15:22:26 +0000 Subject: [PATCH 68/99] docs(m4.2): espdrop find finds the iPad through the ESP's own stack Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01XzgaN4Bv1m8Ka9iqo7QVUN --- docs/SPIKES.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/docs/SPIKES.md b/docs/SPIKES.md index 3becb9d..35e8057 100644 --- a/docs/SPIKES.md +++ b/docs/SPIKES.md @@ -348,6 +348,11 @@ automatisch gefunden (USB-Kennung von Espressif), sonst `-p /dev/ttyACM0` bzw. `-p COM5`. Danach geht `awdl_bridge.py` wie gewohnt (es schaltet zurück auf L2). +Ergebnis (2026-10-23, C5, iPad mit offenem Teilen-Menü, Wecksignal vom ESP): +`find` fand das iPad sofort (`found [fe80::…]:8770 flags 111611`); in 30 s +21 Anfragen, 38 AirDrop-Antworten, keine unlesbar. Freier Heap in `ip`: +~89,5 KB (`rxq=0`), vorher ~30 KB mit belegter L2-Warteschlange. + Tests ohne Hardware (simulierter ESP mit iPad-Antworten): ```sh ~/opendrop-venv/bin/python -m unittest discover -s host/tests From fd416482c3083c69acfd8f04d7b37dd240c59b98 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 23 Oct 2026 15:31:52 +0000 Subject: [PATCH 69/99] feat(m4.3): TCP connect in the device's socket proxy (firmware part) - bridge: in IP mode the queue to the host stays, shallow (8), as the one ordered path for proxy messages (bridge_post, non-blocking, with a space callback), so a connection's FIN never overtakes its data and the lwIP thread never waits on USB. HELLO_REPLY reports 4 connections. - sockproxy: TCP_CONNECT / TCP_DATA / TCP_CREDIT / TCP_CLOSE (types 17-21) with credit-based flow control both ways: lwIP's window opens only for bytes that reached the host, the host may have as much outstanding as the peer has not ACKed; FIN as half-close, RST/errors close; callbacks that aborted a pcb return ERR_ABRT. UDP_RECV now goes through the same queue. HELLO's credit sets the per-connection allowance to the host. - sdkconfig.bridge.defaults: lwIP TCP send buffer and window 8 x MSS. - host link.py: the TCP message encoders/parsers. C5 and C3 bridge build; host unit tests pass. The host side of TCP and espdrop send are not written yet; nothing of this is tested on hardware. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01XzgaN4Bv1m8Ka9iqo7QVUN --- firmware/spikes/main/bridge.c | 66 +++- firmware/spikes/main/bridge.h | 17 + firmware/spikes/main/sockproxy.c | 423 +++++++++++++++++++--- firmware/spikes/main/sockproxy.h | 19 +- firmware/spikes/sdkconfig.bridge.defaults | 6 + host/espdrop/link.py | 45 +++ 6 files changed, 514 insertions(+), 62 deletions(-) diff --git a/firmware/spikes/main/bridge.c b/firmware/spikes/main/bridge.c index 1554f07..885e628 100644 --- a/firmware/spikes/main/bridge.c +++ b/firmware/spikes/main/bridge.c @@ -50,7 +50,12 @@ static const uint8_t k_rx_q_depths[] = {64, 48, 40, 32, 24, 16, 8}; * Measured on the C5 with Bluetooth: over a 38 MB receive the free heap fell * only ~7 KB below its idle value (stats heap vs heap_min). */ #define BRIDGE_HEAP_RESERVE (24u * 1024u) +/* In IP mode the same queue carries the socket proxy's messages to the host, + * shallower: one ordered path, so a connection's FIN can never overtake its + * last data, and the lwIP thread never waits on USB. */ +#define BRIDGE_IPQ_DEPTH 8 typedef struct { + uint8_t type; /* BRIDGE_MSG_FRAME (L2), or a proxy message (IP mode) */ uint16_t len; uint8_t eth[14 + BRIDGE_RX_PAYLOAD_MAX]; } rx_frame_t; @@ -72,6 +77,8 @@ static SemaphoreHandle_t s_l2q_lock; static SemaphoreHandle_t s_l2q_freed; /* given by the fwd task after a release */ static volatile bool s_l2q_release; /* asks the fwd task to free the queue */ static TaskHandle_t s_fwd_task; +static volatile bool s_post_wanted; /* a bridge_post found it full */ +static void (*s_post_space_cb)(void); /* called when room appears */ static QueueHandle_t s_peer_q; /* peer_evt_t: AWDL task -> peer task */ static uint8_t s_rx_q_depth; /* depth actually allocated */ static bool s_started; @@ -117,7 +124,7 @@ static void send_test_frame(void) { /* Current mode (enum bridge_mode). Read by the Wi-Fi task for every frame. */ static volatile uint8_t s_mode = BRIDGE_MODE_L2; -static bool l2q_alloc(void); +static bool l2q_alloc(uint8_t max_depth); static void l2q_release(void); /* Switch L2 <-> IP. Into IP: bring the interface up first, then route frames to @@ -129,11 +136,14 @@ static int set_mode(uint8_t mode) { if (mode == BRIDGE_MODE_IP) { if (awdl_netif_set_up(mac, true) != 0) return -1; s_mode = BRIDGE_MODE_IP; - l2q_release(); /* its memory is lwIP's now */ + l2q_release(); /* its memory is lwIP's now ... */ + l2q_alloc(BRIDGE_IPQ_DEPTH); /* ... but for a small ordered queue */ + sockproxy_reset(0); } else { - if (!l2q_alloc()) return -1; /* the host's awdl0 needs it */ + l2q_release(); /* the small IP-mode queue, if any */ + if (!l2q_alloc(255)) return -1; /* the host's awdl0 needs a deep one */ s_mode = BRIDGE_MODE_L2; - sockproxy_reset(); + sockproxy_reset(0); if (awdl_netif_is_up()) awdl_netif_set_up(mac, false); } return 0; @@ -144,7 +154,7 @@ static void send_hello_reply(void) { r[0] = BRIDGE_PROTO_VERSION; r[1] = s_mode; radio_get_self_mac(r + 2); - r[8] = 0; /* proxy connections: none yet (M4.3) */ + r[8] = SOCKPROXY_TCP_SLOTS; /* TCP connections at once */ r[9] = 1400 & 0xff; /* max TCP_DATA chunk, little endian */ r[10] = 1400 >> 8; r[11] = (ble_wake_ready() ? 0x01 : 0) /* caps: BLE wake */ @@ -168,6 +178,7 @@ void bridge_rx_frame(const uint8_t dst[6], const uint8_t src[6], uint16_t ethert scratch.eth[13] = (uint8_t)(ethertype & 0xff); if (plen) memcpy(scratch.eth + 14, payload, plen); scratch.len = (uint16_t)(14 + plen); + scratch.type = BRIDGE_MSG_FRAME; /* Never wait: the lock is only held around a mode switch. */ if (xSemaphoreTake(s_l2q_lock, 0) != pdTRUE) { s_rx_drops++; return; } if (s_rx_fwd_q && xQueueSend(s_rx_fwd_q, &scratch, 0) != pdTRUE) @@ -175,13 +186,15 @@ void bridge_rx_frame(const uint8_t dst[6], const uint8_t src[6], uint16_t ethert xSemaphoreGive(s_l2q_lock); } -/* Create the L2 RX queue, as deep as the heap allows with BRIDGE_HEAP_RESERVE - * left over (the smallest depth is always tried). No-op if it exists. */ -static bool l2q_alloc(void) { +/* Create the queue to the host, at most max_depth deep and as deep as the heap + * allows with BRIDGE_HEAP_RESERVE left over (the smallest depth that fits + * max_depth is always tried). No-op if it exists. */ +static bool l2q_alloc(uint8_t max_depth) { if (s_rx_fwd_q) return true; QueueHandle_t q = NULL; uint8_t depth = 0; for (size_t i = 0; i < sizeof(k_rx_q_depths) && !q; i++) { + if (k_rx_q_depths[i] > max_depth && i + 1 < sizeof(k_rx_q_depths)) continue; size_t need = (size_t)k_rx_q_depths[i] * sizeof(rx_frame_t); bool last = (i + 1 == sizeof(k_rx_q_depths)); if (!last && esp_get_free_heap_size() < need + BRIDGE_HEAP_RESERVE) @@ -198,6 +211,25 @@ static bool l2q_alloc(void) { return true; } +bool bridge_post(uint8_t type, const void *a, size_t alen, const void *b, size_t blen) { + static rx_frame_t item; /* guarded by s_l2q_lock */ + if (alen + blen > sizeof(item.eth)) return false; + if (!s_l2q_lock || xSemaphoreTake(s_l2q_lock, pdMS_TO_TICKS(100)) != pdTRUE) return false; + bool ok = false; + if (s_rx_fwd_q) { + item.type = type; + item.len = (uint16_t)(alen + blen); + if (alen) memcpy(item.eth, a, alen); + if (blen) memcpy(item.eth + alen, b, blen); + ok = xQueueSend(s_rx_fwd_q, &item, 0) == pdTRUE; + } + xSemaphoreGive(s_l2q_lock); + if (!ok) s_post_wanted = true; + return ok; +} + +void bridge_set_post_space_cb(void (*cb)(void)) { s_post_space_cb = cb; } + /* Hand the L2 RX queue's memory back (IP mode). The fwd task frees it within * one receive timeout; returns once it has, or after 1 s. */ static void l2q_release(void) { @@ -230,10 +262,14 @@ static void bridge_fwd_task(void *arg) { } /* A timeout, not forever, so a release request is seen promptly. */ if (xQueueReceive(q, &fr, pdMS_TO_TICKS(50)) == pdTRUE) { - if (bridge_send(BRIDGE_MSG_FRAME, fr.eth, fr.len) == 0) { + if (bridge_send(fr.type, fr.eth, fr.len) == 0 && fr.type == BRIDGE_MSG_FRAME) { s_frames_to_host++; s_bytes_to_host += fr.len; } + if (s_post_wanted) { /* room again for a waiting producer */ + s_post_wanted = false; + if (s_post_space_cb) s_post_space_cb(); + } } } } @@ -616,12 +652,20 @@ static void handle_msg(const uint8_t *msg, size_t len) { set_mode(payload[1]); else set_mode(BRIDGE_MODE_L2); - sockproxy_reset(); /* a new host session starts with no sockets */ + /* A new host session starts with no sockets, and says how much each + * connection may send it before it grants more. */ + sockproxy_reset(plen >= 6 ? (uint32_t)(payload[2] | (payload[3] << 8) | + (payload[4] << 16) | ((uint32_t)payload[5] << 24)) + : 0); send_hello_reply(); break; case BRIDGE_MSG_UDP_OPEN: case BRIDGE_MSG_UDP_SEND: case BRIDGE_MSG_UDP_CLOSE: + case BRIDGE_MSG_TCP_CONNECT: + case BRIDGE_MSG_TCP_DATA: + case BRIDGE_MSG_TCP_CREDIT: + case BRIDGE_MSG_TCP_CLOSE: sockproxy_handle(type, payload, plen); break; /* PEER_* are device->host only (see bridge_notify_peer). */ @@ -668,7 +712,7 @@ int bridge_start(void) { s_l2q_lock = xSemaphoreCreateMutex(); s_l2q_freed = xSemaphoreCreateBinary(); - if (s_l2q_lock && s_l2q_freed) l2q_alloc(); /* L2 after boot */ + if (s_l2q_lock && s_l2q_freed) l2q_alloc(255); /* L2 after boot */ s_peer_q = xQueueCreate(BRIDGE_PEER_Q_LEN, sizeof(peer_evt_t)); if (!s_rx_fwd_q || !s_peer_q) { if (s_rx_fwd_q) vQueueDelete(s_rx_fwd_q); diff --git a/firmware/spikes/main/bridge.h b/firmware/spikes/main/bridge.h index de322de..da29728 100644 --- a/firmware/spikes/main/bridge.h +++ b/firmware/spikes/main/bridge.h @@ -40,6 +40,13 @@ enum bridge_msg_type { BRIDGE_MSG_CMD_REPLY = 8, /* device -> host: command output text */ BRIDGE_MSG_HELLO = 9, /* host -> device: u8 version, u8 mode (M4) */ BRIDGE_MSG_HELLO_REPLY = 10,/* device -> host: version, mode, mac, limits, caps */ + BRIDGE_MSG_TCP_LISTEN = 16, /* host -> device: u16 port (M4.4) */ + BRIDGE_MSG_TCP_CONNECT = 17, /* host -> device: u16 conn, dst[16], u16 port */ + BRIDGE_MSG_TCP_OPENED = 18, /* device -> host: u16 conn, peer[16], u16 rport, u16 lport, + * u8 accepted, u32 credit */ + BRIDGE_MSG_TCP_DATA = 19, /* both: u16 conn, data */ + BRIDGE_MSG_TCP_CREDIT = 20, /* both: u16 conn, u32 bytes (the sender may send more) */ + BRIDGE_MSG_TCP_CLOSE = 21, /* both: u16 conn, u8 kind (0 FIN, 1 RST), i8 err */ /* Socket proxy, IP mode (docs/M4_ENTWURF.md section 4, main/sockproxy.c) */ BRIDGE_MSG_UDP_OPEN = 24, /* host -> device: u16 port, u8 flags */ BRIDGE_MSG_UDP_RECV = 25, /* device -> host: u16 port, src[16], u16 sport, u8 flags, data */ @@ -93,6 +100,16 @@ void bridge_notify_peer(bool added, const uint8_t mac[6]); /* printf-style LOG message to the host. */ void bridge_log(const char *fmt, ...) __attribute__((format(printf, 1, 2))); +/* + * Queue one message for the host without waiting (IP mode: the socket proxy). + * The payload is a || b. Messages queued here go out in order. Returns false + * when the queue is full (or absent); the callback set with + * bridge_set_post_space_cb then runs, from the forwarding task, as soon as an + * entry has gone out. + */ +bool bridge_post(uint8_t type, const void *a, size_t alen, const void *b, size_t blen); +void bridge_set_post_space_cb(void (*cb)(void)); + #ifdef __cplusplus } #endif diff --git a/firmware/spikes/main/sockproxy.c b/firmware/spikes/main/sockproxy.c index 5d6fae8..3186678 100644 --- a/firmware/spikes/main/sockproxy.c +++ b/firmware/spikes/main/sockproxy.c @@ -1,6 +1,22 @@ /* * EspDrop - socket proxy between the host and the device's own IP stack. See - * sockproxy.h. Wire formats: main/bridge.h, docs/M4_ENTWURF.md section 4.1. + * sockproxy.h. Wire formats: main/bridge.h, docs/M4_ENTWURF.md section 4. + * + * Everything below runs on the lwIP thread except sockproxy_handle and + * sockproxy_reset, which only hand work to it. Messages to the host go through + * bridge_post, the ordered queue the forwarding task drains; when it is full, + * TCP data waits in lwIP (its receive window stays closed) and the queue's + * space callback kicks us again. + * + * Flow control (section 4.3), per connection: + * - device -> host: host_credit bytes may be sent; the host grants more with + * TCP_CREDIT once its application read them. lwIP's window is only opened + * (tcp_recved) for bytes that went to the host, so a slow host or USB + * slows the peer down instead of losing frames. + * - host -> device: the host may have as many bytes outstanding as + * TCP_OPENED granted (the TCP send buffer); every byte the peer ACKs is + * granted again with TCP_CREDIT. Bytes lwIP could not take at once wait in + * tx_buf, which the credit bounds. * * SPDX-License-Identifier: GPL-3.0-or-later * Copyright (C) 2026 EspDrop contributors @@ -15,6 +31,7 @@ #include "freertos/semphr.h" #include "lwip/ip6_addr.h" #include "lwip/pbuf.h" +#include "lwip/tcp.h" #include "lwip/tcpip.h" #include "lwip/udp.h" @@ -25,15 +42,37 @@ #define UDP_HDR_RECV (2 + 16 + 2 + 1) #define UDP_HDR_SEND (2 + 16 + 2) #define UDP_MAX_DATA AWDL_NETIF_MTU /* nothing bigger crosses AWDL anyway */ +#define TCP_CHUNK 1400 /* TCP_DATA payload to the host (HELLO max_chunk) */ + +/* ---- state (lwIP thread only) ---------------------------------------------- */ typedef struct { struct udp_pcb *pcb; uint16_t port; } udp_slot_t; -static udp_slot_t s_udp[UDP_SLOTS]; /* lwIP thread only */ +static udp_slot_t s_udp[UDP_SLOTS]; + +typedef struct { + bool used; + uint16_t id; + struct tcp_pcb *pcb; /* NULL once lwIP freed it or we closed it */ + uint32_t host_credit; /* bytes we may still send to the host */ + struct pbuf *rx; /* from the peer, not yet sent to the host */ + bool rx_fin; /* the peer closed its side (after rx) */ + bool fin_posted; /* ... and the host has been told */ + uint8_t *tx_buf; /* from the host, lwIP did not take it yet */ + size_t tx_len, tx_cap; + bool host_fin; /* the host closed its side (after tx_buf) */ + bool shut_tx; /* ... and lwIP has been told */ +} tcp_conn_t; +static tcp_conn_t s_tcp[SOCKPROXY_TCP_SLOTS]; +static uint32_t s_host_credit = 65536; /* from HELLO */ +/* Set when tcp_abort ran: a callback that did that must return ERR_ABRT. */ +static bool s_aborted; /* Counters for `ip`. Aligned 32-bit, stats only. */ static volatile uint32_t s_udp_rx, s_udp_tx, s_udp_err; +static volatile uint32_t s_tcp_opened, s_tcp_to_host, s_tcp_from_host, s_tcp_err; /* A host message, copied for the lwIP thread. */ typedef struct { @@ -42,53 +81,72 @@ typedef struct { uint8_t data[]; } job_t; +static inline uint16_t rd16(const uint8_t *p) { return (uint16_t)(p[0] | (p[1] << 8)); } +static inline void wr16(uint8_t *p, uint16_t v) { p[0] = (uint8_t)v; p[1] = (uint8_t)(v >> 8); } +static inline void wr32(uint8_t *p, uint32_t v) { + p[0] = (uint8_t)v; p[1] = (uint8_t)(v >> 8); p[2] = (uint8_t)(v >> 16); p[3] = (uint8_t)(v >> 24); +} + +/* Control messages: through the ordered queue if it has room, else directly + * (blocking on USB is acceptable for these rare, small ones; none of them may + * overtake a connection's data - see post_close). */ +static void post_ctl(uint8_t type, const void *p, size_t n) { + if (!bridge_post(type, p, n, NULL, 0)) bridge_send(type, p, n); +} + static void send_result(uint8_t req, uint16_t id, int8_t err) { uint8_t r[4] = { req, (uint8_t)(id & 0xff), (uint8_t)(id >> 8), (uint8_t)err }; - bridge_send(BRIDGE_MSG_RESULT, r, sizeof(r)); + post_ctl(BRIDGE_MSG_RESULT, r, sizeof(r)); +} + +static void zone(ip_addr_t *a, const uint8_t addr16[16]) { + IP_SET_TYPE_VAL(*a, IPADDR_TYPE_V6); + memcpy(ip_2_ip6(a)->addr, addr16, 16); + ip6_addr_assign_zone(ip_2_ip6(a), IP6_UNKNOWN, awdl_netif_get()); /* link scope: AWDL */ } -static udp_slot_t *slot_for(uint16_t port) { +/* ---- UDP --------------------------------------------------------------------- */ + +static udp_slot_t *udp_slot_for(uint16_t port) { for (int i = 0; i < UDP_SLOTS; i++) if (s_udp[i].pcb && s_udp[i].port == port) return &s_udp[i]; return NULL; } -static void close_slot(udp_slot_t *s) { +static void udp_close_slot(udp_slot_t *s) { if (s->pcb) udp_remove(s->pcb); s->pcb = NULL; s->port = 0; } -/* lwIP thread: a datagram for one of our ports -> UDP_RECV to the host. */ static void udp_rx(void *arg, struct udp_pcb *pcb, struct pbuf *p, const ip_addr_t *addr, u16_t port) { (void)pcb; udp_slot_t *s = arg; - static uint8_t msg[UDP_HDR_RECV + UDP_MAX_DATA]; /* lwIP thread only */ + static uint8_t data[UDP_MAX_DATA]; if (!IP_IS_V6(addr) || p->tot_len > UDP_MAX_DATA) { s_udp_err++; pbuf_free(p); return; } - msg[0] = (uint8_t)(s->port & 0xff); - msg[1] = (uint8_t)(s->port >> 8); - memcpy(msg + 2, ip_2_ip6(addr)->addr, 16); - msg[18] = (uint8_t)(port & 0xff); - msg[19] = (uint8_t)(port >> 8); - msg[20] = ip6_addr_ismulticast(ip_2_ip6(ip_current_dest_addr())) ? 0x01 : 0x00; - pbuf_copy_partial(p, msg + UDP_HDR_RECV, p->tot_len, 0); - size_t n = UDP_HDR_RECV + p->tot_len; + uint8_t hdr[UDP_HDR_RECV]; + wr16(hdr, s->port); + memcpy(hdr + 2, ip_2_ip6(addr)->addr, 16); + wr16(hdr + 18, port); + hdr[20] = ip6_addr_ismulticast(ip_2_ip6(ip_current_dest_addr())) ? 0x01 : 0x00; + u16_t n = pbuf_copy_partial(p, data, p->tot_len, 0); pbuf_free(p); - if (bridge_send(BRIDGE_MSG_UDP_RECV, msg, n) == 0) s_udp_rx++; + /* A datagram may be lost: if the queue is full, it is. */ + if (bridge_post(BRIDGE_MSG_UDP_RECV, hdr, sizeof(hdr), data, n)) s_udp_rx++; else s_udp_err++; } -static void do_open(const uint8_t *d, size_t n) { +static void udp_open_job(const uint8_t *d, size_t n) { if (n < 3) { send_result(BRIDGE_MSG_UDP_OPEN, 0, SOCKPROXY_EBADREQ); return; } - uint16_t port = (uint16_t)(d[0] | (d[1] << 8)); + uint16_t port = rd16(d); struct netif *nif = awdl_netif_get(); if (!nif || !awdl_netif_is_up()) { send_result(BRIDGE_MSG_UDP_OPEN, port, SOCKPROXY_EDOWN); return; } - if (slot_for(port)) { send_result(BRIDGE_MSG_UDP_OPEN, port, SOCKPROXY_OK); return; } + if (udp_slot_for(port)) { send_result(BRIDGE_MSG_UDP_OPEN, port, SOCKPROXY_OK); return; } udp_slot_t *s = NULL; for (int i = 0; i < UDP_SLOTS && !s; i++) if (!s_udp[i].pcb) s = &s_udp[i]; @@ -111,24 +169,21 @@ static void do_open(const uint8_t *d, size_t n) { send_result(BRIDGE_MSG_UDP_OPEN, port, SOCKPROXY_OK); } -static void do_send(const uint8_t *d, size_t n) { +static void udp_send_job(const uint8_t *d, size_t n) { if (n < UDP_HDR_SEND || n - UDP_HDR_SEND > UDP_MAX_DATA) { s_udp_err++; send_result(BRIDGE_MSG_UDP_SEND, 0, SOCKPROXY_EBADREQ); return; } - uint16_t port = (uint16_t)(d[0] | (d[1] << 8)); - udp_slot_t *s = slot_for(port); + uint16_t port = rd16(d); + udp_slot_t *s = udp_slot_for(port); if (!s) { /* failures only: a RESULT per datagram would double the traffic */ s_udp_err++; send_result(BRIDGE_MSG_UDP_SEND, port, awdl_netif_is_up() ? SOCKPROXY_ENOTOPEN : SOCKPROXY_EDOWN); return; } ip_addr_t to; - IP_SET_TYPE_VAL(to, IPADDR_TYPE_V6); - memcpy(ip_2_ip6(&to)->addr, d + 2, 16); - ip6_addr_assign_zone(ip_2_ip6(&to), IP6_UNKNOWN, awdl_netif_get()); /* link scope: AWDL */ - uint16_t dport = (uint16_t)(d[18] | (d[19] << 8)); + zone(&to, d + 2); size_t len = n - UDP_HDR_SEND; struct pbuf *p = pbuf_alloc(PBUF_TRANSPORT, (u16_t)len, PBUF_RAM); if (!p) { @@ -137,7 +192,7 @@ static void do_send(const uint8_t *d, size_t n) { return; } memcpy(p->payload, d + UDP_HDR_SEND, len); - err_t e = udp_sendto(s->pcb, p, &to, dport); + err_t e = udp_sendto(s->pcb, p, &to, rd16(d + 18)); pbuf_free(p); if (e == ERR_OK) { s_udp_tx++; @@ -147,23 +202,288 @@ static void do_send(const uint8_t *d, size_t n) { } } +/* ---- TCP --------------------------------------------------------------------- */ + +static tcp_conn_t *tcp_find(uint16_t id) { + for (int i = 0; i < SOCKPROXY_TCP_SLOTS; i++) + if (s_tcp[i].used && s_tcp[i].id == id) return &s_tcp[i]; + return NULL; +} + +/* Detach from lwIP (no more callbacks into this slot) and free the slot. */ +static void tcp_release(tcp_conn_t *c, bool abort) { + if (c->pcb) { + struct tcp_pcb *pcb = c->pcb; + tcp_arg(pcb, NULL); + tcp_recv(pcb, NULL); + tcp_sent(pcb, NULL); + tcp_err(pcb, NULL); + tcp_poll(pcb, NULL, 0); + if (abort || tcp_close(pcb) != ERR_OK) { /* close can fail on no memory */ + tcp_abort(pcb); + s_aborted = true; + } + } + if (c->rx) pbuf_free(c->rx); + free(c->tx_buf); + memset(c, 0, sizeof(*c)); +} + +static void post_close(tcp_conn_t *c, uint8_t kind, int8_t err) { + uint8_t m[4]; + wr16(m, c->id); + m[2] = kind; + m[3] = (uint8_t)err; + if (kind == 0) { + /* FIN only through the ordered queue: behind the connection's data. */ + if (bridge_post(BRIDGE_MSG_TCP_CLOSE, m, sizeof(m), NULL, 0)) c->fin_posted = true; + } else { + post_ctl(BRIDGE_MSG_TCP_CLOSE, m, sizeof(m)); + } +} + +/* Both directions closed: let lwIP finish the close on its own. */ +static void tcp_maybe_done(tcp_conn_t *c) { + if (c->fin_posted && c->shut_tx) tcp_release(c, false); +} + +/* Peer -> host: as much buffered data as credit and the queue allow. */ +static void tcp_flush_rx(tcp_conn_t *c) { + static uint8_t chunk[TCP_CHUNK]; + while (c->rx && c->host_credit > 0) { + u16_t n = c->rx->tot_len; + if (n > TCP_CHUNK) n = TCP_CHUNK; + if (n > c->host_credit) n = (u16_t)c->host_credit; + pbuf_copy_partial(c->rx, chunk, n, 0); + uint8_t hdr[2]; + wr16(hdr, c->id); + if (!bridge_post(BRIDGE_MSG_TCP_DATA, hdr, sizeof(hdr), chunk, n)) + return; /* the queue's space callback brings us back */ + c->rx = pbuf_free_header(c->rx, n); + if (c->pcb) tcp_recved(c->pcb, n); /* open the window by what left */ + c->host_credit -= n; + s_tcp_to_host += n; + } + if (!c->rx && c->rx_fin && !c->fin_posted) post_close(c, 0, 0); + tcp_maybe_done(c); +} + +/* Host -> peer: hand lwIP what it takes of tx_buf, then a pending FIN. */ +static void tcp_flush_tx(tcp_conn_t *c) { + if (!c->pcb) return; + size_t off = 0; + while (off < c->tx_len) { + size_t n = c->tx_len - off; + u16_t room = tcp_sndbuf(c->pcb); + if (n > room) n = room; + if (n > 0xffff) n = 0xffff; + if (n == 0 || tcp_write(c->pcb, c->tx_buf + off, (u16_t)n, TCP_WRITE_FLAG_COPY) != ERR_OK) + break; /* full: tcp_sent / poll retries */ + off += n; + } + if (off) { + memmove(c->tx_buf, c->tx_buf + off, c->tx_len - off); + c->tx_len -= off; + tcp_output(c->pcb); + } + if (c->tx_len == 0 && c->host_fin && !c->shut_tx) { + if (tcp_shutdown(c->pcb, 0, 1) == ERR_OK) c->shut_tx = true; + } +} + +static err_t tcp_on_recv(void *arg, struct tcp_pcb *pcb, struct pbuf *p, err_t err) { + (void)pcb; (void)err; + tcp_conn_t *c = arg; + if (!c) { /* released already: nobody wants it */ + if (p) pbuf_free(p); + return ERR_OK; + } + if (!p) { + c->rx_fin = true; + } else if (c->rx) { + pbuf_cat(c->rx, p); + } else { + c->rx = p; + } + s_aborted = false; + tcp_flush_rx(c); + return s_aborted ? ERR_ABRT : ERR_OK; +} + +static err_t tcp_on_sent(void *arg, struct tcp_pcb *pcb, u16_t len) { + (void)pcb; + tcp_conn_t *c = arg; + if (!c) return ERR_OK; + uint8_t m[6]; + wr16(m, c->id); + wr32(m + 2, len); /* acked: the host may send that much again */ + post_ctl(BRIDGE_MSG_TCP_CREDIT, m, sizeof(m)); + s_aborted = false; + tcp_flush_tx(c); + tcp_maybe_done(c); + return s_aborted ? ERR_ABRT : ERR_OK; +} + +static void tcp_on_err(void *arg, err_t err) { + tcp_conn_t *c = arg; /* lwIP has freed the pcb already */ + if (!c) return; + c->pcb = NULL; + s_tcp_err++; + post_close(c, 1, (int8_t)err); + tcp_release(c, false); +} + +static err_t tcp_on_poll(void *arg, struct tcp_pcb *pcb) { + (void)pcb; + tcp_conn_t *c = arg; + s_aborted = false; + if (c) { + tcp_flush_tx(c); + if (c->used) tcp_flush_rx(c); + } + return s_aborted ? ERR_ABRT : ERR_OK; +} + +static err_t tcp_on_connected(void *arg, struct tcp_pcb *pcb, err_t err) { + tcp_conn_t *c = arg; + if (!c) return ERR_OK; + if (err != ERR_OK) { /* lwIP reports a failed connect through tcp_err */ + return err; + } + uint8_t m[2 + 16 + 2 + 2 + 1 + 4]; + wr16(m, c->id); + memcpy(m + 2, ip_2_ip6(&pcb->remote_ip)->addr, 16); + wr16(m + 18, pcb->remote_port); + wr16(m + 20, pcb->local_port); + m[22] = 0; /* we connected, not accepted */ + wr32(m + 23, tcp_sndbuf(pcb)); /* the host may send this much */ + post_ctl(BRIDGE_MSG_TCP_OPENED, m, sizeof(m)); + s_tcp_opened++; + return ERR_OK; +} + +static void tcp_setup(tcp_conn_t *c, struct tcp_pcb *pcb) { + c->pcb = pcb; + tcp_arg(pcb, c); + tcp_recv(pcb, tcp_on_recv); + tcp_sent(pcb, tcp_on_sent); + tcp_err(pcb, tcp_on_err); + tcp_poll(pcb, tcp_on_poll, 2); /* every second: retries */ + tcp_nagle_disable(pcb); /* HTTP requests are small and waited for */ +} + +static void tcp_fail(uint16_t id, int8_t err) { + uint8_t m[4]; + wr16(m, id); + m[2] = 1; + m[3] = (uint8_t)err; + post_ctl(BRIDGE_MSG_TCP_CLOSE, m, sizeof(m)); + s_tcp_err++; +} + +static void tcp_connect_job(const uint8_t *d, size_t n) { + if (n < 20) { tcp_fail(n >= 2 ? rd16(d) : 0, SOCKPROXY_EBADREQ); return; } + uint16_t id = rd16(d); + struct netif *nif = awdl_netif_get(); + if (!nif || !awdl_netif_is_up()) { tcp_fail(id, SOCKPROXY_EDOWN); return; } + if (tcp_find(id)) { tcp_fail(id, SOCKPROXY_EBADREQ); return; } + tcp_conn_t *c = NULL; + for (int i = 0; i < SOCKPROXY_TCP_SLOTS && !c; i++) + if (!s_tcp[i].used) c = &s_tcp[i]; + if (!c) { tcp_fail(id, SOCKPROXY_ENOSLOT); return; } + struct tcp_pcb *pcb = tcp_new_ip_type(IPADDR_TYPE_V6); + if (!pcb) { tcp_fail(id, SOCKPROXY_ENOMEM); return; } + memset(c, 0, sizeof(*c)); + c->used = true; + c->id = id; + c->host_credit = s_host_credit; + tcp_bind_netif(pcb, nif); + tcp_setup(c, pcb); + ip_addr_t to; + zone(&to, d + 2); + err_t e = tcp_connect(pcb, &to, rd16(d + 18), tcp_on_connected); + if (e != ERR_OK) { + tcp_release(c, true); + tcp_fail(id, SOCKPROXY_EFAIL); + } +} + +static void tcp_data_job(const uint8_t *d, size_t n) { + if (n < 2) return; + uint16_t id = rd16(d); + tcp_conn_t *c = tcp_find(id); + if (!c || !c->pcb || c->host_fin) { /* gone, or data after the host's FIN */ + if (!c) tcp_fail(id, SOCKPROXY_ENOTOPEN); + return; + } + size_t len = n - 2; + s_tcp_from_host += len; + if (c->tx_len + len > c->tx_cap) { /* the credit bounds this to the send buffer */ + size_t cap = c->tx_len + len; + uint8_t *b = realloc(c->tx_buf, cap); + if (!b) { + post_close(c, 1, SOCKPROXY_ENOMEM); + tcp_release(c, true); + return; + } + c->tx_buf = b; + c->tx_cap = cap; + } + memcpy(c->tx_buf + c->tx_len, d + 2, len); + c->tx_len += len; + tcp_flush_tx(c); +} + +static void tcp_credit_job(const uint8_t *d, size_t n) { + if (n < 6) return; + tcp_conn_t *c = tcp_find(rd16(d)); + if (!c) return; + c->host_credit += (uint32_t)(d[2] | (d[3] << 8) | (d[4] << 16) | ((uint32_t)d[5] << 24)); + tcp_flush_rx(c); +} + +static void tcp_close_job(const uint8_t *d, size_t n) { + if (n < 3) return; + tcp_conn_t *c = tcp_find(rd16(d)); + if (!c) return; + if (d[2] != 0 || !c->pcb) { /* RST from the host: drop everything at once */ + tcp_release(c, true); + return; + } + c->host_fin = true; /* FIN: after whatever is still in tx_buf */ + tcp_flush_tx(c); + tcp_maybe_done(c); +} + +/* The host queue has room again: retry what waited for it. */ +static void kick(void *arg) { + (void)arg; + for (int i = 0; i < SOCKPROXY_TCP_SLOTS; i++) + if (s_tcp[i].used) tcp_flush_rx(&s_tcp[i]); +} + +static void on_queue_space(void) { /* forwarding task */ + tcpip_try_callback(kick, NULL); /* if lwIP's mailbox is full, poll retries */ +} + +/* ---- dispatch ------------------------------------------------------------------ */ + static void job_run(void *arg) { job_t *j = arg; switch (j->type) { - case BRIDGE_MSG_UDP_OPEN: - do_open(j->data, j->len); - break; - case BRIDGE_MSG_UDP_SEND: - do_send(j->data, j->len); - break; + case BRIDGE_MSG_UDP_OPEN: udp_open_job(j->data, j->len); break; + case BRIDGE_MSG_UDP_SEND: udp_send_job(j->data, j->len); break; case BRIDGE_MSG_UDP_CLOSE: if (j->len >= 2) { - udp_slot_t *s = slot_for((uint16_t)(j->data[0] | (j->data[1] << 8))); - if (s) close_slot(s); + udp_slot_t *s = udp_slot_for(rd16(j->data)); + if (s) udp_close_slot(s); } break; - default: - break; + case BRIDGE_MSG_TCP_CONNECT: tcp_connect_job(j->data, j->len); break; + case BRIDGE_MSG_TCP_DATA: tcp_data_job(j->data, j->len); break; + case BRIDGE_MSG_TCP_CREDIT: tcp_credit_job(j->data, j->len); break; + case BRIDGE_MSG_TCP_CLOSE: tcp_close_job(j->data, j->len); break; + default: break; } free(j); } @@ -171,32 +491,40 @@ static void job_run(void *arg) { void sockproxy_handle(uint8_t type, const uint8_t *payload, size_t len) { job_t *j = malloc(sizeof(*j) + len); if (!j) { - s_udp_err++; + s_tcp_err++; send_result(type, 0, SOCKPROXY_ENOMEM); return; } j->type = type; j->len = len; memcpy(j->data, payload, len); + /* Blocking post: the host's data must not be dropped here (the credit + * already bounds how much can be in flight). */ if (tcpip_callback(job_run, j) != ERR_OK) { free(j); - s_udp_err++; + s_tcp_err++; send_result(type, 0, SOCKPROXY_ENOMEM); } } static SemaphoreHandle_t s_reset_done; +static uint32_t s_reset_credit; -static void do_reset(void *arg) { +static void reset_job(void *arg) { (void)arg; - for (int i = 0; i < UDP_SLOTS; i++) close_slot(&s_udp[i]); + for (int i = 0; i < UDP_SLOTS; i++) udp_close_slot(&s_udp[i]); + for (int i = 0; i < SOCKPROXY_TCP_SLOTS; i++) + if (s_tcp[i].used) tcp_release(&s_tcp[i], true); + s_host_credit = s_reset_credit ? s_reset_credit : 65536; + bridge_set_post_space_cb(on_queue_space); xSemaphoreGive(s_reset_done); } -void sockproxy_reset(void) { +void sockproxy_reset(uint32_t host_credit) { if (!s_reset_done && !(s_reset_done = xSemaphoreCreateBinary())) return; xSemaphoreTake(s_reset_done, 0); - if (tcpip_callback(do_reset, NULL) == ERR_OK) + s_reset_credit = host_credit; + if (tcpip_callback(reset_job, NULL) == ERR_OK) xSemaphoreTake(s_reset_done, pdMS_TO_TICKS(1000)); } @@ -205,7 +533,14 @@ int sockproxy_format_status(char *buf, size_t cap) { for (int i = 0; i < UDP_SLOTS && o > 0 && (size_t)o < cap; i++) if (s_udp[i].pcb) o += snprintf(buf + o, cap - (size_t)o, " %u", s_udp[i].port); if (o > 0 && (size_t)o < cap) - o += snprintf(buf + o, cap - (size_t)o, " rx=%u tx=%u err=%u", + o += snprintf(buf + o, cap - (size_t)o, " rx=%u tx=%u err=%u tcp: open", (unsigned)s_udp_rx, (unsigned)s_udp_tx, (unsigned)s_udp_err); + int open = 0; + for (int i = 0; i < SOCKPROXY_TCP_SLOTS; i++) + if (s_tcp[i].used) open++; + if (o > 0 && (size_t)o < cap) + o += snprintf(buf + o, cap - (size_t)o, "=%d opened=%u to_host=%u from_host=%u err=%u", + open, (unsigned)s_tcp_opened, (unsigned)s_tcp_to_host, + (unsigned)s_tcp_from_host, (unsigned)s_tcp_err); return o; } diff --git a/firmware/spikes/main/sockproxy.h b/firmware/spikes/main/sockproxy.h index 573a956..7c93def 100644 --- a/firmware/spikes/main/sockproxy.h +++ b/firmware/spikes/main/sockproxy.h @@ -4,12 +4,13 @@ * In IP mode the host does not see packets but sockets (docs/M4_ENTWURF.md, * sections 3.3 and 4): it opens a UDP port on the AWDL interface, sends * datagrams to an address and gets the ones that arrive, with sender and - * whether they came to a multicast group. That is all mDNS needs (M4.2); TCP - * follows in M4.3. + * whether they came to a multicast group (mDNS, M4.2), and it opens TCP + * connections to a peer and moves their bytes with credit-based flow control + * (M4.3, section 4.3). * * Host messages are handed in from the bridge RX task, copied and processed on - * the lwIP thread, which owns all socket state; device messages go out with - * bridge_send from there. + * the lwIP thread, which owns all socket state; messages to the host go out in + * order through bridge_post. * * SPDX-License-Identifier: GPL-3.0-or-later * Copyright (C) 2026 EspDrop contributors @@ -34,11 +35,15 @@ enum sockproxy_err { SOCKPROXY_ENOTOPEN = -6, /* port not opened with UDP_OPEN */ }; -/* A host message of type UDP_OPEN / UDP_SEND / UDP_CLOSE (bridge RX task). */ +#define SOCKPROXY_TCP_SLOTS 4 /* reported as max_conns in HELLO_REPLY */ + +/* A host message of type UDP_* or TCP_* (bridge RX task). */ void sockproxy_handle(uint8_t type, const uint8_t *payload, size_t len); -/* Close every socket (new HELLO, leaving IP mode). Waits for the lwIP thread. */ -void sockproxy_reset(void); +/* Close every socket (new HELLO, leaving IP mode) and set how many bytes each + * new TCP connection may send to the host before it grants more (HELLO + * credit). Waits for the lwIP thread. */ +void sockproxy_reset(uint32_t host_credit); /* One-line status for the `ip` command. */ int sockproxy_format_status(char *buf, size_t cap); diff --git a/firmware/spikes/sdkconfig.bridge.defaults b/firmware/spikes/sdkconfig.bridge.defaults index 58d61ec..2ed1240 100644 --- a/firmware/spikes/sdkconfig.bridge.defaults +++ b/firmware/spikes/sdkconfig.bridge.defaults @@ -40,3 +40,9 @@ CONFIG_BT_NIMBLE_TRANSPORT_ACL_FROM_LL_COUNT=4 CONFIG_BT_NIMBLE_TRANSPORT_EVT_COUNT=16 CONFIG_BT_LE_CONN_RESERVED_MEMORY_COUNT=0 CONFIG_PARTITION_TABLE_SINGLE_APP_LARGE=y + +# M4 IP mode: TCP buffers for AirDrop over AWDL, where a round trip waits for a +# shared slot (up to ~0.5 s): 8 x MSS each way instead of 4 x. The socket proxy +# holds at most 4 connections; the heap in IP mode is ~89 KB (docs/SPIKES.md). +CONFIG_LWIP_TCP_SND_BUF_DEFAULT=11520 +CONFIG_LWIP_TCP_WND_DEFAULT=11520 diff --git a/host/espdrop/link.py b/host/espdrop/link.py index 876f0c9..098d107 100644 --- a/host/espdrop/link.py +++ b/host/espdrop/link.py @@ -29,6 +29,13 @@ MSG_CMD_REPLY = 8 # device -> host: command output text MSG_HELLO = 9 # host -> device: u8 version, u8 mode (0 L2, 1 IP), u32 credit MSG_HELLO_REPLY = 10 # device -> host: version, mode, mac[6], max_conns, max_chunk, caps +MSG_TCP_LISTEN = 16 # host -> device: u16 port (M4.4) +MSG_TCP_CONNECT = 17 # host -> device: u16 conn, dst[16], u16 port +MSG_TCP_OPENED = 18 # device -> host: u16 conn, peer[16], u16 rport, u16 lport, + # u8 accepted, u32 credit +MSG_TCP_DATA = 19 # both: u16 conn, data +MSG_TCP_CREDIT = 20 # both: u16 conn, u32 bytes the other side may send more +MSG_TCP_CLOSE = 21 # both: u16 conn, u8 kind (0 FIN, 1 RST), i8 err MSG_UDP_OPEN = 24 # host -> device: u16 port, u8 flags (bit 0: ff02::fb) MSG_UDP_RECV = 25 # device -> host: u16 port, src[16], u16 src_port, u8 flags, data MSG_UDP_SEND = 26 # host -> device: u16 port, dst[16], u16 dst_port, data @@ -176,3 +183,41 @@ def parse_result(p: bytes): return None req, ident, err = struct.unpack_from(" bytes: + if len(dst) != 16: + raise ValueError("dst must be a 16-byte IPv6 address") + return frame(MSG_TCP_CONNECT, struct.pack(" bytes: + return frame(MSG_TCP_DATA, struct.pack(" bytes: + return frame(MSG_TCP_CREDIT, struct.pack(" bytes: + return frame(MSG_TCP_CLOSE, struct.pack(" Date: Fri, 23 Oct 2026 15:41:03 +0000 Subject: [PATCH 70/99] feat(m4.3): espdrop send over the ESP's socket proxy (host part) The host side of sending through the device's own IP stack, completing M4.3. - proxy.py: a TcpConn is one proxied connection; the caller gets a normal loopback TCP socket (a TCP pair, since http.client sets TCP_NODELAY and a Unix socketpair rejects it) while two threads move bytes to and from the device with the credit flow control of the design (uplink bounded by the granted credit, downlink granting credit back for whatever the app read; FIN as half-close, RST closes). A Manager routes TCP messages and hands out host connection ids. - device.py: dispatches TCP messages to the Manager, exposes _send/_conn_gone. - airdrop.py: runs the patched OpenDrop unchanged, only replacing HTTPSConnectionAWDL's connection factory with one that dials through the proxy; find_receiver + send_files. - cli.py: the send subcommand (-r id, -n name, files), with the ESP's BLE wake. Tests: a TCP-bridging fake device forwards connections to a real patched OpenDrop receiver on IPv4 loopback (loopback_receiver.py), and the new end-to-end test sends a file through proxy, credits, TLS, Discover, Ask and Upload and checks it arrives - runs in the container without IPv6. 19 host tests pass. Not yet tested on hardware. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01XzgaN4Bv1m8Ka9iqo7QVUN --- docs/SPIKES.md | 26 ++++ host/espdrop/airdrop.py | 113 ++++++++++++++++ host/espdrop/cli.py | 55 +++++++- host/espdrop/device.py | 15 +++ host/espdrop/proxy.py | 220 ++++++++++++++++++++++++++++++++ host/tests/apple_mdns.py | 6 +- host/tests/fake_device.py | 73 ++++++++++- host/tests/loopback_receiver.py | 48 +++++++ host/tests/test_host.py | 37 ++++++ 9 files changed, 588 insertions(+), 5 deletions(-) create mode 100644 host/espdrop/airdrop.py create mode 100644 host/espdrop/proxy.py create mode 100644 host/tests/loopback_receiver.py diff --git a/docs/SPIKES.md b/docs/SPIKES.md index 35e8057..29d7c8d 100644 --- a/docs/SPIKES.md +++ b/docs/SPIKES.md @@ -358,6 +358,32 @@ Tests ohne Hardware (simulierter ESP mit iPad-Antworten): ~/opendrop-venv/bin/python -m unittest discover -s host/tests ``` +## M4.3: senden über den ESP (`espdrop send`) + +Senden läuft jetzt auch über den eigenen IP-Stack des ESP, ohne `awdl0`. +Die Host-App baut über den ESP eine TCP-Verbindung zum iPhone/iPad auf +(Socket-Proxy mit Kreditsteuerung) und fährt darauf das gepatchte OpenDrop +(TLS, `/Discover`, `/Ask`, `/Upload`) unverändert, nur mit ausgetauschter +Verbindungsschicht. + +```sh +~/opendrop-venv/bin/python tools/espdrop.py send foto.jpg +~/opendrop-venv/bin/python tools/espdrop.py send -r "Annas iPhone" -n MeinPC a.pdf b.png +``` +`send` startet das BLE-Wecksignal des ESP, sucht den Empfänger, baut die +Verbindung auf und überträgt; am iPad „Annehmen“ tippen. Bedingungen wie +immer: AirDrop „Für alle“, entsperrt, Teilen-Menü offen. + +Tests ohne Hardware: Zum simulierten ESP kommt ein TCP-überbrückender +(`TcpBridgeDevice`), der Verbindungen an einen echten, auf `127.0.0.1` +laufenden OpenDrop-Empfänger (`loopback_receiver.py`) weiterreicht. Der Test +`EndToEndTest.test_send_arrives` sendet so eine Datei komplett durch Proxy, +Kredite, TLS, `/Discover`, `/Ask` und `/Upload` und prüft, dass sie ankommt +(läuft im Container, wo IPv6 fehlt, weil der Proxy intern IPv4-Loopback nutzt). +```sh +~/opendrop-venv/bin/python -m unittest discover -s host/tests +``` + ## Danach Ergebnisse (auch Fehlschläge) in `docs/UEBERBLICK.md` unter „Offene Fragen" diff --git a/host/espdrop/airdrop.py b/host/espdrop/airdrop.py new file mode 100644 index 0000000..fb10c9f --- /dev/null +++ b/host/espdrop/airdrop.py @@ -0,0 +1,113 @@ +# EspDrop - drive the patched OpenDrop over the device's socket proxy. +# +# OpenDrop (0.13.0 + the airdrop-mt7921 patches) does all of AirDrop: TLS, the +# /Discover, /Ask and /Upload requests, the dvzip/cpio archive. We reuse it +# unchanged and only replace how its HTTPS client reaches the receiver: instead +# of a kernel socket on awdl0 it gets one end of a TCP pair whose other end the +# device bridges to the AWDL peer (proxy.py). So the whole send path runs on +# Windows and Linux with no awdl0, no root and no firewall rule. +# +# SPDX-License-Identifier: GPL-3.0-or-later +# Copyright (C) 2026 EspDrop contributors + +import ipaddress +import logging +import os +import sys + +try: + from opendrop.client import AirDropClient + from opendrop.config import AirDropConfig +except ImportError as e: # pragma: no cover - depends on the environment + raise ImportError("OpenDrop is missing; run espdrop with the venv built by " + "tools/opendrop_install.sh (~/opendrop-venv/bin/python)") from e + +from .mdns import Browser + +logger = logging.getLogger(__name__) + + +def _addr16(text: str) -> bytes: + """A receiver's IPv6 string (zone stripped) to 16 bytes for the proxy.""" + return ipaddress.IPv6Address(text.split("%", 1)[0]).packed + + +def find_receiver(dev, timeout=30.0, want=None, on_packet=None): + """Browse for an AirDrop receiver on the device's AWDL link. + + Returns the first complete receiver (or the one whose id/name matches + `want`), or None. The caller sends the BLE wake.""" + import time + hit = {} + done = __import__("threading").Event() + + def found(r): + if want and want.lower() not in (r.id.lower(), (r.server or "").lower()): + return + hit.setdefault("r", r) + done.set() + + b = Browser(dev, on_found=found, on_packet=on_packet) + b.start() + try: + done.wait(timeout) + finally: + b.stop() + return hit.get("r") or (b.receivers()[0] if b.receivers() and not want else None) + + +def _bind_client(client, dev, peer16, name=""): + """Make `client`'s HTTPS connections go through the device's proxy.""" + def factory(address, timeout=None, source_address=None): + port = address[1] + conn = dev.tcp.connect(peer16, port, name=name or f"airdrop:{port}") + if not conn.opened.wait(timeout or 30): + raise OSError(f"no connection to [{ipaddress.IPv6Address(peer16)}]:{port} " + f"(the device did not open it)") + if conn.error: + raise OSError(f"connection to port {port} failed: {conn.error}") + return conn.app + + # AirDropClient builds its HTTPSConnectionAWDL lazily inside send_POST, so we + # cannot reach the instance beforehand. Wrap the class __init__ to install + # our factory on every connection it opens; http.client calls + # self._create_connection((host, port), timeout, src) in connect(), and + # HTTPSConnection then wraps the returned socket in TLS itself. Restored on + # teardown so nothing else in the process is affected. + from opendrop import client as odc + base_init = odc.HTTPSConnectionAWDL.__init__ + + def init(self, *a, **kw): + base_init(self, *a, **kw) + self._create_connection = factory + + odc.HTTPSConnectionAWDL.__init__ = init + client._restore_conn_init = lambda: setattr(odc.HTTPSConnectionAWDL, "__init__", base_init) + + +def send_files(dev, receiver, files, name="EspDrop", timeout=60.0): + """Send files to a Receiver through the device. Returns 0 on success.""" + peer16 = _addr16(receiver.parsed_addresses()[0]) + config = AirDropConfig(computer_name=name, interface="awdl0") + client = AirDropClient(config, (str(ipaddress.IPv6Address(peer16)), int(receiver.port))) + _bind_client(client, dev, peer16, name=name) + try: + logger.info("Asking the receiver to accept ...") + if not client.send_ask(files): + logger.warning("the receiver declined") + return 1 + logger.info("accepted; uploading ...") + if not client.send_upload(files, reask=lambda: client.send_ask(files)): + logger.warning("the upload failed") + return 1 + logger.info("sent") + return 0 + finally: + restore = getattr(client, "_restore_conn_init", None) + if restore: + restore() + if client.http_conn is not None: + try: + client.http_conn.close() + except OSError: + pass diff --git a/host/espdrop/cli.py b/host/espdrop/cli.py index e5caea4..2e2979d 100644 --- a/host/espdrop/cli.py +++ b/host/espdrop/cli.py @@ -11,6 +11,7 @@ # Copyright (C) 2026 EspDrop contributors import argparse +import os import sys import time @@ -94,6 +95,49 @@ def packet(src, mcast, what): dev.close() +def cmd_send(args, transport=None): + from .airdrop import find_receiver, send_files + for f in args.file: + if not os.path.isfile(f): + print(f"error: no such file: {f}", file=sys.stderr) + return 1 + dev = _open(args, transport) + woke = False + try: + h = _hello(dev) + if not args.no_wake and h["caps"] & link.CAP_BLE: + secs = min(600, int(args.time) + 120) # also covers Accept and the upload + reply = dev.cmd(f"wake {secs}") + woke = reply.startswith("wake on") + print(f"BLE wake from the ESP: {reply}", flush=True) + elif not args.no_wake: + print("no Bluetooth wake in this firmware: wake the target otherwise", flush=True) + + def packet(src, mcast, what): + print(f" mdns {src or '-'}: {what}", flush=True) + + print(f"looking for {args.receiver or 'a receiver'} " + f"(target: AirDrop Everyone, unlocked, share sheet OPEN) ...", flush=True) + r = find_receiver(dev, timeout=args.time, want=args.receiver, + on_packet=packet if args.verbose else None) + if r is None: + print("no AirDrop receiver found; `espdrop find -v` shows what the browse saw", + file=sys.stderr) + return 1 + print(f"sending {len(args.file)} file(s) to {r.id} [{r.parsed_addresses()[0]}]:{r.port} " + f"... (tap Accept on the device)", flush=True) + rc = send_files(dev, r, args.file, name=args.name) + print("sent." if rc == 0 else "send failed.", flush=True) + return rc + finally: + if woke: + try: + dev.cmd("wake off") + except DeviceError: + pass + dev.close() + + def cmd_status(args, transport=None): dev = _open(args, transport) try: @@ -126,6 +170,15 @@ def main(argv=None, transport=None): f.add_argument("-t", "--time", type=float, default=30.0, help="seconds to browse") f.add_argument("--no-wake", action="store_true", help="do not send the BLE wake") f.add_argument("-v", "--verbose", action="store_true", help="show every AirDrop mDNS message") + f.set_defaults(name="EspDrop") + s = sub.add_parser("send", help="send files to an AirDrop receiver") + s.add_argument("file", nargs="+") + s.add_argument("-r", "--receiver", help="receiver id or host name (default: the first found)") + s.add_argument("-n", "--name", default="EspDrop", help="sender name shown on the device") + s.add_argument("-t", "--time", type=float, default=30.0, help="seconds to look for the receiver") + s.add_argument("--no-wake", action="store_true", help="do not send the BLE wake") + s.add_argument("-v", "--verbose", action="store_true") + sub.add_parser("status", help="device, interface and counters") c = sub.add_parser("cmd", help="send one device command") c.add_argument("text", nargs="+") @@ -133,7 +186,7 @@ def main(argv=None, transport=None): c.add_argument("--wait", type=float, default=12.0, help="ping6: seconds to show replies") c.set_defaults(verbose=True) args = ap.parse_args(argv) - handlers = {"find": cmd_find, "status": cmd_status, "cmd": cmd_cmd} + handlers = {"find": cmd_find, "send": cmd_send, "status": cmd_status, "cmd": cmd_cmd} try: return handlers[args.action](args, transport) except (DeviceError, OSError) as e: diff --git a/host/espdrop/device.py b/host/espdrop/device.py index 2caead8..a327fac 100644 --- a/host/espdrop/device.py +++ b/host/espdrop/device.py @@ -10,6 +10,7 @@ import threading from . import link +from .proxy import Manager class DeviceError(Exception): @@ -30,6 +31,7 @@ def __init__(self, port, on_log=None): self._results = {} # (request type, id) -> err, for waiters self._result_cond = threading.Condition() self._udp = {} # port -> handler(src16, sport, multicast, data) + self.tcp = Manager(self) # proxied TCP connections self._stop = threading.Event() self.error = None # set if the reader thread died self._reader = threading.Thread(target=self._read_loop, name="espdrop-link", @@ -74,6 +76,9 @@ def _dispatch(self, t, p): handler = self._udp.get(u[0]) if handler: handler(*u[1:]) + elif t in (link.MSG_TCP_OPENED, link.MSG_TCP_DATA, link.MSG_TCP_CREDIT, + link.MSG_TCP_CLOSE): + self.tcp.dispatch(t, p) elif t == link.MSG_RESULT: r = link.parse_result(p) if r: @@ -145,7 +150,17 @@ def udp_close(self, port): self._udp.pop(port, None) self._write(link.udp_close(port)) + # ---- used by proxy.py -------------------------------------------------- + + def _send(self, framed: bytes): + """Write one already-framed message (proxy TCP_* traffic).""" + self._write(framed) + + def _conn_gone(self, cid): + self.tcp.forget(cid) + def close(self): + self.tcp.close_all() self._stop.set() self._reader.join(timeout=1.0) self.port.close() diff --git a/host/espdrop/proxy.py b/host/espdrop/proxy.py new file mode 100644 index 0000000..d9e94c6 --- /dev/null +++ b/host/espdrop/proxy.py @@ -0,0 +1,220 @@ +# EspDrop - TCP connections through the device as ordinary local sockets. +# +# A TcpConn is one connection the device holds to an AWDL peer. The application +# gets `app`, a normal TCP socket on 127.0.0.1 (a TCP pair rather than a Unix +# socketpair: http.client sets TCP_NODELAY and fails on anything else). Two +# threads move the bytes between the other end and the device, with the +# credit-based flow control of docs/M4_ENTWURF.md section 4.3: +# - uplink (app -> peer): never more outstanding than the device granted +# (TCP_OPENED credit, then TCP_CREDIT for every byte the peer ACKed); +# - downlink (peer -> app): every byte written to the local socket is granted +# back to the device (TCP_CREDIT), so a slow application slows the peer. +# FIN travels both ways as a half-close; RST/errors close the local socket. +# +# A Manager owns the connection table: it routes the device's TCP messages to +# the right TcpConn and hands out host-side connection ids (0x0001..0x7fff). +# +# SPDX-License-Identifier: GPL-3.0-or-later +# Copyright (C) 2026 EspDrop contributors + +import queue +import socket +import threading + +from . import link + +_FIN = object() +_RST = object() + + +def tcp_pair(): + """Two connected TCP sockets on the loopback interface.""" + lst = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + try: + lst.bind(("127.0.0.1", 0)) + lst.listen(1) + a = socket.create_connection(lst.getsockname()) + b, _ = lst.accept() + finally: + lst.close() + for s in (a, b): + s.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1) + return a, b + + +class TcpConn: + """One proxied connection. `app` is the local socket the caller uses; the + class drives the other end of the pair against the device.""" + + def __init__(self, dev, conn_id, max_chunk=1400, name=""): + self.dev = dev + self.id = conn_id + self.name = name or f"tcp{conn_id}" + self.max_chunk = max_chunk + self.app, self._own = tcp_pair() + self.opened = threading.Event() + self.closed = threading.Event() + self.error = None # text, when the connection failed or was reset + self.peer = None # (addr16, port) once opened + self._credit = 0 # bytes we may still send the device (uplink) + self._cv = threading.Condition() + self._down = queue.Queue() # payloads / _FIN / _RST from the device + self._dead = False + self.bytes_up = self.bytes_down = 0 + + # ---- device events (reader thread; must not block) --------------------- + + def on_opened(self, peer16, rport, credit): + self.peer = (peer16, rport) + with self._cv: + self._credit = credit + threading.Thread(target=self._uplink, name=f"{self.name}-up", daemon=True).start() + threading.Thread(target=self._downlink, name=f"{self.name}-down", daemon=True).start() + self.opened.set() + + def on_data(self, data): + self._down.put(data) + + def on_credit(self, n): + with self._cv: + self._credit += n + self._cv.notify_all() + + def on_close(self, kind, err): + if kind == link.TCP_FIN: + self._down.put(_FIN) + return + # RST or an lwIP error on the device side. + self.error = self.error or f"reset (err {err}: {link.ERRORS.get(err, 'lwIP')})" + with self._cv: + self._dead = True + self._cv.notify_all() + if self.opened.is_set(): + self._down.put(_RST) + else: + self.opened.set() # a failed connect: wake the waiter + self._finish() + + # ---- pumps ------------------------------------------------------------- + + def _uplink(self): + """app -> device, respecting the credit the device granted.""" + try: + while True: + with self._cv: + self._cv.wait_for(lambda: self._credit > 0 or self._dead) + if self._dead: + return + want = min(self._credit, self.max_chunk) + try: + data = self._own.recv(want) + except OSError: + data = b"" + if not data: + break # the application closed its send side + with self._cv: + self._credit -= len(data) + self.dev._send(link.tcp_data(self.id, data)) + self.bytes_up += len(data) + finally: + if not self._dead: + self.dev._send(link.tcp_close(self.id, link.TCP_FIN)) + + def _downlink(self): + """device -> app, granting credit back for whatever the app took.""" + try: + while True: + item = self._down.get() + if item is _FIN: + try: + self._own.shutdown(socket.SHUT_WR) # EOF to the application + except OSError: + pass + return + if item is _RST: + return + try: + self._own.sendall(item) + except OSError: + return + self.bytes_down += len(item) + self.dev._send(link.tcp_credit(self.id, len(item))) + finally: + self._finish() + + def _finish(self): + """Close the local sockets once; wake anyone waiting on the connection.""" + if self.closed.is_set(): + return + self.closed.set() + for s in (self._own, self.app): + try: + s.close() + except OSError: + pass + self.dev._conn_gone(self.id) + + +class Manager: + """Routes the device's TCP messages to connections and allocates ids.""" + + def __init__(self, dev): + self.dev = dev + self._conns = {} + self._lock = threading.Lock() + self._next = 1 # host ids: 0x0001..0x7fff (device uses 0x8000+) + + def connect(self, peer16: bytes, port: int, name="") -> TcpConn: + """Open a connection to [peer]:port through the device; returns at once. + Wait on conn.opened, then check conn.error.""" + with self._lock: + cid = self._next + self._next = self._next + 1 if self._next < 0x7fff else 1 + chunk = (self.dev.hello_info or {}).get("max_chunk", 1400) + conn = TcpConn(self.dev, cid, max_chunk=chunk, name=name) + self._conns[cid] = conn + self.dev._send(link.tcp_connect(cid, peer16, port)) + return conn + + def forget(self, cid): + with self._lock: + self._conns.pop(cid, None) + + def close_all(self): + with self._lock: + conns = list(self._conns.values()) + for c in conns: + if not c._dead: + try: + self.dev._send(link.tcp_close(c.id, link.TCP_RST)) + except Exception: + pass + c._finish() + + # ---- called by Device's reader thread ---------------------------------- + + def dispatch(self, msg_type, payload): + if msg_type == link.MSG_TCP_OPENED: + o = link.parse_tcp_opened(payload) + if o: + cid, peer16, rport, _lport, _accepted, credit = o + c = self._conns.get(cid) + if c: + c.on_opened(peer16, rport, credit) + elif msg_type == link.MSG_TCP_DATA: + if len(payload) >= 2: + c = self._conns.get(int.from_bytes(payload[:2], "little")) + if c: + c.on_data(payload[2:]) + elif msg_type == link.MSG_TCP_CREDIT: + if len(payload) >= 6: + c = self._conns.get(int.from_bytes(payload[:2], "little")) + if c: + c.on_credit(int.from_bytes(payload[2:6], "little")) + elif msg_type == link.MSG_TCP_CLOSE: + cc = link.parse_tcp_close(payload) + if cc: + cid, kind, err = cc + c = self._conns.get(cid) + if c: + c.on_close(kind, err) diff --git a/host/tests/apple_mdns.py b/host/tests/apple_mdns.py index 26677aa..4931786 100644 --- a/host/tests/apple_mdns.py +++ b/host/tests/apple_mdns.py @@ -79,7 +79,7 @@ def f(m): return f -def response(kind="small", pad_id=PAD_ID, flags="111611", ttl_ptr=4500): +def response(kind="small", pad_id=PAD_ID, flags="111611", ttl_ptr=4500, port=8770): """kind: 'small' (PTR answer + TXT/SRV/AAAA/NSEC additionals), 'full' (the big announcement), 'goodbye' (PTR with TTL 0), 'other' (no AirDrop at all).""" ad = f"{pad_id}._airdrop._tcp.local." @@ -88,7 +88,7 @@ def response(kind="small", pad_id=PAD_ID, flags="111611", ttl_ptr=4500): if kind == "small": ans = [("_airdrop._tcp.local.", 12, False, ttl_ptr, _name(ad))] add = [(ad, 16, True, 4500, _txt(f"flags={flags}")), - (ad, 33, True, 120, _srv(8770)), + (ad, 33, True, 120, _srv(port)), (HOST, 28, True, 120, _aaaa), (ad, 47, True, 4500, _nsec(ad, [16, 33])), (HOST, 47, True, 120, _nsec(HOST, [28]))] @@ -101,7 +101,7 @@ def response(kind="small", pad_id=PAD_ID, flags="111611", ttl_ptr=4500): _name("_applicationServicePairing._tcp.local.")), ("_applicationServicePairing._tcp.local.", 12, False, 4500, _name(asp)), (HOST, 28, True, 120, _aaaa), - (ad, 33, True, 120, _srv(8770)), + (ad, 33, True, 120, _srv(port)), (asp, 33, True, 120, _srv(49842))] add = [(ad, 47, True, 4500, _nsec(ad, [16, 33])), (asp, 47, True, 4500, _nsec(asp, [16, 33])), diff --git a/host/tests/fake_device.py b/host/tests/fake_device.py index 11f3791..57178c9 100644 --- a/host/tests/fake_device.py +++ b/host/tests/fake_device.py @@ -28,6 +28,7 @@ def __init__(self, answers=None, caps=link.CAP_BLE | link.CAP_IP, version=link.P # answers: {n: [kind, ...]} - after the n-th AirDrop query (1-based) the # device delivers these apple_mdns.response() kinds from the iPad. self.answers = answers or {} + self.mdns_port = 8770 # SRV port in the canned iPad answers self.caps, self.version, self.ip_ok, self.mac = caps, version, ip_ok, mac self.mode = link.MODE_L2 self.udp_ports = set() @@ -99,9 +100,79 @@ def _handle(self, t, p): if is_query and b"_airdrop" in data: self.queries.append(data) for kind in self.answers.get(len(self.queries), []): - self.deliver(response(kind)) + self.deliver(response(kind, port=self.mdns_port)) def deliver(self, data, src=PAD_IP.packed, sport=5353, multicast=True, port=5353): """Hand the host a datagram as if it had arrived over AWDL.""" self._emit(link.MSG_UDP_RECV, struct.pack(" socket + self._credit_each = 65536 + + def _handle(self, t, p): + import socket + import struct + import threading + if t == link.MSG_TCP_CONNECT and len(p) >= 20: + cid = struct.unpack_from("= 2: + cid = struct.unpack_from("= 3: + cid = struct.unpack_from(" Date: Fri, 23 Oct 2026 15:51:31 +0000 Subject: [PATCH 71/99] docs(m4.3): espdrop send works on hardware Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01XzgaN4Bv1m8Ka9iqo7QVUN --- docs/SPIKES.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/docs/SPIKES.md b/docs/SPIKES.md index 29d7c8d..e2f4db0 100644 --- a/docs/SPIKES.md +++ b/docs/SPIKES.md @@ -374,6 +374,11 @@ Verbindungsschicht. Verbindung auf und überträgt; am iPad „Annehmen“ tippen. Bedingungen wie immer: AirDrop „Für alle“, entsperrt, Teilen-Menü offen. +Ergebnis (2026-10-23, C5): Senden an ein iPad erfolgreich über `espdrop send`, +ohne `awdl0`/root/Firewall. `ip` danach: `tcp: opened=1 to_host=1137 +from_host=146499 err=0` (eine Verbindung, ~146 KB Datei hin, ~1,1 KB HTTP +zurück), `learned=126`, `heap=74240` in der IP-Betriebsart. + Tests ohne Hardware: Zum simulierten ESP kommt ein TCP-überbrückender (`TcpBridgeDevice`), der Verbindungen an einen echten, auf `127.0.0.1` laufenden OpenDrop-Empfänger (`loopback_receiver.py`) weiterreicht. Der Test From 3b83342f745797936df4cdbcc3d4bf4c77f8e993 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 23 Oct 2026 15:58:57 +0000 Subject: [PATCH 72/99] feat(m4.4): espdrop receive over the ESP's own IP stack Receiving no longer needs awdl0, root or a firewall rule either. Firmware - sockproxy: TCP_LISTEN opens a listening pcb on the AWDL interface; on accept it allocates a device-side id (0x8000+), sends TCP_OPENED with accepted=1 and reuses the same data/credit/close path as an outbound connection. One listener; reset closes it. Host - proxy.Manager.listen + dispatch: a TCP_OPENED for an id the host did not allocate (accepted) makes a TcpConn and calls the accept callback. - device.listen(port, on_accept). - mdns.Responder: announces _airdrop._tcp (SRV/TXT/AAAA, AAAA = the ESP's EUI-64 link-local from the HELLO mac) every 5 s and answers PTR queries, unicast for a QU question; goodbye on stop. - receive.py + espdrop receive: the patched OpenDrop server on 127.0.0.1, the device listening on 8771, each accepted connection spliced to a fresh loopback connection into that server, announced by the responder. Tests: an inbound-bridging fake device and a real OpenDrop sender drive /Discover, /Ask, /Upload against espdrop receive's server; the new ReceiveEndToEndTest checks the file arrives. 20 host tests pass. C5/C3 bridge and C5 console build. Not yet tested on hardware. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01XzgaN4Bv1m8Ka9iqo7QVUN --- docs/SPIKES.md | 20 +++++ firmware/spikes/main/bridge.c | 1 + firmware/spikes/main/sockproxy.c | 58 +++++++++++++- host/espdrop/cli.py | 27 ++++++- host/espdrop/device.py | 4 + host/espdrop/link.py | 4 + host/espdrop/mdns.py | 73 ++++++++++++++++++ host/espdrop/proxy.py | 21 ++++- host/espdrop/receive.py | 128 +++++++++++++++++++++++++++++++ host/tests/fake_device.py | 71 +++++++++++++++++ host/tests/test_host.py | 73 ++++++++++++++++++ 11 files changed, 475 insertions(+), 5 deletions(-) create mode 100644 host/espdrop/receive.py diff --git a/docs/SPIKES.md b/docs/SPIKES.md index e2f4db0..b689c57 100644 --- a/docs/SPIKES.md +++ b/docs/SPIKES.md @@ -389,6 +389,26 @@ Kredite, TLS, `/Discover`, `/Ask` und `/Upload` und prüft, dass sie ankommt ~/opendrop-venv/bin/python -m unittest discover -s host/tests ``` +## M4.4: empfangen über den ESP (`espdrop receive`) + +Empfangen läuft jetzt ebenfalls über den eigenen IP-Stack des ESP. Die Firmware +nimmt eingehende AWDL-Verbindungen an (`TCP_LISTEN`/accept), die Host-App meldet +sich per mDNS als Empfänger an (Responder, alle 5 s, mit AAAA = Link-Local des +ESP aus der HELLO-MAC) und fährt den OpenDrop-Server auf `127.0.0.1`; jede +angenommene Verbindung wird dorthin durchgereicht. + +```sh +~/opendrop-venv/bin/python tools/espdrop.py receive -n MeinPC +``` +Am iPhone/iPad eine Datei teilen und „MeinPC“ wählen; die Datei landet im +aktuellen Verzeichnis. `awdl_bridge.py` darf dabei nicht laufen. + +Tests ohne Hardware: Ein eingehend überbrückender Fake (`AcceptBridgeDevice`) +simuliert ein verbindendes Apple-Gerät; ein echter OpenDrop-Sender fährt +`/Discover`, `/Ask`, `/Upload` gegen den von `espdrop receive` gestarteten +Server. `ReceiveEndToEndTest` prüft, dass die Datei ankommt. Zusammen 20 +Host-Tests. + ## Danach Ergebnisse (auch Fehlschläge) in `docs/UEBERBLICK.md` unter „Offene Fragen" diff --git a/firmware/spikes/main/bridge.c b/firmware/spikes/main/bridge.c index 885e628..0df2d15 100644 --- a/firmware/spikes/main/bridge.c +++ b/firmware/spikes/main/bridge.c @@ -662,6 +662,7 @@ static void handle_msg(const uint8_t *msg, size_t len) { case BRIDGE_MSG_UDP_OPEN: case BRIDGE_MSG_UDP_SEND: case BRIDGE_MSG_UDP_CLOSE: + case BRIDGE_MSG_TCP_LISTEN: case BRIDGE_MSG_TCP_CONNECT: case BRIDGE_MSG_TCP_DATA: case BRIDGE_MSG_TCP_CREDIT: diff --git a/firmware/spikes/main/sockproxy.c b/firmware/spikes/main/sockproxy.c index 3186678..c9918d4 100644 --- a/firmware/spikes/main/sockproxy.c +++ b/firmware/spikes/main/sockproxy.c @@ -67,6 +67,8 @@ typedef struct { } tcp_conn_t; static tcp_conn_t s_tcp[SOCKPROXY_TCP_SLOTS]; static uint32_t s_host_credit = 65536; /* from HELLO */ +static struct tcp_pcb *s_listen; /* the one listening pcb, if any */ +static uint16_t s_next_accept = 0x8000; /* device-allocated ids for accepts */ /* Set when tcp_abort ran: a callback that did that must return ERR_ABRT. */ static bool s_aborted; @@ -204,6 +206,9 @@ static void udp_send_job(const uint8_t *d, size_t n) { /* ---- TCP --------------------------------------------------------------------- */ +static void tcp_setup(tcp_conn_t *c, struct tcp_pcb *pcb); +static void tcp_post_opened(tcp_conn_t *c, struct tcp_pcb *pcb, uint8_t accepted); + static tcp_conn_t *tcp_find(uint16_t id) { for (int i = 0; i < SOCKPROXY_TCP_SLOTS; i++) if (s_tcp[i].used && s_tcp[i].id == id) return &s_tcp[i]; @@ -350,16 +355,37 @@ static err_t tcp_on_connected(void *arg, struct tcp_pcb *pcb, err_t err) { if (err != ERR_OK) { /* lwIP reports a failed connect through tcp_err */ return err; } + tcp_post_opened(c, pcb, 0); /* we connected, not accepted */ + return ERR_OK; +} + +static err_t tcp_on_accept(void *arg, struct tcp_pcb *pcb, err_t err) { + (void)arg; + if (err != ERR_OK || !pcb) return ERR_VAL; + tcp_conn_t *c = NULL; + for (int i = 0; i < SOCKPROXY_TCP_SLOTS && !c; i++) + if (!s_tcp[i].used) c = &s_tcp[i]; + if (!c) { tcp_abort(pcb); return ERR_ABRT; } /* no slot: refuse */ + memset(c, 0, sizeof(*c)); + c->used = true; + c->id = s_next_accept; + s_next_accept = s_next_accept < 0xffff ? s_next_accept + 1 : 0x8000; + c->host_credit = s_host_credit; + tcp_setup(c, pcb); + tcp_post_opened(c, pcb, 1); + return ERR_OK; +} + +static void tcp_post_opened(tcp_conn_t *c, struct tcp_pcb *pcb, uint8_t accepted) { uint8_t m[2 + 16 + 2 + 2 + 1 + 4]; wr16(m, c->id); memcpy(m + 2, ip_2_ip6(&pcb->remote_ip)->addr, 16); wr16(m + 18, pcb->remote_port); wr16(m + 20, pcb->local_port); - m[22] = 0; /* we connected, not accepted */ + m[22] = accepted; wr32(m + 23, tcp_sndbuf(pcb)); /* the host may send this much */ post_ctl(BRIDGE_MSG_TCP_OPENED, m, sizeof(m)); s_tcp_opened++; - return ERR_OK; } static void tcp_setup(tcp_conn_t *c, struct tcp_pcb *pcb) { @@ -408,6 +434,32 @@ static void tcp_connect_job(const uint8_t *d, size_t n) { } } +static void tcp_listen_job(const uint8_t *d, size_t n) { + if (n < 2) { send_result(BRIDGE_MSG_TCP_LISTEN, 0, SOCKPROXY_EBADREQ); return; } + uint16_t port = rd16(d); + struct netif *nif = awdl_netif_get(); + if (!nif || !awdl_netif_is_up()) { send_result(BRIDGE_MSG_TCP_LISTEN, port, SOCKPROXY_EDOWN); return; } + if (s_listen) { send_result(BRIDGE_MSG_TCP_LISTEN, port, SOCKPROXY_OK); return; } + struct tcp_pcb *pcb = tcp_new_ip_type(IPADDR_TYPE_V6); + if (!pcb) { send_result(BRIDGE_MSG_TCP_LISTEN, port, SOCKPROXY_ENOMEM); return; } + tcp_bind_netif(pcb, nif); + if (tcp_bind(pcb, IP6_ADDR_ANY, port) != ERR_OK) { + tcp_close(pcb); + send_result(BRIDGE_MSG_TCP_LISTEN, port, SOCKPROXY_EFAIL); + return; + } + err_t e = ERR_OK; + struct tcp_pcb *lpcb = tcp_listen_with_backlog_and_err(pcb, SOCKPROXY_TCP_SLOTS, &e); + if (!lpcb) { + tcp_close(pcb); + send_result(BRIDGE_MSG_TCP_LISTEN, port, SOCKPROXY_EFAIL); + return; + } + s_listen = lpcb; + tcp_accept(lpcb, tcp_on_accept); + send_result(BRIDGE_MSG_TCP_LISTEN, port, SOCKPROXY_OK); +} + static void tcp_data_job(const uint8_t *d, size_t n) { if (n < 2) return; uint16_t id = rd16(d); @@ -479,6 +531,7 @@ static void job_run(void *arg) { if (s) udp_close_slot(s); } break; + case BRIDGE_MSG_TCP_LISTEN: tcp_listen_job(j->data, j->len); break; case BRIDGE_MSG_TCP_CONNECT: tcp_connect_job(j->data, j->len); break; case BRIDGE_MSG_TCP_DATA: tcp_data_job(j->data, j->len); break; case BRIDGE_MSG_TCP_CREDIT: tcp_credit_job(j->data, j->len); break; @@ -513,6 +566,7 @@ static uint32_t s_reset_credit; static void reset_job(void *arg) { (void)arg; for (int i = 0; i < UDP_SLOTS; i++) udp_close_slot(&s_udp[i]); + if (s_listen) { tcp_close(s_listen); s_listen = NULL; } for (int i = 0; i < SOCKPROXY_TCP_SLOTS; i++) if (s_tcp[i].used) tcp_release(&s_tcp[i], true); s_host_credit = s_reset_credit ? s_reset_credit : 65536; diff --git a/host/espdrop/cli.py b/host/espdrop/cli.py index 2e2979d..1d5fd1b 100644 --- a/host/espdrop/cli.py +++ b/host/espdrop/cli.py @@ -138,6 +138,27 @@ def packet(src, mcast, what): dev.close() +def cmd_receive(args, transport=None): + import time + from .receive import Receiver + dev = _open(args, transport) + rx = Receiver(dev, name=args.name) + try: + _hello(dev) + rx.start() + print(f"ready: on the iPhone/iPad, share a file and pick {args.name!r}. " + f"Ctrl+C to stop.", flush=True) + try: + while not dev.error: + time.sleep(0.3) + except KeyboardInterrupt: + print() + return 0 + finally: + rx.stop() + dev.close() + + def cmd_status(args, transport=None): dev = _open(args, transport) try: @@ -179,6 +200,9 @@ def main(argv=None, transport=None): s.add_argument("--no-wake", action="store_true", help="do not send the BLE wake") s.add_argument("-v", "--verbose", action="store_true") + rv = sub.add_parser("receive", help="receive files from an Apple device") + rv.add_argument("-n", "--name", default="EspDrop", help="name shown on the sender") + sub.add_parser("status", help="device, interface and counters") c = sub.add_parser("cmd", help="send one device command") c.add_argument("text", nargs="+") @@ -186,7 +210,8 @@ def main(argv=None, transport=None): c.add_argument("--wait", type=float, default=12.0, help="ping6: seconds to show replies") c.set_defaults(verbose=True) args = ap.parse_args(argv) - handlers = {"find": cmd_find, "send": cmd_send, "status": cmd_status, "cmd": cmd_cmd} + handlers = {"find": cmd_find, "send": cmd_send, "receive": cmd_receive, + "status": cmd_status, "cmd": cmd_cmd} try: return handlers[args.action](args, transport) except (DeviceError, OSError) as e: diff --git a/host/espdrop/device.py b/host/espdrop/device.py index a327fac..ef8154b 100644 --- a/host/espdrop/device.py +++ b/host/espdrop/device.py @@ -150,6 +150,10 @@ def udp_close(self, port): self._udp.pop(port, None) self._write(link.udp_close(port)) + def listen(self, port, on_accept): + """Accept AWDL connections to `port`; on_accept(conn) per connection.""" + self.tcp.listen(port, on_accept) + # ---- used by proxy.py -------------------------------------------------- def _send(self, framed: bytes): diff --git a/host/espdrop/link.py b/host/espdrop/link.py index 098d107..b5cd9df 100644 --- a/host/espdrop/link.py +++ b/host/espdrop/link.py @@ -189,6 +189,10 @@ def parse_result(p: bytes): TCP_RST = 1 +def tcp_listen(port: int) -> bytes: + return frame(MSG_TCP_LISTEN, struct.pack(" bytes: if len(dst) != 16: raise ValueError("dst must be a 16-byte IPv6 address") diff --git a/host/espdrop/mdns.py b/host/espdrop/mdns.py index 75cb640..b118a20 100644 --- a/host/espdrop/mdns.py +++ b/host/espdrop/mdns.py @@ -205,3 +205,76 @@ def _get(self, key, shown, src): rec.source = src rec.last_seen = time.monotonic() return rec + + +class Responder: + """Announce our AirDrop receiver on the AWDL link (docs/M4_ENTWURF.md 5.3). + + Builds our _airdrop._tcp records with python-zeroconf's ServiceInfo, sends + an unsolicited announcement every `interval` seconds (as the + opendrop-mdns-reannounce patch does: the iPhone only ever learns of us by + catching an announce burst over a link we share for a fraction of each + sequence), answers PTR queries, and sends a goodbye on stop.""" + + def __init__(self, device, instance_id, port, our_addr16, flags=0x3fb, + interval=5.0, host=None): + from zeroconf import ServiceInfo + self.device = device + self.addr16 = our_addr16 + self.server = host or f"{instance_id}.local." + name = f"{instance_id}._airdrop._tcp.local." + self.info = ServiceInfo(AIRDROP, name, port=port, + properties={b"flags": str(flags).encode()}, + server=self.server, addresses=[bytes(our_addr16)]) + self.interval = interval + self._stop = __import__("threading").Event() + self._thread = None + + def _announce_packet(self): + out = DNSOutgoing(const._FLAGS_QR_RESPONSE | const._FLAGS_AA) + for rec in ([self.info.dns_pointer(), self.info.dns_service(), + self.info.dns_text()] + list(self.info.dns_addresses())): + out.add_answer_at_time(rec, 0) + return out.packets()[0] + + def start(self): + self.device.udp_open(MDNS_PORT, self._on_udp, join_mdns=True) + self._thread = __import__("threading").Thread( + target=self._loop, name="espdrop-mdns-rsp", daemon=True) + self._thread.start() + + def _loop(self): + while not self._stop.is_set(): + try: + self.device.udp_send(MDNS_PORT, MDNS_GROUP, MDNS_PORT, self._announce_packet()) + except Exception: + pass + self._stop.wait(self.interval) + + def _on_udp(self, src16, sport, multicast, data): + msg = DNSIncoming(data, (str(ipaddress.IPv6Address(src16)), MDNS_PORT)) + if not msg.valid or not msg.is_query(): + return + if any(q.name.lower() == AIRDROP and q.type in (T_PTR, const._TYPE_ANY) + for q in msg.questions): + pkt = self._announce_packet() + # Answer a QU question by unicast too, then multicast. + if not multicast: + self.device.udp_send(MDNS_PORT, src16, sport, pkt) + self.device.udp_send(MDNS_PORT, MDNS_GROUP, MDNS_PORT, pkt) + + def stop(self): + self._stop.set() + if self._thread: + self._thread.join(timeout=2) + try: + out = DNSOutgoing(const._FLAGS_QR_RESPONSE | const._FLAGS_AA) + self.info.dns_pointer().ttl = 0 + out.add_answer_at_time(self.info.dns_pointer(), 0) + self.device.udp_send(MDNS_PORT, MDNS_GROUP, MDNS_PORT, out.packets()[0]) + except Exception: + pass + try: + self.device.udp_close(MDNS_PORT) + except Exception: + pass diff --git a/host/espdrop/proxy.py b/host/espdrop/proxy.py index d9e94c6..28bf6fc 100644 --- a/host/espdrop/proxy.py +++ b/host/espdrop/proxy.py @@ -163,6 +163,7 @@ def __init__(self, dev): self._conns = {} self._lock = threading.Lock() self._next = 1 # host ids: 0x0001..0x7fff (device uses 0x8000+) + self._on_accept = None # called with each accepted TcpConn def connect(self, peer16: bytes, port: int, name="") -> TcpConn: """Open a connection to [peer]:port through the device; returns at once. @@ -176,6 +177,12 @@ def connect(self, peer16: bytes, port: int, name="") -> TcpConn: self.dev._send(link.tcp_connect(cid, peer16, port)) return conn + def listen(self, port, on_accept): + """Ask the device to accept connections on `port`; on_accept(conn) runs + for each (the peer opened it; conn.app is ready).""" + self._on_accept = on_accept + self.dev._send(link.tcp_listen(port)) + def forget(self, cid): with self._lock: self._conns.pop(cid, None) @@ -197,9 +204,19 @@ def dispatch(self, msg_type, payload): if msg_type == link.MSG_TCP_OPENED: o = link.parse_tcp_opened(payload) if o: - cid, peer16, rport, _lport, _accepted, credit = o + cid, peer16, rport, _lport, accepted, credit = o c = self._conns.get(cid) - if c: + if c is None and accepted: + # The device allocated this id (0x8000+) for a peer that + # connected to our listener. + chunk = (self.dev.hello_info or {}).get("max_chunk", 1400) + c = TcpConn(self.dev, cid, max_chunk=chunk, name=f"accept{cid:#x}") + with self._lock: + self._conns[cid] = c + c.on_opened(peer16, rport, credit) + if self._on_accept: + self._on_accept(c) + elif c: c.on_opened(peer16, rport, credit) elif msg_type == link.MSG_TCP_DATA: if len(payload) >= 2: diff --git a/host/espdrop/receive.py b/host/espdrop/receive.py new file mode 100644 index 0000000..d36962d --- /dev/null +++ b/host/espdrop/receive.py @@ -0,0 +1,128 @@ +# EspDrop - receive files from an iPhone/iPad through the ESP (M4.4). +# +# The patched OpenDrop runs its real AirDrop server (TLS, /Discover, /Ask, +# /Upload, the archive handling) on an IPv4 loopback socket. The device accepts +# the AWDL connections the phone makes to us and, for each, we splice its bytes +# to a fresh loopback connection into that server. mDNS is announced over the +# device's UDP proxy (mdns.Responder). So receiving needs no awdl0, root or +# firewall rule either, on Windows and Linux. +# +# The server's TLS accept stays on its own listening socket (so it wraps each +# connection itself); we only move bytes, we do not re-implement TLS. +# +# SPDX-License-Identifier: GPL-3.0-or-later +# Copyright (C) 2026 EspDrop contributors + +import logging +import os +import socket +import threading +from http.server import ThreadingHTTPServer + +try: + from opendrop.config import AirDropConfig + from opendrop.server import AirDropServerHandler +except ImportError as e: # pragma: no cover + raise ImportError("OpenDrop is missing; run espdrop with the venv built by " + "tools/opendrop_install.sh") from e + +from . import mdns + +logger = logging.getLogger(__name__) +AIRDROP_PORT = 8771 + + +class _V4Server(ThreadingHTTPServer): + address_family = socket.AF_INET + daemon_threads = True + allow_reuse_address = True + + +def eui64_linklocal(mac6: bytes) -> bytes: + """The EUI-64 link-local the device uses on AWDL (matches awdl_netif.c).""" + m = bytearray(mac6) + iid = bytes([m[0] ^ 0x02, m[1], m[2], 0xff, 0xfe, m[3], m[4], m[5]]) + return b"\xfe\x80\x00\x00\x00\x00\x00\x00" + iid + + +class Receiver: + def __init__(self, dev, name="EspDrop", flags=0x3fb): + self.dev = dev + self.config = AirDropConfig(computer_name=name) + self.config.flags = flags + self.httpd = None + self.loop_port = None + self.responder = None + self._splices = [] + + # ---- the local OpenDrop server ----------------------------------------- + + def _start_server(self): + handler = AirDropServerHandler + handler.config = self.config + self.httpd = _V4Server(("127.0.0.1", 0), handler) + self.httpd.socket = self.config.get_ssl_context().wrap_socket( + self.httpd.socket, server_side=True) + self.loop_port = self.httpd.socket.getsockname()[1] + threading.Thread(target=self.httpd.serve_forever, name="espdrop-httpd", + daemon=True).start() + + # ---- one accepted AWDL connection -------------------------------------- + + def _on_accept(self, conn): + try: + local = socket.create_connection(("127.0.0.1", self.loop_port)) + except OSError as e: + logger.warning("could not reach the local server (%s)", e) + conn.app.close() + return + self._splice(conn.app, local) + self._splice(local, conn.app) + + def _splice(self, src, dst): + def pump(): + try: + while True: + data = src.recv(4096) + if not data: + break + dst.sendall(data) + except OSError: + pass + finally: + for s in (src, dst): + try: + s.shutdown(socket.SHUT_RDWR) + except OSError: + pass + # The partner pump sees EOF from the shutdown and returns; then + # both sockets can be closed (idempotent, so either pump may). + for s in (src, dst): + try: + s.close() + except OSError: + pass + t = threading.Thread(target=pump, daemon=True) + t.start() + self._splices.append(t) + + # ---- lifecycle --------------------------------------------------------- + + def start(self): + self._start_server() + mac = bytes(int(b, 16) for b in self.dev.hello_info["mac"].split(":")) + instance = f"{mac.hex()}" + self.responder = mdns.Responder(self.dev, instance, AIRDROP_PORT, + eui64_linklocal(mac), flags=self.config.flags, + host=f"{instance}.local.") + self.responder.start() + self.dev.listen(AIRDROP_PORT, self._on_accept) + logger.info("receiving as %r on the ESP's AWDL link (port %d); Ctrl+C to stop", + self.config.computer_name, AIRDROP_PORT) + + def stop(self): + if self.responder: + self.responder.stop() + if self.httpd: + self.httpd.shutdown() + self.httpd.server_close() diff --git a/host/tests/fake_device.py b/host/tests/fake_device.py index 57178c9..f6517a8 100644 --- a/host/tests/fake_device.py +++ b/host/tests/fake_device.py @@ -176,3 +176,74 @@ def _pump(self, cid, sk): sk.close() except OSError: pass + + +class AcceptBridgeDevice(FakeDevice): + """A FakeDevice for the receive path: it has a listener (TCP_LISTEN), and + open_incoming() simulates a peer connecting to it. The returned socket is + the sender's end; the device bridges it to the host as an accepted + connection (TCP_OPENED accepted=1, then TCP_DATA/TCP_CREDIT both ways).""" + + def __init__(self, **kw): + super().__init__(**kw) + self.listening = None # port, once TCP_LISTEN arrived + self._in = {} # accepted id -> sender-side socket (device end) + self._next = 0x8000 + + def _handle(self, t, p): + import socket + import struct + import threading + if t == link.MSG_TCP_LISTEN and len(p) >= 2: + self.listening = struct.unpack_from("= 2: + cid = struct.unpack_from("= 3: + cid = struct.unpack_from(" Date: Fri, 23 Oct 2026 20:18:25 +0000 Subject: [PATCH 73/99] feat(m4.5): Windows environment for the host app (Python, no .exe) OpenDrop takes two things from the system that Windows lacks; the host app now provides both, and Linux behaves as before: - TLS certificate: made with python-cryptography before OpenDrop would run openssl (same files, RSA 2048, self-signed, CN = name). - libarchive: LIBARCHIVE, a DLL recorded by the installer, archive.dll on PATH, then Windows' own System32 archiveint.dll; probed before libarchive-c binds it, with advice when none works. On Windows the archive writer names members with "/" (OpenDrop's os.path.join gives ".\name") and reads the files in Python, not through libarchive's code-page paths. tools/opendrop_install.py builds the venv without bash, git or openssl: opendrop 0.13.0 plus the 17 airdrop-mt7921 patches at 27edf28, applied by a strict built-in patcher, every patch and resulting file checked against pinned digests of the bash install (byte-identical OpenDrop on both platforms), plus pyserial and cryptography. Same stamps as the bash script, so either recognises the other's venv. tools/espdrop.cmd starts the app with that venv in cmd and PowerShell. espdrop check reports Python, pyserial, zeroconf, libarchive, OpenDrop and the TLS state, and packs, reads and unpacks a test archive. pyserial timeouts are only set when they change (each set reconfigures the port on Windows). 31 host tests. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01XzgaN4Bv1m8Ka9iqo7QVUN --- .gitattributes | 4 + docs/M4_ENTWURF.md | 6 +- docs/SPIKES.md | 64 ++++++ host/README.md | 60 ++++- host/espdrop/airdrop.py | 19 +- host/espdrop/cli.py | 25 ++- host/espdrop/env.py | 341 +++++++++++++++++++++++++++++ host/espdrop/receive.py | 14 +- host/espdrop/transport.py | 13 +- host/tests/test_env.py | 251 +++++++++++++++++++++ host/tests/test_host.py | 8 +- tools/espdrop.cmd | 16 ++ tools/espdrop.py | 5 +- tools/opendrop_install.py | 449 ++++++++++++++++++++++++++++++++++++++ 14 files changed, 1244 insertions(+), 31 deletions(-) create mode 100644 .gitattributes create mode 100644 host/espdrop/env.py create mode 100644 host/tests/test_env.py create mode 100644 tools/espdrop.cmd create mode 100644 tools/opendrop_install.py diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..6abcaa3 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,4 @@ +# Line ends that must not depend on the checkout's core.autocrlf: cmd.exe +# misreads batch files with bare LF, bash rejects scripts with CRLF. +*.cmd text eol=crlf +*.sh text eol=lf diff --git a/docs/M4_ENTWURF.md b/docs/M4_ENTWURF.md index 33cea76..f0dc63b 100644 --- a/docs/M4_ENTWURF.md +++ b/docs/M4_ENTWURF.md @@ -253,9 +253,11 @@ Was die letzten Wochen gezeigt haben, wird hier eingebaut statt nachgebessert: - **Linux:** kein root, kein TAP, keine Firewall-Regel. Nur Zugriff auf `/dev/ttyACM0` (Gruppe `dialout` bzw. `uucp`). - **Windows 10/11:** Der USB-Serial/JTAG-Port des ESP meldet sich als - CDC-Gerät, Windows bringt den Treiber mit (COM-Port). Python 3.12+, + CDC-Gerät, Windows bringt den Treiber mit (COM-Port). Python 3.9+, pyserial und OpenDrops Abhängigkeiten; heikel ist `libarchive` (braucht eine - DLL). Ein fertiges Paket (z. B. PyInstaller) kommt erst, wenn alles läuft. + DLL). Ein fertiges Paket (z. B. PyInstaller) kommt erst mit einer + Oberfläche. Umsetzung (M4.5): `tools/opendrop_install.py`, + `host/espdrop/env.py`, Anleitung in `host/README.md`. ## 6. Testen ohne Hardware diff --git a/docs/SPIKES.md b/docs/SPIKES.md index b689c57..a97d29c 100644 --- a/docs/SPIKES.md +++ b/docs/SPIKES.md @@ -409,6 +409,70 @@ simuliert ein verbindendes Apple-Gerät; ein echter OpenDrop-Sender fährt Server. `ReceiveEndToEndTest` prüft, dass die Datei ankommt. Zusammen 20 Host-Tests. +Ergebnis (2026-10-23, C5, Linux): Senden und Empfangen funktionieren beide über +den eigenen IP-Stack des ESP. + +## M4.5: Windows + +Dieselbe Firmware, dieselbe Host-App. Unter Windows fehlt OpenDrop zweierlei, +was Linux mitbringt; beides erledigt jetzt die Host-App (`host/espdrop/env.py`): +- **TLS-Zertifikat:** OpenDrop erzeugt es beim ersten Start mit dem Programm + `openssl`. Die Host-App legt es vorher mit python-cryptography an (gleiche + Dateien in `~/.opendrop/keys`, RSA 2048, selbstsigniert, CN = Name). Ohne + cryptography bleibt alles wie bisher (Linux). +- **libarchive:** Es gibt keine offizielle Windows-Version. Gesucht wird + `LIBARCHIVE`, dann die beim Installieren angegebene DLL, dann `archive.dll` + im `PATH`, dann Windows' eigene `System32\archiveint.dll`. Ob die genügt, + ist offen; `espdrop check` probiert es aus. Sonst die DLL aus MSYS2 + (`host/README.md`). + +Dazu eine Windows-Eigenheit beim Senden: OpenDrop benennt Archiv-Einträge mit +`os.path.join(".", name)`, unter Windows also `.\name`. Unter Windows schreibt +die Host-App die Einträge deshalb selbst, mit `/` und den Dateidaten aus +Python (gleicher Eintrag: normale Datei, Größe, Änderungszeit). + +Installation ohne bash, git und openssl: `tools/opendrop_install.py` macht +dasselbe wie `opendrop_install.sh` (OpenDrop 0.13.0 + 17 Patches aus +airdrop-mt7921, Stand 27edf28) und legt pyserial und cryptography dazu. Die +Patches wendet es selbst an; jeder Patch und jede fertige Datei wird gegen +feste Prüfsummen der Linux-Installation geprüft, unter Windows läuft also +byte-genau derselbe OpenDrop-Code. Am Ende ruft es `espdrop check` auf. + +Test unter Windows 10/11 (Python 3.9+ von python.org, 64 Bit; dieses Repo als +Checkout oder ZIP): +1. In cmd oder PowerShell im Repo-Ordner: `py tools\opendrop_install.py`. + Es endet mit der Ausgabe von `espdrop check`, am besten mit `ready.`. + Interessant ist die Zeile `libarchive` (welche DLL) und `archive`. +2. ESP mit der Bridge-Firmware anstecken (dieselbe wie unter Linux). Im + Geräte-Manager erscheint ein COM-Port („USB Serial Device“, ohne eigenen + Treiber). Dann: + ```bat + .\tools\espdrop.cmd check + .\tools\espdrop.cmd status + .\tools\espdrop.cmd find -t 30 + .\tools\espdrop.cmd send C:\Pfad\foto.jpg + .\tools\espdrop.cmd receive -n MeinPC + ``` + Bedingungen am iPad wie gewohnt (AirDrop „Für alle“, entsperrt, + Teilen-Menü offen). Empfangene Dateien landen im aktuellen Ordner. +3. Zusätzlich einmal eine Datei mit Umlaut im Namen senden und empfangen + (z. B. `Prüfung.txt`): ob libarchive unter Windows Nicht-ASCII-Namen richtig + umsetzt, ist noch offen. + +Abgenommen, wenn `find`, `send` und `receive` unter Windows dasselbe tun wie +unter Linux. Hilfreich bei Fehlern: die vollständige Ausgabe von +`espdrop check` und des fehlgeschlagenen Befehls (ohne Gerätenamen und +Adressen, das Repo ist öffentlich). + +Tests ohne Hardware (Linux, Container): `test_env.py` prüft Zertifikat +(OpenDrop nimmt es an, ruft kein openssl auf), den Windows-Archivschreiber +(Einträge `./name`, Inhalt; ein voller Sendevorgang damit bis in einen echten +OpenDrop-Empfänger), die libarchive-Suche, `espdrop check`, pyserial-Timeouts +und den Patch-Anwender des Installers. Der Installer selbst lief im Container +komplett durch (frische venv, 17 Patches, alle Prüfsummen gleich, +`check` ok); erneutes Ausführen erkennt die fertige Installation, +eine mit `opendrop_install.sh` gebaute auch. Zusammen 31 Host-Tests. + ## Danach Ergebnisse (auch Fehlschläge) in `docs/UEBERBLICK.md` unter „Offene Fragen" diff --git a/host/README.md b/host/README.md index a904b52..713d5f0 100644 --- a/host/README.md +++ b/host/README.md @@ -9,13 +9,65 @@ how to test each stage: [`docs/SPIKES.md`](../docs/SPIKES.md). | `espdrop/link.py` | USB framing and message formats (shared with `tools/espdrop_link.py`) | | `espdrop/transport.py` | serial port: pyserial, or a raw POSIX tty without it | | `espdrop/device.py` | HELLO, commands, UDP sockets on the device | -| `espdrop/mdns.py` | AirDrop browser over the device's UDP | -| `espdrop/cli.py` | `find`, `status`, `cmd` | -| `tests/` | simulated device with iPad-shaped mDNS answers | +| `espdrop/proxy.py` | TCP connections over the device, with credits, as local sockets | +| `espdrop/mdns.py` | AirDrop browser and responder over the device's UDP | +| `espdrop/airdrop.py` | `send`: the patched OpenDrop client over the proxy | +| `espdrop/receive.py` | `receive`: the patched OpenDrop server on loopback | +| `espdrop/env.py` | platform setup: libarchive, TLS certificate, Windows fixes, `check` | +| `espdrop/cli.py` | `find`, `send`, `receive`, `status`, `cmd`, `check` | +| `tests/` | simulated device with iPad-shaped mDNS answers, end-to-end via OpenDrop | -Run from a checkout with the OpenDrop venv's python (it has python-zeroconf): +Only one program can own the serial port: stop `tools/awdl_bridge.py` first. + +## Linux + +The OpenDrop venv from `tools/opendrop_install.sh` (needs python3, git, +openssl, libarchive). pyserial is optional; without it the app uses the tty +directly. No root: only access to `/dev/ttyACM0` (group `dialout`, `uucp` on +Arch). ```sh +~/opendrop-venv/bin/python tools/espdrop.py check ~/opendrop-venv/bin/python tools/espdrop.py find ~/opendrop-venv/bin/python -m unittest discover -s host/tests ``` + +## Windows 10/11 + +Needs Python 3.9+ from python.org (64 bit, with the `py` launcher) and this +checkout; no git, openssl, admin rights, driver or firewall rule. The ESP's +USB-Serial/JTAG shows up as a COM port with Windows' own driver. + +```bat +py tools\opendrop_install.py +.\tools\espdrop.cmd check +.\tools\espdrop.cmd status +.\tools\espdrop.cmd find +.\tools\espdrop.cmd send C:\path\photo.jpg +.\tools\espdrop.cmd receive +``` + +`opendrop_install.py` builds `%USERPROFILE%\opendrop-venv`: OpenDrop 0.13.0 from +PyPI, the 17 airdrop-mt7921 patches (downloaded at the pinned revision; every +patch and every resulting file is checked against pinned digests, so the code +is byte-identical to the Linux install), pyserial and cryptography (the TLS +certificate, which OpenDrop would otherwise make with openssl). It ends with +`espdrop check`. `espdrop.cmd` runs the app with that venv (`OPENDROP_VENV` +picks another) and works in cmd and PowerShell. Received files land in the +current folder. The port is found by its Espressif USB id, else `-p COM5`. + +**libarchive** (OpenDrop packs and unpacks every transfer with it) has no +official Windows build. The app tries, in order: `LIBARCHIVE` (path of a DLL), +the DLL recorded with `opendrop_install.py --libarchive FILE`, an `archive.dll` +on `PATH`, and Windows' own `C:\Windows\System32\archiveint.dll`. `check` +packs, reads and unpacks a test archive with it. If none works, take the one +from MSYS2: + +```bat +winget install MSYS2.MSYS2 +C:\msys64\usr\bin\bash -lc "pacman -S --noconfirm mingw-w64-ucrt-x86_64-libarchive" +py tools\opendrop_install.py --libarchive C:\msys64\ucrt64\bin\libarchive-13.dll +``` + +Known so far only from the code, to be confirmed on hardware: file names with +non-ASCII letters (`check` warns if libarchive mangles them). diff --git a/host/espdrop/airdrop.py b/host/espdrop/airdrop.py index fb10c9f..e9fe906 100644 --- a/host/espdrop/airdrop.py +++ b/host/espdrop/airdrop.py @@ -15,14 +15,13 @@ import os import sys -try: - from opendrop.client import AirDropClient - from opendrop.config import AirDropConfig -except ImportError as e: # pragma: no cover - depends on the environment - raise ImportError("OpenDrop is missing; run espdrop with the venv built by " - "tools/opendrop_install.sh (~/opendrop-venv/bin/python)") from e +from . import env -from .mdns import Browser +env.load_opendrop() # libarchive and the Windows fixes first; ImportError with advice +from opendrop.client import AirDropClient # noqa: E402 +from opendrop.config import AirDropConfig # noqa: E402 + +from .mdns import Browser # noqa: E402 logger = logging.getLogger(__name__) @@ -50,7 +49,10 @@ def found(r): b = Browser(dev, on_found=found, on_packet=on_packet) b.start() try: - done.wait(timeout) + end = time.monotonic() + timeout + # Short waits: a long one cannot be interrupted with Ctrl+C on Windows. + while not done.wait(0.25) and time.monotonic() < end: + pass finally: b.stop() return hit.get("r") or (b.receivers()[0] if b.receivers() and not want else None) @@ -88,6 +90,7 @@ def init(self, *a, **kw): def send_files(dev, receiver, files, name="EspDrop", timeout=60.0): """Send files to a Receiver through the device. Returns 0 on success.""" peer16 = _addr16(receiver.parsed_addresses()[0]) + env.ensure_identity(name) # before OpenDrop would call openssl config = AirDropConfig(computer_name=name, interface="awdl0") client = AirDropClient(config, (str(ipaddress.IPv6Address(peer16)), int(receiver.port))) _bind_client(client, dev, peer16, name=name) diff --git a/host/espdrop/cli.py b/host/espdrop/cli.py index 1d5fd1b..bada12c 100644 --- a/host/espdrop/cli.py +++ b/host/espdrop/cli.py @@ -1,8 +1,11 @@ # EspDrop - command line of the M4 host app (docs/M4_ENTWURF.md, section 5). # # espdrop find [-t SECS] [--no-wake] [-v] AirDrop receivers on the AWDL link +# espdrop send FILE... [-r ID] [-n NAME] send files to a receiver +# espdrop receive [-n NAME] receive files (into the current folder) # espdrop status device, interface, counters # espdrop cmd TEXT... one device command (as in the bridge) +# espdrop check is this PC ready (no device needed) # # The device is switched into IP mode (HELLO); it stays there until the next # session (tools/awdl_bridge.py switches it back to L2 itself). @@ -182,7 +185,20 @@ def cmd_cmd(args, transport=None): dev.close() +def cmd_check(args, transport=None): + from . import env + print("EspDrop host app: checking this PC (no device needed)", flush=True) + n = env.check(lambda text: print(text, flush=True)) + print("ready." if n == 0 else f"{n} problem(s).", flush=True) + return 0 if n == 0 else 1 + + def main(argv=None, transport=None): + # Device and file names can be anything; a Windows console or a redirect in + # its code page must not end the run over one character. + for stream in (sys.stdout, sys.stderr): + if hasattr(stream, "reconfigure"): + stream.reconfigure(errors="replace") ap = argparse.ArgumentParser(prog="espdrop", description="EspDrop host app (M4)") ap.add_argument("-p", "--port", help="serial port (default: the only ESP found; " "Linux /dev/ttyACM0, Windows COM5)") @@ -209,14 +225,21 @@ def main(argv=None, transport=None): c.add_argument("--timeout", type=float, default=3.0) c.add_argument("--wait", type=float, default=12.0, help="ping6: seconds to show replies") c.set_defaults(verbose=True) + sub.add_parser("check", help="is this PC ready: Python, pyserial, OpenDrop, libarchive, TLS") args = ap.parse_args(argv) handlers = {"find": cmd_find, "send": cmd_send, "receive": cmd_receive, - "status": cmd_status, "cmd": cmd_cmd} + "status": cmd_status, "cmd": cmd_cmd, "check": cmd_check} try: return handlers[args.action](args, transport) + except ImportError as e: # OpenDrop or libarchive (env.py says what to do) + print(f"error: {e}", file=sys.stderr) + return 1 except (DeviceError, OSError) as e: print(f"error: {e}", file=sys.stderr) if isinstance(e, PermissionError): print(" (Linux: add yourself to the 'dialout' group - 'uucp' on Arch - and " "log in again)", file=sys.stderr) + elif os.name == "nt" and "denied" in str(e).lower(): + print(" (Windows: another program has the COM port open - a serial monitor, " + "idf.py monitor or a second espdrop; close it)", file=sys.stderr) return 1 diff --git a/host/espdrop/env.py b/host/espdrop/env.py new file mode 100644 index 0000000..7ef2fd9 --- /dev/null +++ b/host/espdrop/env.py @@ -0,0 +1,341 @@ +# EspDrop - the host app's runtime environment, on Windows and Linux (M4.5). +# +# OpenDrop takes two things from the system that a Linux distribution provides +# and Windows does not: +# - libarchive, which python-libarchive-c loads with ctypes, for the archive +# of every transfer. There is no official Windows build. Windows 10/11 ship +# one of their own as System32\archiveint.dll; we try it after an explicit +# LIBARCHIVE, the path tools/opendrop_install.py recorded in the venv and +# the normal library search. `espdrop check` shows whether it works. +# - the openssl command, with which OpenDrop makes its self-signed TLS +# certificate on first start. When python-cryptography is installed (the +# Windows installer adds it) we make the same certificate with it before +# OpenDrop looks; otherwise OpenDrop calls openssl as before, so Linux is +# unchanged. +# One OpenDrop habit also breaks on Windows: archive members are named +# os.path.join(".", name), i.e. ".\name", and their files are read back +# through libarchive's narrow-char paths (fix_archive_writer). +# +# SPDX-License-Identifier: GPL-3.0-or-later +# Copyright (C) 2026 EspDrop contributors + +import ctypes +import ctypes.util +import datetime +import os +import sys + +LIBARCHIVE_RECORD = "espdrop-libarchive.txt" # in the venv, see tools/opendrop_install.py + +# What OpenDrop and libarchive-c need from libarchive, checked before loading +# it for real (libarchive-c binds ~100 functions on import and fails on the +# first missing one, with a less helpful message). +_REQUIRED = ("archive_version_number", "archive_read_new", "archive_read_support_format_all", + "archive_read_support_filter_all", "archive_write_new", + "archive_write_set_format_cpio", "archive_write_add_filter_gzip", + "archive_write_disk_new", "archive_read_disk_new", "archive_entry_new") + +if os.name == "nt": + INSTALL_HINT = ("set it up with: py tools\\opendrop_install.py, then start espdrop with " + "tools\\espdrop.cmd (it uses %USERPROFILE%\\opendrop-venv)") + LIBARCHIVE_HINT = ("see host/README.md, 'Windows': install libarchive with MSYS2 and run " + "py tools\\opendrop_install.py --libarchive " + "C:\\msys64\\ucrt64\\bin\\libarchive-13.dll") +else: + INSTALL_HINT = ("set it up with tools/opendrop_install.sh and run espdrop with " + "~/opendrop-venv/bin/python") + LIBARCHIVE_HINT = "install libarchive (Debian/Ubuntu: apt install libarchive13)" + +_libarchive = None # (path or None, detail) once prepare_libarchive() ran + + +def probe_libarchive(path): + """(ok, detail) for a libarchive file: it loads and has what we use.""" + try: + lib = ctypes.CDLL(path) + except (OSError, TypeError) as e: + return False, f"cannot load: {e}" + missing = [s for s in _REQUIRED if not hasattr(lib, s)] + if missing: + return False, "lacks " + ", ".join(missing) + lib.archive_version_number.restype = ctypes.c_int + v = lib.archive_version_number() + return True, f"libarchive {v // 1000000}.{v // 1000 % 1000}.{v % 1000}" + + +def recorded_libarchive(): + """The libarchive tools/opendrop_install.py --libarchive recorded, or None.""" + try: + with open(os.path.join(sys.prefix, LIBARCHIVE_RECORD), encoding="utf-8") as f: + return f.read().strip() or None + except OSError: + return None + + +def libarchive_candidates(): + """Where to look, best first: a path, or None for the normal library search + (PATH on Windows, ldconfig on Linux; what libarchive-c does by itself).""" + if os.environ.get("LIBARCHIVE"): + return [os.environ["LIBARCHIVE"]] # explicit: that one or nothing + out = [] + rec = recorded_libarchive() + if rec: + out.append(rec) + out.append(None) + if os.name == "nt": + out.append(os.path.join(os.environ.get("SystemRoot", r"C:\Windows"), + "System32", "archiveint.dll")) + return out + + +def prepare_libarchive(): + """Point python-libarchive-c at a usable libarchive, before it is imported. + + Returns (path, detail), path None meaning libarchive-c's own search finds + it. Raises ImportError with advice when none works.""" + global _libarchive + if _libarchive: + return _libarchive + tried = [] + for cand in libarchive_candidates(): + path = cand or ctypes.util.find_library("archive") + if not path: + tried.append("library search: nothing found") + continue + ok, detail = probe_libarchive(path) + if ok: + if cand: + os.environ["LIBARCHIVE"] = cand + _libarchive = (cand, f"{detail} ({path})") + return _libarchive + tried.append(f"{path}: {detail}") + raise ImportError("no usable libarchive (" + "; ".join(tried) + "); " + LIBARCHIVE_HINT) + + +def load_opendrop(): + """Import the patched OpenDrop, ready for this platform. + + Raises ImportError with advice when OpenDrop or libarchive is missing.""" + import warnings + prepare_libarchive() + try: + with warnings.catch_warnings(): + # config.py imports pkg_resources (hence setuptools<81 in the venv); + # the deprecation notice on every start is not for our users. + warnings.filterwarnings("ignore", message="pkg_resources is deprecated") + import opendrop.client # noqa: F401 (loads libarchive) + import opendrop.config # noqa: F401 + import opendrop.server # noqa: F401 + import opendrop.util + except ImportError as e: + raise ImportError(f"cannot import OpenDrop ({e}); {INSTALL_HINT}") from e + except (OSError, AttributeError, TypeError) as e: # libarchive-c binding it + raise ImportError(f"libarchive does not work with OpenDrop ({e}); " + f"{LIBARCHIVE_HINT}") from e + if os.name == "nt": + fix_archive_writer(opendrop.util.AbsArchiveWrite) + + +def fix_archive_writer(cls): + """Replace OpenDrop's AbsArchiveWrite.add_abs_file (Windows). + + The original names the member os.path.join(".", name), which on Windows is + ".\\name", and takes the file's data and metadata from libarchive's + read_disk, whose narrow-char paths are in the ANSI code page there (a user + folder like C:\\Users\\Jörg breaks it). This one opens the file in Python and + writes the member itself: a regular file with its size and mtime, under the + name with "/". Idempotent.""" + if getattr(cls.add_abs_file, "espdrop", False): + return + + def add_abs_file(self, path, store_path): + st = os.stat(path) + + def chunks(): + with open(path, "rb") as f: + while True: + data = f.read(1 << 16) + if not data: + return + yield data + self.add_file_from_memory(store_path.replace("\\", "/"), st.st_size, chunks(), + permission=0o644, mtime=int(st.st_mtime)) + + add_abs_file.espdrop = True + cls.add_abs_file = add_abs_file + + +# ---- TLS identity ---------------------------------------------------------- + +def identity_files(airdrop_dir="~/.opendrop"): + keys = os.path.join(os.path.expanduser(airdrop_dir), "keys") + return keys, os.path.join(keys, "certificate.pem"), os.path.join(keys, "key.pem") + + +def ensure_identity(name, airdrop_dir="~/.opendrop"): + """Create OpenDrop's TLS key and self-signed certificate if they are missing. + + The same files and shape as OpenDrop's `openssl req -newkey rsa:2048 -nodes + -x509 -days 365 -subj /CN=` (config.py), made with + python-cryptography. Returns "exists", "created", or "openssl" when + cryptography is not installed (OpenDrop then runs openssl itself).""" + keys, cert_file, key_file = identity_files(airdrop_dir) + if os.path.exists(cert_file) and os.path.exists(key_file): + return "exists" + try: + from cryptography import x509 + from cryptography.hazmat.primitives import hashes, serialization + from cryptography.hazmat.primitives.asymmetric import rsa + from cryptography.x509.oid import NameOID + except ImportError: + return "openssl" + key = rsa.generate_private_key(public_exponent=65537, key_size=2048) + subject = x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, name or "OpenDrop")]) + now = datetime.datetime.now(datetime.timezone.utc) + pub = key.public_key() + # openssl's default v3_ca extensions for req -x509. + cert = (x509.CertificateBuilder() + .subject_name(subject).issuer_name(subject).public_key(pub) + .serial_number(x509.random_serial_number()) + .not_valid_before(now).not_valid_after(now + datetime.timedelta(days=365)) + .add_extension(x509.SubjectKeyIdentifier.from_public_key(pub), critical=False) + .add_extension(x509.AuthorityKeyIdentifier.from_issuer_public_key(pub), + critical=False) + .add_extension(x509.BasicConstraints(ca=True, path_length=None), critical=True) + .sign(key, hashes.SHA256())) + os.makedirs(keys, exist_ok=True) + _write(key_file, key.private_bytes(serialization.Encoding.PEM, + serialization.PrivateFormat.PKCS8, + serialization.NoEncryption()), private=True) + _write(cert_file, cert.public_bytes(serialization.Encoding.PEM)) + return "created" + + +def _write(path, data, private=False): + tmp = path + ".tmp" + fd = os.open(tmp, os.O_WRONLY | os.O_CREAT | os.O_TRUNC | getattr(os, "O_BINARY", 0), + 0o600 if private else 0o644) + with os.fdopen(fd, "wb") as f: + f.write(data) + os.replace(tmp, path) + + +# ---- espdrop check --------------------------------------------------------- + +def _archive_selftest(): + """Pack two files the way OpenDrop's client does, read them back and + extract them the way its server does. Returns (ok, detail, warnings).""" + import io + import tempfile + import libarchive + import libarchive.extract + from opendrop.util import AbsArchiveWrite + + files = {"espdrop-check.txt": b"EspDrop libarchive check\n" * 50, + "pr\u00fcfung-\u00e4.txt": b"non-ASCII name\n"} + warnings = [] + with tempfile.TemporaryDirectory() as src, tempfile.TemporaryDirectory() as dst: + for fn, data in files.items(): + with open(os.path.join(src, fn), "wb") as f: + f.write(data) + stream = io.BytesIO() + with libarchive.custom_writer(stream.write, "cpio", filter_name="gzip", + archive_write_class=AbsArchiveWrite) as archive: + for fn in files: + archive.add_abs_file(os.path.join(src, fn), os.path.join(".", fn)) + blob = stream.getvalue() + with libarchive.memory_reader(blob) as archive: + got = [(e.pathname, e.mtime) for e in archive] + names = [n for n, _ in got] + want = [f"./{fn}" for fn in files] + if names[:1] != want[:1]: + return False, f"archive holds {names!r}, expected {want!r}", warnings + mtime = int(os.stat(os.path.join(src, next(iter(files)))).st_mtime) + if got[0][1] != mtime: # e.g. a time_t size mismatch in the binding + warnings.append(f"file time packed as {got[0][1]}, expected {mtime}") + if names[1:] != want[1:]: + warnings.append(f"non-ASCII name packed as {names[1:]!r}") + cwd = os.getcwd() + os.chdir(dst) # OpenDrop's server extracts into cwd + try: + libarchive.extract.extract_memory(blob) + finally: + os.chdir(cwd) + for fn, data in files.items(): + p = os.path.join(dst, fn) + if not os.path.isfile(p): + if fn.isascii(): + return False, f"extracted files: {os.listdir(dst)!r}", warnings + warnings.append(f"non-ASCII name unpacked as {os.listdir(dst)!r}") + continue + with open(p, "rb") as f: + if f.read() != data: + return False, f"{fn}: content differs after unpacking", warnings + return True, f"cpio+gzip pack/read/extract ok ({len(blob)} bytes)", warnings + + +def check(out=print): + """What `espdrop check` reports. Returns the number of problems.""" + import platform + problems = 0 + + def line(ok, what, detail): + nonlocal problems + tag = {True: "ok ", False: "FAIL", None: "warn"}[ok] + problems += ok is False + out(f" {tag} {what:<11} {detail}") + + v = sys.version_info + line(v >= (3, 9), "python", f"{platform.python_version()} {platform.architecture()[0]} " + f"({sys.executable})") + try: + import serial + from .transport import candidates + ports = candidates() + line(True, "pyserial", f"{serial.__version__}; ESP ports: {', '.join(ports) or 'none'}") + except ImportError: + line(None if os.name == "posix" else False, "pyserial", + "not installed (needed on Windows; Linux falls back to the raw tty)") + try: + import zeroconf + line(True, "zeroconf", zeroconf.__version__) + except ImportError as e: + line(False, "zeroconf", f"{e}; {INSTALL_HINT}") + try: + path, detail = prepare_libarchive() + line(True, "libarchive", detail) + except ImportError as e: + line(False, "libarchive", str(e)) + return problems # OpenDrop cannot load without it + try: + load_opendrop() + import opendrop + import opendrop.server + with open(opendrop.server.__file__, encoding="utf-8") as f: + patched = "trailing CRLF of the final chunk" in f.read() + line(patched, "opendrop", f"{opendrop.__version__}, " + f"{'patched' if patched else 'NOT patched'} " + f"({os.path.dirname(opendrop.__file__)})") + except ImportError as e: + line(False, "opendrop", str(e)) + return problems + try: + ok, detail, warnings = _archive_selftest() + line(ok, "archive", detail) + for w in warnings: + line(None, "archive", w) + except Exception as e: # noqa: BLE001 - report, do not crash + line(False, "archive", f"{type(e).__name__}: {e}") + keys, cert_file, _key = identity_files() + if os.path.exists(cert_file): + line(True, "tls", f"certificate in {keys}") + else: + try: + import cryptography # noqa: F401 + line(True, "tls", f"will be made with python-cryptography in {keys}") + except ImportError: + import shutil + line(bool(shutil.which("openssl")), "tls", + "OpenDrop will make it with openssl" if shutil.which("openssl") + else "neither python-cryptography nor openssl: pip install cryptography") + return problems diff --git a/host/espdrop/receive.py b/host/espdrop/receive.py index d36962d..27eac91 100644 --- a/host/espdrop/receive.py +++ b/host/espdrop/receive.py @@ -19,14 +19,13 @@ import threading from http.server import ThreadingHTTPServer -try: - from opendrop.config import AirDropConfig - from opendrop.server import AirDropServerHandler -except ImportError as e: # pragma: no cover - raise ImportError("OpenDrop is missing; run espdrop with the venv built by " - "tools/opendrop_install.sh") from e +from . import env -from . import mdns +env.load_opendrop() # libarchive and the Windows fixes first; ImportError with advice +from opendrop.config import AirDropConfig # noqa: E402 +from opendrop.server import AirDropServerHandler # noqa: E402 + +from . import mdns # noqa: E402 logger = logging.getLogger(__name__) AIRDROP_PORT = 8771 @@ -48,6 +47,7 @@ def eui64_linklocal(mac6: bytes) -> bytes: class Receiver: def __init__(self, dev, name="EspDrop", flags=0x3fb): self.dev = dev + env.ensure_identity(name) # before OpenDrop would call openssl self.config = AirDropConfig(computer_name=name) self.config.flags = flags self.httpd = None diff --git a/host/espdrop/transport.py b/host/espdrop/transport.py index f30bf53..12bc494 100644 --- a/host/espdrop/transport.py +++ b/host/espdrop/transport.py @@ -65,15 +65,22 @@ class PySerialPort: def __init__(self, name: str): import serial self.name = name - self.ser = serial.Serial(name, timeout=0, write_timeout=5) + # DTR and RTS stay asserted (pyserial's default, as a Linux open does): + # on the USB-Serial/JTAG, RTS without DTR resets the chip. + self.ser = serial.Serial(name, timeout=0.2, write_timeout=5.0) + # Setting a timeout reconfigures the port on Windows (SetCommState, + # SetCommTimeouts), so only when it changes, not on every call. + self._rt, self._wt = 0.2, 5.0 def read(self, timeout: float) -> bytes: - self.ser.timeout = timeout + if timeout != self._rt: + self.ser.timeout = self._rt = timeout n = max(1, self.ser.in_waiting) return self.ser.read(n) def write(self, data: bytes, timeout: float = 5.0) -> None: - self.ser.write_timeout = timeout + if timeout != self._wt: + self.ser.write_timeout = self._wt = timeout self.ser.write(data) def close(self) -> None: diff --git a/host/tests/test_env.py b/host/tests/test_env.py new file mode 100644 index 0000000..a52b5f2 --- /dev/null +++ b/host/tests/test_env.py @@ -0,0 +1,251 @@ +# EspDrop tests - the Windows pieces of M4.5, run on Linux: TLS identity, +# archive writer, libarchive lookup, `espdrop check`, the installer's patcher. +# +# ~/opendrop-venv/bin/python -m unittest discover -s host/tests +# +# SPDX-License-Identifier: GPL-3.0-or-later +# Copyright (C) 2026 EspDrop contributors + +import contextlib +import importlib.util +import io +import os +import ssl +import sys +import tempfile +import textwrap +import unittest +from unittest import mock + +HERE = os.path.dirname(os.path.abspath(__file__)) +sys.path.insert(0, os.path.join(HERE, "..")) +sys.path.insert(0, HERE) + +from espdrop import cli, env # noqa: E402 +import test_host # noqa: E402 (module only: its TestCases are not collected twice) + + +def load_installer(): + path = os.path.join(HERE, "..", "..", "tools", "opendrop_install.py") + spec = importlib.util.spec_from_file_location("opendrop_install", path) + mod = importlib.util.module_from_spec(spec) + spec.loader.exec_module(mod) + return mod + + +class IdentityTest(unittest.TestCase): + def setUp(self): + try: + import cryptography # noqa: F401 + except ImportError: + self.skipTest("python-cryptography not installed") + + def test_created_like_openssl_and_used_by_opendrop(self): + from cryptography import x509 + env.load_opendrop() + from opendrop.config import AirDropConfig + with tempfile.TemporaryDirectory() as d: + self.assertEqual(env.ensure_identity("Pr\u00fcf-PC", d), "created") + self.assertEqual(env.ensure_identity("Pr\u00fcf-PC", d), "exists") + keys, cert_file, key_file = env.identity_files(d) + with open(cert_file, "rb") as f: + cert = x509.load_pem_x509_certificate(f.read()) + self.assertEqual(cert.subject.rfc4514_string(), "CN=Pr\u00fcf-PC") + self.assertEqual(cert.subject, cert.issuer) + self.assertTrue(cert.extensions.get_extension_for_class(x509.BasicConstraints).value.ca) + self.assertEqual(cert.public_key().key_size, 2048) + if os.name == "posix": + self.assertEqual(os.stat(key_file).st_mode & 0o777, 0o600) + ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER).load_cert_chain(cert_file, key_file) + # OpenDrop finds both files and does not run openssl. + with mock.patch("subprocess.run", side_effect=AssertionError("openssl called")): + config = AirDropConfig(computer_name="Pr\u00fcf-PC", airdrop_dir=d) + config.get_ssl_context() + + +class ArchiveWriterTest(unittest.TestCase): + """fix_archive_writer (used on Windows) gives members with '/' names and the + file's bytes.""" + + def test_names_and_content(self): + env.load_opendrop() + import libarchive + from opendrop.util import AbsArchiveWrite + + class Writer(AbsArchiveWrite): + pass + env.fix_archive_writer(Writer) + env.fix_archive_writer(Writer) # idempotent + data = os.urandom(150000) + with tempfile.TemporaryDirectory() as d: + path = os.path.join(d, "Bild 1.jpg") + with open(path, "wb") as f: + f.write(data) + out = io.BytesIO() + with libarchive.custom_writer(out.write, "cpio", filter_name="gzip", + archive_write_class=Writer) as archive: + archive.add_abs_file(path, ".\\Bild 1.jpg") # os.path.join(".", name) on Windows + with libarchive.memory_reader(out.getvalue()) as archive: + got = [(e.pathname, e.size, e.isfile, b"".join(e.get_blocks())) for e in archive] + self.assertEqual(got, [("./Bild 1.jpg", len(data), True, data)]) + + +class FixedWriterEndToEndTest(test_host.EndToEndTest): + """espdrop send with the Windows archive writer, received by real OpenDrop.""" + + def setUp(self): + env.load_opendrop() + from opendrop.util import AbsArchiveWrite + self.cls, self.orig = AbsArchiveWrite, AbsArchiveWrite.__dict__["add_abs_file"] + env.fix_archive_writer(AbsArchiveWrite) + + def tearDown(self): + self.cls.add_abs_file = self.orig + + +class LibarchiveLookupTest(unittest.TestCase): + def test_explicit_path_is_the_only_candidate(self): + with mock.patch.dict(os.environ, {"LIBARCHIVE": "/opt/la/libarchive.so"}): + self.assertEqual(env.libarchive_candidates(), ["/opt/la/libarchive.so"]) + + def test_windows_falls_back_to_system_libarchive(self): + with mock.patch.dict(os.environ, {"SystemRoot": r"C:\Windows"}), \ + mock.patch.object(env.os, "name", "nt"): + os.environ.pop("LIBARCHIVE", None) + cands = env.libarchive_candidates() + self.assertIn(None, cands) + self.assertTrue(cands[-1].endswith("archiveint.dll"), cands) + + def test_probe(self): + ok, detail = env.probe_libarchive(os.path.join(HERE, "no-such-libarchive.dll")) + self.assertFalse(ok) + self.assertIn("cannot load", detail) + import ctypes.util + found = ctypes.util.find_library("archive") + if found: + ok, detail = env.probe_libarchive(found) + self.assertTrue(ok, detail) + self.assertTrue(detail.startswith("libarchive 3."), detail) + + +class CheckTest(unittest.TestCase): + def test_this_venv_is_ready(self): + out = io.StringIO() + with contextlib.redirect_stdout(out): + rc = cli.main(["check"]) + self.assertEqual(rc, 0, out.getvalue()) + text = out.getvalue() + for item in ("libarchive", "opendrop", "archive", "tls"): + self.assertRegex(text, rf"ok {item}", text) + self.assertIn("ready.", text) + + +class PySerialPortTest(unittest.TestCase): + def test_timeouts_only_set_when_they_change(self): + try: + import serial # noqa: F401 + except ImportError: + self.skipTest("pyserial not installed") + from espdrop.transport import PySerialPort + + class FakeSerial: + def __init__(self, name, timeout, write_timeout): + self.sets, self.in_waiting = [], 0 + + def __setattr__(self, k, v): + if k in ("timeout", "write_timeout"): + self.sets.append((k, v)) + object.__setattr__(self, k, v) + + def read(self, n): + return b"" + + def write(self, data): + return len(data) + + with mock.patch("serial.Serial", FakeSerial): + port = PySerialPort("COM9") + for _ in range(3): + port.read(0.2) + port.write(b"x") + self.assertEqual(port.ser.sets, []) + port.read(0.5) + port.write(b"x", timeout=2.0) + port.read(0.5) + self.assertEqual(port.ser.sets, [("timeout", 0.5), ("write_timeout", 2.0)]) + + +class PatchApplierTest(unittest.TestCase): + SRC = "\n".join(f"line {i}" for i in range(1, 41)) + "\n" + + def setUp(self): + self.inst = load_installer() + + def patch(self, body): + return textwrap.dedent("""\ + commentary before the diff + diff --git a/opendrop/x.py b/opendrop/x.py + index 0000000..1111111 100644 + --- a/opendrop/x.py\t2026-01-01 00:00:00.000000000 +0000 + +++ b/opendrop/x.py\t2026-01-01 00:00:00.000000000 +0000 + """) + textwrap.dedent(body) + + def test_hunks_with_offset_and_dash_lines(self): + # Two lines more at the top than the patch expects: both hunks apply at + # offset +2. The second removes "-- old" and adds "++ new", which in the + # diff read "--- old" / "+++ new" and must not be taken for a header. + src = "extra a\nextra b\n" + self.SRC.replace("line 31", "-- old") + p = self.patch("""\ + @@ -3,3 +3,4 @@ + line 3 + -line 4 + +line four + +line 4b + line 5 + @@ -30,3 +31,3 @@ + line 30 + --- old + +++ new + line 32 + """) + (path, hunks), = self.inst.parse_patch(p) + self.assertEqual(path, "opendrop/x.py") + out = "\n".join(self.inst.apply_hunks(src.split("\n"), hunks)) + want = (src.replace("line 4\n", "line four\nline 4b\n") + .replace("-- old", "++ new")) + self.assertEqual(out, want) + + def test_mismatch_and_refusals(self): + bad = self.patch("""\ + @@ -3,2 +3,2 @@ + line 3 + -line 99 + +line x + """) + (_path, hunks), = self.inst.parse_patch(bad) + with self.assertRaises(self.inst.PatchError): + self.inst.apply_hunks(self.SRC.split("\n"), hunks) + with self.assertRaises(self.inst.PatchError): + self.inst.parse_patch("--- /dev/null\n+++ b/opendrop/new.py\n@@ -0,0 +1 @@\n+x\n") + with self.assertRaises(self.inst.PatchError): # counts do not add up + self.inst.parse_patch(self.patch("@@ -1,2 +1,2 @@\n line 1\n+only\n")) + with tempfile.TemporaryDirectory() as site: + outside = ("--- a/elsewhere/x.py\n+++ b/elsewhere/x.py\n" + "@@ -1 +1 @@\n-line 1\n+line one\n") + with self.assertRaises(self.inst.PatchError): + self.inst.apply_patch(site, outside.encode(), "outside") + + def test_apply_patch_writes_lf_files(self): + with tempfile.TemporaryDirectory() as site: + os.makedirs(os.path.join(site, "opendrop")) + target = os.path.join(site, "opendrop", "x.py") + with open(target, "wb") as f: + f.write(self.SRC.encode()) + p = self.patch("@@ -1,2 +1,2 @@\n-line 1\n+line one\n line 2\n") + self.inst.apply_patch(site, p.encode(), "t") + with open(target, "rb") as f: + self.assertEqual(f.read(), self.SRC.replace("line 1\n", "line one\n", 1).encode()) + + +if __name__ == "__main__": + unittest.main() diff --git a/host/tests/test_host.py b/host/tests/test_host.py index 53676f0..b1bed20 100644 --- a/host/tests/test_host.py +++ b/host/tests/test_host.py @@ -196,10 +196,6 @@ def _handle(self, t, p): self.assertIn("does not answer HELLO", err) -if __name__ == "__main__": - unittest.main() - - class EndToEndTest(unittest.TestCase): """espdrop send all the way to a real patched-OpenDrop receiver on loopback, bridged by the fake device. Exercises the proxy, credits, TLS, /Discover, @@ -308,3 +304,7 @@ def eui64_text(): import ipaddress from espdrop.receive import eui64_linklocal return ipaddress.IPv6Address(eui64_linklocal(bytes.fromhex("020000000001"))) + + +if __name__ == "__main__": + unittest.main() diff --git a/tools/espdrop.cmd b/tools/espdrop.cmd new file mode 100644 index 0000000..6bd5dc3 --- /dev/null +++ b/tools/espdrop.cmd @@ -0,0 +1,16 @@ +@echo off +rem EspDrop - start the host app on Windows, with the venv from tools\opendrop_install.py. +rem +rem .\tools\espdrop.cmd check, status, find, send FILE..., receive +rem +rem Works in cmd and PowerShell (a .cmd needs no execution policy). The venv is +rem OPENDROP_VENV if set, else %USERPROFILE%\opendrop-venv. +rem +rem SPDX-License-Identifier: GPL-3.0-or-later +rem Copyright (C) 2026 EspDrop contributors +setlocal +set "VENV=%OPENDROP_VENV%" +if not defined VENV set "VENV=%USERPROFILE%\opendrop-venv" +if not exist "%VENV%\Scripts\python.exe" echo error: no OpenDrop venv in "%VENV%" - run: py "%~dp0opendrop_install.py" 1>&2 & exit /b 1 +"%VENV%\Scripts\python.exe" "%~dp0espdrop.py" %* +exit /b %ERRORLEVEL% diff --git a/tools/espdrop.py b/tools/espdrop.py index 01864e3..ebe9cf2 100755 --- a/tools/espdrop.py +++ b/tools/espdrop.py @@ -2,9 +2,10 @@ # EspDrop - start the M4 host app from a checkout, on Linux and Windows. # # ~/opendrop-venv/bin/python tools/espdrop.py find -# py tools\espdrop.py -p COM5 find (Windows, venv with pyserial) +# .\tools\espdrop.cmd find (Windows, venv from tools\opendrop_install.py) # -# The OpenDrop venv's python is needed for python-zeroconf (mDNS messages). +# The OpenDrop venv's python is needed for python-zeroconf (mDNS messages) and +# OpenDrop itself (send, receive). # Stop tools/awdl_bridge.py first: only one program can own the serial port. # # SPDX-License-Identifier: GPL-3.0-or-later diff --git a/tools/opendrop_install.py b/tools/opendrop_install.py new file mode 100644 index 0000000..804694d --- /dev/null +++ b/tools/opendrop_install.py @@ -0,0 +1,449 @@ +#!/usr/bin/env python3 +# EspDrop - install the patched OpenDrop into a virtualenv, for Windows (M4.5). +# +# The Python counterpart of tools/opendrop_install.sh, for a PC without bash, +# git or openssl. Same OpenDrop: PyPI opendrop 0.13.0 plus the 17 patches of +# jedbillyb/airdrop-mt7921 (GPLv3) at the pinned revision, in their fixed order. +# The patches and every resulting file are checked against pinned digests (those +# of the install the bash script makes), so Windows and Linux run byte-identical +# OpenDrop code. On top come pyserial (COM ports) and cryptography (the TLS +# certificate, in place of the openssl command). +# +# py tools\opendrop_install.py Windows (Python 3.9+ from python.org) +# python3 tools/opendrop_install.py works on Linux too +# +# Options: +# --venv DIR target venv (default: OPENDROP_VENV, else ~/opendrop-venv) +# --patches DIR take the patches from DIR (a folder with opendrop-*.patch, +# e.g. patches/ of an airdrop-mt7921 checkout) instead of +# downloading the pinned revision from GitHub +# --libarchive FILE use this libarchive DLL (recorded in the venv; "auto" +# forgets it again) +# +# Re-running is safe: a complete, matching install is left alone; an incomplete +# or outdated one that this script or the bash one made is rebuilt (an outdated +# one is kept aside until the new one works). The last step runs +# `espdrop check`, which also tells whether libarchive works: Windows 10/11 have +# one (System32\archiveint.dll); if that does not do, host/README.md says how to +# get one. +# +# SPDX-License-Identifier: GPL-3.0-or-later +# Copyright (C) 2026 EspDrop contributors + +import argparse +import hashlib +import io +import os +import re +import shutil +import stat +import subprocess +import sys +import urllib.error +import urllib.request +import zipfile + +OPENDROP_VERSION = "0.13.0" +REV = "27edf28" # as AIRDROP_MT7921_REV in the bash script +REV_FULL = "27edf28900d6b9a948816ee5e864afe1ce245056" +ARCHIVE_URL = f"https://github.com/jedbillyb/airdrop-mt7921/archive/{REV_FULL}.zip" + +# The series in its fixed order (as PATCHES in opendrop_install.sh, "a series, +# not a menu"), each with the sha256 of the file at REV. +PATCHES = { + "ios26-airdrop": "a12c60bbc8aa2ec9264c350c9641aaccf8de5a13d8762c2d534ebbfcf663eee3", + "recv-window": "542d8a1795d7ed83ca400139b812cdacc1db27c42accb24917cb086ca96b5c6b", + "py314-send": "b1069d13edfcae38edb0a6608b079fb9b61aeb69558f3c04cce23c0ae2987c40", + "mdns-repeat": "5cc87ef8788b3e058a467e918897214eb431ef1ee4cf5049805eb1fade0e03eb", + "find-report": "a0bae567a362eba6823c1b5454e017bddeb6da897aa7515c1db673da4db7fc8f", + "tls-keylog": "cb11838f39d05653bfc984edfae081e5a3e857c57563f8f8b46432ac2cceffdf", + "upload-arms": "2510d8f27ddec43c29fca14655c4fef86e0ec865467608f987212869df1a73de", + "ask-confirm": "2e86aeb5c1880d660f80c699eb7b380b40b85357c7f3f2a6244cb664fdd2ee96", + "mdns-reannounce": "6d035db7e092ff48a5817494ee63de4119f128f6fcb2a73d7779e9a04c11e770", + "threaded-server": "1b6f49185fadd284e168a92f934c12c342dccaecfc1c092d9534a468b1435759", + "url-items": "a3b5d527e311fb1f29a9e960d55e9b0cff60d7281315057431831299928bc9f2", + "zeroconf-update-service": "fe550476d1da14f3437932ed97ba7b8314f0d5eba01f0af16bb3702a861d5e26", + "salvage-truncated": "b00c24773a880eb965db4546c56f8bceb9b05deb832d6527e809344385b4e3ec", + "salvage-trim": "d2dafd52800740be3140802dd494baf964a96c766ff8621f4db9679b7877ceb5", + "send-multifile": "30cad173cf6488f4f5e972f048bfbbf2899c7decde2429bebcf7b9fa6dde2935", + "send-status": "c209e99241d5a5aa6140ca34de9d9ceb6b8b91e8aa226d1104ca31612114051e", + "send-stall": "c31c766bc816cb2696614bf92f04b0f3781a5e5361da6d2df2c3737642717edb", +} +# OpenDrop's sources after the series, as opendrop_install.sh leaves them. +RESULT = { + "opendrop/__init__.py": "09c5e6d0cd29501c4ddd3191f8fbe11ea4a9d3ffc5dea74b12d51f244441f299", + "opendrop/__main__.py": "b482241b8b4b6b8f4c185c768902771f83cb760840ac0f95a02bee8a95771080", + "opendrop/cli.py": "d42a0cef8870a1525be95ec530d04582e71d5ce92534ee5839e71e98cbb69f02", + "opendrop/client.py": "77b422a84923a56d2db18b7885d63a24297ab69bdd57eb3d29667eed75d55cc7", + "opendrop/config.py": "7e42362549fe2ea074688b6b2be2c7cbe7ab7a54f01a35a19618cb26910dba2a", + "opendrop/server.py": "be731940c314f2b2409c263d6585c407cb935c93c00de805affd602693d76503", + "opendrop/util.py": "390ee35c01c3d332e57be49266610a44bd658dbe1fe16084a5e5bfeccb30fe2c", +} +EXTRAS = ["pyserial", "cryptography"] +# Markers in the venv, shared with the bash script: WIP while one of us builds +# it, OK (holding the series signature) once every patch landed and was checked. +STAMP_WIP = ".espdrop-installing" +STAMP_OK = ".espdrop-installed" +LIBARCHIVE_RECORD = "espdrop-libarchive.txt" # read by host/espdrop/env.py + +HERE = os.path.dirname(os.path.abspath(__file__)) + + +class PatchError(Exception): + pass + + +def die(msg): + print(f"error: {msg}", file=sys.stderr) + sys.exit(1) + + +def step(msg): + print(f"\n==> {msg}", flush=True) + + +def venv_python(venv): + return os.path.join(venv, "Scripts", "python.exe") if os.name == "nt" \ + else os.path.join(venv, "bin", "python") + + +def run(cmd, what, **kw): + try: + return subprocess.run(cmd, check=True, **kw) + except (OSError, subprocess.CalledProcessError) as e: + die(f"{what} failed ({e}); run again to retry") + + +def series_sig(): + v = sys.version_info + return (f"opendrop {OPENDROP_VERSION} python {v[0]}.{v[1]} mt7921 {REV} " + f"patches {' '.join(PATCHES)}") + + +def ours(d): + return (os.path.exists(os.path.join(d, STAMP_OK)) or os.path.exists(os.path.join(d, STAMP_WIP)) + or (os.path.isfile(os.path.join(d, "pyvenv.cfg")) + and os.path.isfile(os.path.join(d, "espdrop-freeze.txt")))) + + +def complete(d): + return not os.path.exists(os.path.join(d, STAMP_WIP)) and ( + os.path.exists(os.path.join(d, STAMP_OK)) + or os.path.isfile(os.path.join(d, "espdrop-freeze.txt"))) + + +def up_to_date(d): + try: + with open(os.path.join(d, STAMP_OK), encoding="utf-8") as f: + return complete(d) and f.read().strip() == series_sig() + except OSError: + return False + + +def rmtree(path): + def retry(func, p, _exc): # read-only files (Windows) + os.chmod(p, stat.S_IWRITE) + func(p) + if sys.version_info >= (3, 12): + shutil.rmtree(path, onexc=retry) + else: + shutil.rmtree(path, onerror=retry) + + +def sha256(data): + return hashlib.sha256(data).hexdigest() + + +# ---- the patch series ------------------------------------------------------- + +def fetch_patches(src_dir): + """{name: bytes} for the whole series, each checked against its pin.""" + got = {} + if src_dir: + for name in PATCHES: + p = os.path.join(src_dir, f"opendrop-{name}.patch") + try: + with open(p, "rb") as f: + got[name] = f.read() + except OSError as e: + die(f"patch {name} missing in {src_dir} ({e})") + else: + print(f"downloading {ARCHIVE_URL}", flush=True) + try: + with urllib.request.urlopen(ARCHIVE_URL, timeout=120) as r: + blob = r.read() + z = zipfile.ZipFile(io.BytesIO(blob)) + except (OSError, urllib.error.URLError, zipfile.BadZipFile) as e: + die(f"download failed ({e}); without internet, fetch that zip elsewhere, " + f"unpack it and use --patches /patches") + members = {n.rsplit("/", 1)[-1]: n for n in z.namelist() if "/patches/" in n} + for name in PATCHES: + m = members.get(f"opendrop-{name}.patch") + if not m: + die(f"patch {name} missing in the downloaded archive") + got[name] = z.read(m) + for name, data in got.items(): + if sha256(data) != PATCHES[name]: + die(f"patch {name} is not the one reviewed for rev {REV} (sha256 {sha256(data)})") + return got + + +HUNK = re.compile(r"@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@") + + +def parse_patch(text): + """A unified diff (git or diff -u style) -> [(path, [(old_start, old, new)])]. + + Paths lose their first component (-p1). Only what the series uses: changes + to existing text files; anything else is refused rather than guessed.""" + lines = text.split("\n") + files, i = [], 0 + while i < len(lines): + ln = lines[i] + if ln.startswith("--- ") and i + 1 < len(lines) and lines[i + 1].startswith("+++ "): + src = ln[4:].split("\t")[0].strip() + dst = lines[i + 1][4:].split("\t")[0].strip() + if "/dev/null" in (src, dst): + raise PatchError(f"creates or deletes a file ({src} -> {dst})") + files.append((dst.split("/", 1)[1] if "/" in dst else dst, [])) + i += 2 + continue + m = HUNK.match(ln) + if m: + if not files: + raise PatchError("hunk before any file header") + old_n = 1 if m[2] is None else int(m[2]) + new_n = 1 if m[4] is None else int(m[4]) + old, new = [], [] + i += 1 + while len(old) < old_n or len(new) < new_n: + if i >= len(lines): + raise PatchError("patch ends inside a hunk") + ln = lines[i] + tag, body = ln[:1], ln[1:] + if tag == " " or ln == "": # "" : a blank context line, space stripped + old.append(body) + new.append(body) + elif tag == "-": + old.append(body) + elif tag == "+": + new.append(body) + else: + raise PatchError(f"unexpected line in a hunk: {ln[:60]!r}") + i += 1 + if (len(old), len(new)) != (old_n, new_n): + raise PatchError(f"hunk {ln!r}: line counts do not match its header") + files[-1][1].append((int(m[1]), old, new)) + continue + i += 1 # diff/index lines, commentary + return files + + +def _find(lines, old, want): + """Index where `old` occurs, nearest to `want` (exact match only).""" + n = len(old) + for d in range(len(lines) + 1): + for pos in ((want,) if d == 0 else (want - d, want + d)): + if 0 <= pos <= len(lines) - n and lines[pos:pos + n] == old: + return pos + return None + + +def apply_hunks(lines, hunks, path="file"): + """Apply one file's hunks in order, each where its context matches exactly + (at its line or the nearest offset, like `git apply` without fuzz).""" + out, shift = list(lines), 0 + for start, old, new in hunks: + base = start - 1 if old else start # a pure insertion goes after line `start` + if old: + pos = _find(out, old, base + shift) + if pos is None: + raise PatchError(f"{path}: the hunk at line {start} does not apply") + else: + pos = min(max(base + shift, 0), len(out)) + out[pos:pos + len(old)] = new + shift = pos - base + len(new) - len(old) + return out + + +def apply_patch(site, data, name): + """Apply one patch of the series to the files under `site`; all or nothing.""" + files = parse_patch(data.decode("utf-8")) + if not files: + raise PatchError(f"{name}: no changes in it") + changed = {} + for path, hunks in files: + parts = path.split("/") + if os.path.isabs(path) or ".." in parts or parts[0] != "opendrop": + raise PatchError(f"{name}: refusing path {path!r}") + full = os.path.join(site, *parts) + if full in changed: + text = changed[full] + else: + with open(full, "rb") as f: + raw = f.read() + if b"\r" in raw: + raise PatchError(f"{name}: {path} has CR line ends") + text = raw.decode("utf-8") + changed[full] = "\n".join(apply_hunks(text.split("\n"), hunks, path)) + for full, text in changed.items(): + with open(full, "wb") as f: + f.write(text.encode("utf-8")) + + +def tree(site): + """{opendrop/x.py: sha256} of the package sources (what the patches touch).""" + pkg = os.path.join(site, "opendrop") + out = {} + for fn in sorted(os.listdir(pkg)): + if fn.endswith(".py"): + with open(os.path.join(pkg, fn), "rb") as f: + out[f"opendrop/{fn}"] = sha256(f.read()) + return out + + +# ---- the venv --------------------------------------------------------------- + +def build(venv, py, patches): + step(f"creating {venv} with opendrop=={OPENDROP_VERSION}") + os.makedirs(venv) # fresh: it did not exist (or was ours and set aside) + open(os.path.join(venv, STAMP_WIP), "w").close() + run([sys.executable, "-m", "venv", venv], "creating the venv") + # setuptools<81: OpenDrop imports pkg_resources, which setuptools 81 removed. + run([py, "-m", "pip", "install", "-q", f"opendrop=={OPENDROP_VERSION}", "setuptools<81"] + + EXTRAS, "pip install") + site = subprocess.run([py, "-c", "import sysconfig; print(sysconfig.get_paths()['purelib'])"], + check=True, capture_output=True, text=True).stdout.strip() + if not os.path.isfile(os.path.join(site, "opendrop", "server.py")): + die(f"opendrop not found in {site}") + + step(f"applying {len(PATCHES)} patches") + for name, data in patches.items(): + before = tree(site) + try: + apply_patch(site, data, name) + except (PatchError, OSError, UnicodeDecodeError) as e: + die(f"patch {name} did not apply: {e}") + if tree(site) == before: + die(f"patch {name} applied but changed nothing") + print(f" ok {name}", flush=True) + + step("verifying") + got = tree(site) + bad = sorted(p for p in set(RESULT) | set(got) if RESULT.get(p) != got.get(p)) + if bad: + die(f"patched OpenDrop differs from the reviewed install in: {', '.join(bad)}") + run([py, "-m", "compileall", "-q", os.path.join(site, "opendrop")], "compiling OpenDrop") + # Dependencies are unpinned upstream; record what we got for bug reports. + freeze = run([py, "-m", "pip", "freeze"], "pip freeze", capture_output=True).stdout + with open(os.path.join(venv, "espdrop-freeze.txt"), "wb") as f: + f.write(freeze) + with open(os.path.join(venv, STAMP_OK), "w", encoding="utf-8") as f: + f.write(series_sig() + "\n") + os.remove(os.path.join(venv, STAMP_WIP)) + print(f"OpenDrop {OPENDROP_VERSION} + {len(PATCHES)} patches: identical to the " + f"Linux install ({len(RESULT)} files checked)") + + +def main(): + ap = argparse.ArgumentParser(description="install the patched OpenDrop for EspDrop") + ap.add_argument("--venv", default=os.environ.get("OPENDROP_VENV") + or os.path.join(os.path.expanduser("~"), "opendrop-venv")) + ap.add_argument("--patches", metavar="DIR", help="folder with opendrop-*.patch " + "(default: download rev " + REV + ")") + ap.add_argument("--libarchive", metavar="FILE", + help="libarchive DLL to use (recorded in the venv); 'auto' forgets it") + args = ap.parse_args() + venv = os.path.abspath(args.venv) + py = venv_python(venv) + + step("checking requirements") + if sys.version_info < (3, 9): + die(f"Python 3.9 or newer required, this is {sys.version.split()[0]}") + if os.name == "posix" and os.geteuid() == 0: + die("run this as your normal user (OpenDrop keeps its state in ~/.opendrop)") + try: + import venv as _venv # noqa: F401 + import ensurepip # noqa: F401 + except ImportError: + die("this Python has no venv module (Debian/Ubuntu: apt install python3-venv)") + print(f"python: {sys.version.split()[0]} ({sys.executable})") + + # A rebuild that was killed hard leaves the previous install aside; put it back. + old = venv + ".espdrop-old" + if os.path.exists(old) and ours(old) and complete(old) and ( + not os.path.exists(venv) or (ours(venv) and not complete(venv))): + if os.path.exists(venv): + rmtree(venv) + os.rename(old, venv) + + if up_to_date(venv): + print(f"\nalready installed and up to date: {venv}") + extras = [m for m in ("serial", "cryptography") + if subprocess.run([py, "-c", f"import {m}"], capture_output=True).returncode] + if extras: # a venv the bash script made has neither + step("adding pyserial and cryptography") + run([py, "-m", "pip", "install", "-q"] + EXTRAS, "pip install") + else: + step(f"fetching the patch series (rev {REV})") + patches = fetch_patches(args.patches) + restore = False + if os.path.exists(venv): + if not ours(venv): + die(f"{venv} exists but is not an OpenDrop install made by EspDrop; " + f"delete it to install there, or use --venv") + if os.path.exists(old): + if not ours(old): + die(f"{old} is in the way; move it elsewhere") + rmtree(old) + if complete(venv): + step(f"rebuilding {venv} (outdated; it stays usable until the new one works)") + try: + os.rename(venv, old) + except OSError as e: # Windows: a program still runs from it + die(f"cannot move {venv} aside ({e}); close espdrop and anything " + f"else using that venv") + restore = True + else: + step(f"rebuilding {venv} (previous install incomplete)") + rmtree(venv) + try: + build(venv, py, patches) + except BaseException: + if restore: + if os.path.exists(venv): + rmtree(venv) + os.rename(old, venv) + print(f"kept the previous install in {venv}", file=sys.stderr) + raise + if os.path.exists(old) and ours(old): + rmtree(old) + + record = os.path.join(venv, LIBARCHIVE_RECORD) + if args.libarchive == "auto": + if os.path.exists(record): + os.remove(record) + elif args.libarchive: + path = os.path.abspath(args.libarchive) + if not os.path.isfile(path): + die(f"{path} does not exist") + with open(record, "w", encoding="utf-8") as f: + f.write(path + "\n") + print(f"libarchive: {path} (recorded in {record})") + + step("espdrop check") + rc = subprocess.run([py, os.path.join(HERE, "espdrop.py"), "check"]).returncode + launcher = r".\tools\espdrop.cmd" if os.name == "nt" else f"{py} tools/espdrop.py" + if rc: + print(f"\ninstalled in {venv}, but the check above found problems.\n" + f"libarchive: see host/README.md ('Windows'), then run this again with " + f"--libarchive .", file=sys.stderr) + return 1 + print(f"\ndone: {venv}\nnext: plug in the ESP (bridge firmware) and run, from the checkout,\n" + f" {launcher} status\n {launcher} find\n {launcher} send \n" + f" {launcher} receive") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) From 1117029373d9b2527a20ff6d344fa5d2037899a2 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 23 Oct 2026 20:46:51 +0000 Subject: [PATCH 74/99] fix(m4.5): open the COM port with DTR and RTS off on Windows Every command on Windows ended in "Write timeout": the ESP took no data over USB, most likely because the chip was held in reset. The USB-Serial/JTAG keeps the chip in reset while RTS is on and DTR off, and with pyserial's default (both on) usbser.sys does not reliably pass DTR on. On Windows the port is now opened with both lines off, so nothing from the host can reset the chip; Linux keeps the default, where the open raises both lines at once. A write timeout now comes with a hint (replug the ESP, check the firmware). 32 host tests. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01XzgaN4Bv1m8Ka9iqo7QVUN --- docs/SPIKES.md | 11 ++++++++-- host/espdrop/cli.py | 4 ++++ host/espdrop/transport.py | 15 ++++++++++--- host/tests/test_env.py | 45 ++++++++++++++++++++++++++------------- 4 files changed, 55 insertions(+), 20 deletions(-) diff --git a/docs/SPIKES.md b/docs/SPIKES.md index a97d29c..af3c453 100644 --- a/docs/SPIKES.md +++ b/docs/SPIKES.md @@ -459,6 +459,13 @@ Checkout oder ZIP): (z. B. `Prüfung.txt`): ob libarchive unter Windows Nicht-ASCII-Namen richtig umsetzt, ist noch offen. +Erster Lauf unter Windows: Jeder Befehl endete mit `error: Write timeout`. Der +ESP nahm über USB nichts an, wahrscheinlich weil der Chip im Reset festgehalten +wurde. Der USB-Serial/JTAG hält ihn im Reset, solange RTS an und DTR aus ist, +und mit pyserials Vorgabe (beide an) kommt DTR unter Windows nicht zuverlässig +an. Die Host-App öffnet den Port unter Windows jetzt mit DTR und RTS aus (unter +Linux unverändert). Danach den ESP einmal ab- und wieder anstecken. + Abgenommen, wenn `find`, `send` und `receive` unter Windows dasselbe tun wie unter Linux. Hilfreich bei Fehlern: die vollständige Ausgabe von `espdrop check` und des fehlgeschlagenen Befehls (ohne Gerätenamen und @@ -468,10 +475,10 @@ Tests ohne Hardware (Linux, Container): `test_env.py` prüft Zertifikat (OpenDrop nimmt es an, ruft kein openssl auf), den Windows-Archivschreiber (Einträge `./name`, Inhalt; ein voller Sendevorgang damit bis in einen echten OpenDrop-Empfänger), die libarchive-Suche, `espdrop check`, pyserial-Timeouts -und den Patch-Anwender des Installers. Der Installer selbst lief im Container +den Port-Aufbau unter Windows (DTR/RTS aus) und den Patch-Anwender des Installers. Der Installer selbst lief im Container komplett durch (frische venv, 17 Patches, alle Prüfsummen gleich, `check` ok); erneutes Ausführen erkennt die fertige Installation, -eine mit `opendrop_install.sh` gebaute auch. Zusammen 31 Host-Tests. +eine mit `opendrop_install.sh` gebaute auch. Zusammen 32 Host-Tests. ## Danach diff --git a/host/espdrop/cli.py b/host/espdrop/cli.py index bada12c..aa4f472 100644 --- a/host/espdrop/cli.py +++ b/host/espdrop/cli.py @@ -242,4 +242,8 @@ def main(argv=None, transport=None): elif os.name == "nt" and "denied" in str(e).lower(): print(" (Windows: another program has the COM port open - a serial monitor, " "idf.py monitor or a second espdrop; close it)", file=sys.stderr) + elif isinstance(e, TimeoutError) or "Timeout" in type(e).__name__: + print(" (the ESP takes no data over USB: it is not running the bridge " + "firmware, or it hangs or is held in reset; unplug it and plug it " + "back in)", file=sys.stderr) return 1 diff --git a/host/espdrop/transport.py b/host/espdrop/transport.py index 12bc494..69e4476 100644 --- a/host/espdrop/transport.py +++ b/host/espdrop/transport.py @@ -65,9 +65,18 @@ class PySerialPort: def __init__(self, name: str): import serial self.name = name - # DTR and RTS stay asserted (pyserial's default, as a Linux open does): - # on the USB-Serial/JTAG, RTS without DTR resets the chip. - self.ser = serial.Serial(name, timeout=0.2, write_timeout=5.0) + ser = serial.Serial(timeout=0.2, write_timeout=5.0) # not opened yet + ser.port = name + if os.name == "nt": + # The USB-Serial/JTAG holds the chip in reset while RTS is on and + # DTR off, and Windows' usbser.sys does not always pass DTR on: with + # pyserial's default (both on) the chip can end up held in reset and + # every write times out. Both off: nothing here can reset it. (Linux + # keeps the default; its open raises both lines at once.) + ser.dtr = False + ser.rts = False + ser.open() + self.ser = ser # Setting a timeout reconfigures the port on Windows (SetCommState, # SetCommTimeouts), so only when it changes, not on every call. self._rt, self._wt = 0.2, 5.0 diff --git a/host/tests/test_env.py b/host/tests/test_env.py index a52b5f2..af979bc 100644 --- a/host/tests/test_env.py +++ b/host/tests/test_env.py @@ -141,30 +141,45 @@ def test_this_venv_is_ready(self): class PySerialPortTest(unittest.TestCase): - def test_timeouts_only_set_when_they_change(self): + def setUp(self): try: import serial # noqa: F401 except ImportError: self.skipTest("pyserial not installed") - from espdrop.transport import PySerialPort - class FakeSerial: - def __init__(self, name, timeout, write_timeout): - self.sets, self.in_waiting = [], 0 + class FakeSerial: + def __init__(self, timeout, write_timeout): + self.sets, self.in_waiting, self.at_open = [], 0, None + self.dtr = self.rts = True # pyserial's defaults + + def __setattr__(self, k, v): + if k in ("timeout", "write_timeout"): + self.sets.append((k, v)) + object.__setattr__(self, k, v) - def __setattr__(self, k, v): - if k in ("timeout", "write_timeout"): - self.sets.append((k, v)) - object.__setattr__(self, k, v) + def open(self): + self.at_open = (self.port, self.dtr, self.rts) - def read(self, n): - return b"" + def read(self, n): + return b"" - def write(self, data): - return len(data) + def write(self, data): + return len(data) - with mock.patch("serial.Serial", FakeSerial): - port = PySerialPort("COM9") + def open(self, osname): + from espdrop.transport import PySerialPort + with mock.patch("serial.Serial", self.FakeSerial), \ + mock.patch("espdrop.transport.os.name", osname): + return PySerialPort("COM9") + + def test_windows_opens_with_dtr_and_rts_off(self): + # Both on can leave the USB-Serial/JTAG chip held in reset on Windows. + self.assertEqual(self.open("nt").ser.at_open, ("COM9", False, False)) + self.assertEqual(self.open("posix").ser.at_open, ("COM9", True, True)) + + def test_timeouts_only_set_when_they_change(self): + port = self.open("nt") + port.ser.sets.clear() # the constructor's own for _ in range(3): port.read(0.2) port.write(b"x") From 2edaf2a74a6a6934fbea65ff5362d689e0afdb6d Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 23 Oct 2026 21:04:28 +0000 Subject: [PATCH 75/99] fix(m4.5): announce and pack files as ./name on Windows On Windows the iPad accepted the /Ask and then reset the connection on the /Upload (WinError 10054). OpenDrop builds FileBomPath in /Ask with os.path.join(".", name), so it announced .\test.jpg while the archive held ./test.jpg. The /Ask plist now gets ./name too on its way out. The Windows archive writer now also gives the member one link and an inode number, as OpenDrop's read_disk path does on Linux; a bare in-memory entry had 0 for both. send now reports its steps with times (found after, TCP + TLS connect, accepted after, upload size and rate), and -v (also for receive) logs everything with milliseconds, to see where the wait before the Accept prompt goes. 34 host tests. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01XzgaN4Bv1m8Ka9iqo7QVUN --- docs/SPIKES.md | 22 ++++++++--- host/espdrop/airdrop.py | 20 ++++++++-- host/espdrop/cli.py | 15 +++++++- host/espdrop/env.py | 82 +++++++++++++++++++++++++++++++++-------- host/tests/test_env.py | 71 +++++++++++++++++++++++++++++++++++ 5 files changed, 182 insertions(+), 28 deletions(-) diff --git a/docs/SPIKES.md b/docs/SPIKES.md index af3c453..a505524 100644 --- a/docs/SPIKES.md +++ b/docs/SPIKES.md @@ -426,10 +426,12 @@ was Linux mitbringt; beides erledigt jetzt die Host-App (`host/espdrop/env.py`): ist offen; `espdrop check` probiert es aus. Sonst die DLL aus MSYS2 (`host/README.md`). -Dazu eine Windows-Eigenheit beim Senden: OpenDrop benennt Archiv-Einträge mit -`os.path.join(".", name)`, unter Windows also `.\name`. Unter Windows schreibt -die Host-App die Einträge deshalb selbst, mit `/` und den Dateidaten aus -Python (gleicher Eintrag: normale Datei, Größe, Änderungszeit). +Dazu eine Windows-Eigenheit beim Senden: OpenDrop baut den Namen der Datei im +Archiv und `FileBomPath` in `/Ask` mit `os.path.join(".", name)`, unter Windows +also `.\name`. Unter Windows schreibt die Host-App die Archiv-Einträge deshalb +selbst (Name mit `/`, Dateidaten aus Python, Kopf wie bei OpenDrop unter Linux: +normale Datei, Größe, Änderungszeit, ein Link, Inode 1) und setzt in `/Ask` +ebenfalls `./name`. Installation ohne bash, git und openssl: `tools/opendrop_install.py` macht dasselbe wie `opendrop_install.sh` (OpenDrop 0.13.0 + 17 Patches aus @@ -466,6 +468,13 @@ und mit pyserials Vorgabe (beide an) kommt DTR unter Windows nicht zuverlässig an. Die Host-App öffnet den Port unter Windows jetzt mit DTR und RTS aus (unter Linux unverändert). Danach den ESP einmal ab- und wieder anstecken. +Zweiter Lauf: `find` fand das iPad, nach „Annehmen“ endete der Upload mit +`[WinError 10054]` (Verbindung vom iPad zurückgesetzt). Ursache sehr +wahrscheinlich: `/Ask` kündigte die Datei als `.\test.jpg` an, das Archiv +enthielt `./test.jpg`. Beide heißen unter Windows jetzt `./name`. `send` zeigt +jetzt die Zeiten der einzelnen Schritte (gefunden nach, verbunden in, angenommen +nach, Upload-Rate); `-v` gibt ein Protokoll mit Millisekunden aus. + Abgenommen, wenn `find`, `send` und `receive` unter Windows dasselbe tun wie unter Linux. Hilfreich bei Fehlern: die vollständige Ausgabe von `espdrop check` und des fehlgeschlagenen Befehls (ohne Gerätenamen und @@ -475,10 +484,11 @@ Tests ohne Hardware (Linux, Container): `test_env.py` prüft Zertifikat (OpenDrop nimmt es an, ruft kein openssl auf), den Windows-Archivschreiber (Einträge `./name`, Inhalt; ein voller Sendevorgang damit bis in einen echten OpenDrop-Empfänger), die libarchive-Suche, `espdrop check`, pyserial-Timeouts -den Port-Aufbau unter Windows (DTR/RTS aus) und den Patch-Anwender des Installers. Der Installer selbst lief im Container +den Port-Aufbau unter Windows (DTR/RTS aus), die Pfade in `/Ask`, die +Archiv-Köpfe und den Patch-Anwender des Installers. Der Installer selbst lief im Container komplett durch (frische venv, 17 Patches, alle Prüfsummen gleich, `check` ok); erneutes Ausführen erkennt die fertige Installation, -eine mit `opendrop_install.sh` gebaute auch. Zusammen 32 Host-Tests. +eine mit `opendrop_install.sh` gebaute auch. Zusammen 34 Host-Tests. ## Danach diff --git a/host/espdrop/airdrop.py b/host/espdrop/airdrop.py index e9fe906..c17629d 100644 --- a/host/espdrop/airdrop.py +++ b/host/espdrop/airdrop.py @@ -14,6 +14,7 @@ import logging import os import sys +import time from . import env @@ -36,7 +37,6 @@ def find_receiver(dev, timeout=30.0, want=None, on_packet=None): Returns the first complete receiver (or the one whose id/name matches `want`), or None. The caller sends the BLE wake.""" - import time hit = {} done = __import__("threading").Event() @@ -82,6 +82,13 @@ def factory(address, timeout=None, source_address=None): def init(self, *a, **kw): base_init(self, *a, **kw) self._create_connection = factory + connect = self.connect + + def timed_connect(): + t = time.monotonic() + connect() + logger.info("connected (TCP + TLS) in %.1f s", time.monotonic() - t) + self.connect = timed_connect # http.client calls self.connect() odc.HTTPSConnectionAWDL.__init__ = init client._restore_conn_init = lambda: setattr(odc.HTTPSConnectionAWDL, "__init__", base_init) @@ -94,16 +101,21 @@ def send_files(dev, receiver, files, name="EspDrop", timeout=60.0): config = AirDropConfig(computer_name=name, interface="awdl0") client = AirDropClient(config, (str(ipaddress.IPv6Address(peer16)), int(receiver.port))) _bind_client(client, dev, peer16, name=name) + size = sum(os.path.getsize(f) for f in files) try: - logger.info("Asking the receiver to accept ...") + t = time.monotonic() + logger.info("asking the receiver (tap Accept on the device) ...") if not client.send_ask(files): logger.warning("the receiver declined") return 1 - logger.info("accepted; uploading ...") + logger.info("accepted after %.1f s; uploading %.2f MB ...", time.monotonic() - t, size / 1e6) + t = time.monotonic() if not client.send_upload(files, reask=lambda: client.send_ask(files)): logger.warning("the upload failed") return 1 - logger.info("sent") + secs = time.monotonic() - t + logger.info("uploaded %.2f MB in %.1f s (%.2f MB/s)", size / 1e6, secs, + size / 1e6 / max(secs, 1e-3)) return 0 finally: restore = getattr(client, "_restore_conn_init", None) diff --git a/host/espdrop/cli.py b/host/espdrop/cli.py index aa4f472..13169ca 100644 --- a/host/espdrop/cli.py +++ b/host/espdrop/cli.py @@ -14,6 +14,7 @@ # Copyright (C) 2026 EspDrop contributors import argparse +import logging import os import sys import time @@ -119,6 +120,7 @@ def cmd_send(args, transport=None): def packet(src, mcast, what): print(f" mdns {src or '-'}: {what}", flush=True) + t0 = time.monotonic() print(f"looking for {args.receiver or 'a receiver'} " f"(target: AirDrop Everyone, unlocked, share sheet OPEN) ...", flush=True) r = find_receiver(dev, timeout=args.time, want=args.receiver, @@ -127,8 +129,8 @@ def packet(src, mcast, what): print("no AirDrop receiver found; `espdrop find -v` shows what the browse saw", file=sys.stderr) return 1 - print(f"sending {len(args.file)} file(s) to {r.id} [{r.parsed_addresses()[0]}]:{r.port} " - f"... (tap Accept on the device)", flush=True) + print(f"found {r.id} [{r.parsed_addresses()[0]}]:{r.port} after " + f"{time.monotonic() - t0:.1f} s; sending {len(args.file)} file(s)", flush=True) rc = send_files(dev, r, args.file, name=args.name) print("sent." if rc == 0 else "send failed.", flush=True) return rc @@ -218,6 +220,7 @@ def main(argv=None, transport=None): rv = sub.add_parser("receive", help="receive files from an Apple device") rv.add_argument("-n", "--name", default="EspDrop", help="name shown on the sender") + rv.add_argument("-v", "--verbose", action="store_true", help="debug log with times") sub.add_parser("status", help="device, interface and counters") c = sub.add_parser("cmd", help="send one device command") @@ -227,6 +230,14 @@ def main(argv=None, transport=None): c.set_defaults(verbose=True) sub.add_parser("check", help="is this PC ready: Python, pyserial, OpenDrop, libarchive, TLS") args = ap.parse_args(argv) + # Our steps (connect, accept, upload rate) at INFO; OpenDrop's own only from + # WARNING on, unless -v asks for everything, with milliseconds since start. + verbose = getattr(args, "verbose", False) and args.action != "cmd" + logging.basicConfig(level=logging.DEBUG if verbose else logging.INFO, force=True, + format="%(relativeCreated)8.0f ms %(name)s: %(message)s" if verbose + else "%(message)s") + if not verbose: + logging.getLogger("opendrop").setLevel(logging.WARNING) handlers = {"find": cmd_find, "send": cmd_send, "receive": cmd_receive, "status": cmd_status, "cmd": cmd_cmd, "check": cmd_check} try: diff --git a/host/espdrop/env.py b/host/espdrop/env.py index 7ef2fd9..3868cf4 100644 --- a/host/espdrop/env.py +++ b/host/espdrop/env.py @@ -12,9 +12,10 @@ # Windows installer adds it) we make the same certificate with it before # OpenDrop looks; otherwise OpenDrop calls openssl as before, so Linux is # unchanged. -# One OpenDrop habit also breaks on Windows: archive members are named -# os.path.join(".", name), i.e. ".\name", and their files are read back -# through libarchive's narrow-char paths (fix_archive_writer). +# One OpenDrop habit also breaks on Windows: it builds the archive member names +# and the /Ask FileBomPath with os.path.join(".", name), i.e. ".\name", and +# reads the files back through libarchive's narrow-char paths +# (fix_archive_writer, fix_ask_paths). # # SPDX-License-Identifier: GPL-3.0-or-later # Copyright (C) 2026 EspDrop contributors @@ -134,6 +135,7 @@ def load_opendrop(): f"{LIBARCHIVE_HINT}") from e if os.name == "nt": fix_archive_writer(opendrop.util.AbsArchiveWrite) + fix_ask_paths(opendrop.client.AirDropClient) def fix_archive_writer(cls): @@ -142,29 +144,77 @@ def fix_archive_writer(cls): The original names the member os.path.join(".", name), which on Windows is ".\\name", and takes the file's data and metadata from libarchive's read_disk, whose narrow-char paths are in the ANSI code page there (a user - folder like C:\\Users\\Jörg breaks it). This one opens the file in Python and - writes the member itself: a regular file with its size and mtime, under the - name with "/". Idempotent.""" + folder like C:\\Users\\Jörg breaks it). This one opens the file in Python + and writes the member itself, as read_disk would describe it: a regular file + with size, mtime, one link and an inode number (the cpio writer numbers + members 1, 2, ...), under the name with "/". Idempotent.""" if getattr(cls.add_abs_file, "espdrop", False): return + from libarchive import ffi + from libarchive.entry import ArchiveEntry, FileType + lib = ffi.libarchive + lib.archive_entry_set_nlink.argtypes = [ctypes.c_void_p, ctypes.c_uint] + lib.archive_entry_set_nlink.restype = None + lib.archive_entry_set_ino64.argtypes = [ctypes.c_void_p, ctypes.c_int64] + lib.archive_entry_set_ino64.restype = None def add_abs_file(self, path, store_path): st = os.stat(path) - - def chunks(): - with open(path, "rb") as f: - while True: - data = f.read(1 << 16) - if not data: - return - yield data - self.add_file_from_memory(store_path.replace("\\", "/"), st.st_size, chunks(), - permission=0o644, mtime=int(st.st_mtime)) + entry = ArchiveEntry(header_codec=self.header_codec, + pathname=store_path.replace("\\", "/"), size=st.st_size, + filetype=FileType.REGULAR_FILE, perm=0o644, + mtime=int(st.st_mtime)) + lib.archive_entry_set_nlink(entry._entry_p, 1) + lib.archive_entry_set_ino64(entry._entry_p, st.st_ino or 1) + ffi.write_header(self._pointer, entry._entry_p) + with open(path, "rb") as f: + while True: + data = f.read(1 << 16) + if not data: + break + ffi.write_data(self._pointer, data, len(data)) + ffi.write_finish_entry(self._pointer) add_abs_file.espdrop = True cls.add_abs_file = add_abs_file +def fix_ask_paths(cls): + """Announce files as "./name" in /Ask (Windows). + + send_ask sets FileBomPath to os.path.join(".", name), i.e. ".\\name" on + Windows, while the archive holds "./name" (fix_archive_writer), and the + receiver checks the upload against what /Ask announced (the likely reason an + iPad accepted such an /Ask and then reset the connection on the /Upload). + This rewrites the paths in the /Ask plist on its way out; everything else in + it stays as OpenDrop made it. Idempotent.""" + if getattr(cls.send_POST, "espdrop", False): + return + base = cls.send_POST + + def send_POST(self, url, body, *args, **kwargs): + if url == "/Ask" and isinstance(body, (bytes, bytearray)): + body = posix_bom_paths(body) + return base(self, url, body, *args, **kwargs) + + send_POST.espdrop = True + cls.send_POST = send_POST + + +def posix_bom_paths(ask_plist: bytes) -> bytes: + """An /Ask body with "\\" in its FileBomPath values made "/" (unchanged + bytes when there is none).""" + import plistlib + body = plistlib.loads(ask_plist) + changed = False + for f in body.get("Files") or []: + bom = f.get("FileBomPath") if isinstance(f, dict) else None + if isinstance(bom, str) and "\\" in bom: + f["FileBomPath"] = bom.replace("\\", "/") + changed = True + return plistlib.dumps(body, fmt=plistlib.FMT_BINARY) if changed else ask_plist + + # ---- TLS identity ---------------------------------------------------------- def identity_files(airdrop_dir="~/.opendrop"): diff --git a/host/tests/test_env.py b/host/tests/test_env.py index af979bc..3b91ad9 100644 --- a/host/tests/test_env.py +++ b/host/tests/test_env.py @@ -90,6 +90,77 @@ class Writer(AbsArchiveWrite): self.assertEqual(got, [("./Bild 1.jpg", len(data), True, data)]) +def cpio_odc_header(blob): + """The first member's odc header fields and name.""" + names = ("magic", "dev", "ino", "mode", "uid", "gid", "nlink", "rdev", "mtime", + "namesize", "filesize") + widths = (6, 6, 6, 6, 6, 6, 6, 6, 11, 6, 11) + h, off = {}, 0 + for n, w in zip(names, widths): + h[n] = blob[off:off + w].decode() + off += w + h["name"] = blob[off:off + int(h["namesize"], 8) - 1].decode() + return h + + +class ArchiveHeaderTest(unittest.TestCase): + """The Windows writer's member looks like the one OpenDrop's read_disk path + makes on Linux (the one iPads have accepted): same name, type and mode, + one link, inode numbered 1 - not 0/0 as a bare in-memory entry would be.""" + + def test_same_as_opendrop_on_linux(self): + env.load_opendrop() + import libarchive + from opendrop.util import AbsArchiveWrite + + class Writer(AbsArchiveWrite): + pass + env.fix_archive_writer(Writer) + with tempfile.TemporaryDirectory() as d: + path = os.path.join(d, "test.jpg") + with open(path, "wb") as f: + f.write(os.urandom(5000)) + heads = [] + for cls, member in ((AbsArchiveWrite, "./test.jpg"), (Writer, ".\\test.jpg")): + out = io.BytesIO() + with libarchive.custom_writer(out.write, "cpio", archive_write_class=cls) as a: + a.add_abs_file(path, member) + heads.append(cpio_odc_header(out.getvalue())) + ours, theirs = heads[1], heads[0] + for field in ("magic", "ino", "mode", "nlink", "rdev", "mtime", "filesize", "name"): + self.assertEqual(ours[field], theirs[field], field) + self.assertEqual((ours["name"], ours["nlink"], ours["ino"]), ("./test.jpg", "000001", "000001")) + + +class AskPathTest(unittest.TestCase): + """fix_ask_paths (used on Windows): FileBomPath ".\\name" -> "./name" in /Ask.""" + + def ask(self, bom): + import plistlib + return plistlib.dumps({ + "SenderComputerName": "EspDrop", "TransferID": {"id": "0000"}, + "Files": [{"FileName": "a b.jpg", "FileBomPath": bom, "FileIsDirectory": False, + "ConvertMediaFormats": 0}], + "FileIcon": b"\xff\xd8icon"}, fmt=plistlib.FMT_BINARY) + + def test_rewrites_only_the_ask_paths(self): + class Client: + def __init__(self): + self.sent = [] + + def send_POST(self, url, body, headers=None): + self.sent.append((url, body)) + return True, b"" + env.fix_ask_paths(Client) + env.fix_ask_paths(Client) # idempotent + c = Client() + c.send_POST("/Ask", self.ask(".\\a b.jpg")) + c.send_POST("/Upload", b".\\raw", {"TotalBytes": "5"}) + self.assertEqual(c.sent, [("/Ask", self.ask("./a b.jpg")), ("/Upload", b".\\raw")]) + same = self.ask("./a b.jpg") + self.assertIs(env.posix_bom_paths(same), same) # nothing to do: untouched bytes + + class FixedWriterEndToEndTest(test_host.EndToEndTest): """espdrop send with the Windows archive writer, received by real OpenDrop.""" From 4c92122c65714596f18374806e6ee91e81bf4f23 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 23 Oct 2026 21:16:34 +0000 Subject: [PATCH 76/99] docs(m4.5): espdrop send works on Windows find and send work with the iPad from Windows; receive and non-ASCII file names are still to be tested there. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01XzgaN4Bv1m8Ka9iqo7QVUN --- docs/SPIKES.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/docs/SPIKES.md b/docs/SPIKES.md index a505524..aafabc7 100644 --- a/docs/SPIKES.md +++ b/docs/SPIKES.md @@ -475,6 +475,10 @@ enthielt `./test.jpg`. Beide heißen unter Windows jetzt `./name`. `send` zeigt jetzt die Zeiten der einzelnen Schritte (gefunden nach, verbunden in, angenommen nach, Upload-Rate); `-v` gibt ein Protokoll mit Millisekunden aus. +Ergebnis (2026-10-23, Windows, C5, iPad): `find` und `send` funktionieren, die +Datei kam an. Die lange Wartezeit vor dem Senden lag an den Befehlen davor, +nicht an `send`. Noch offen: `receive` unter Windows, Dateinamen mit Umlaut. + Abgenommen, wenn `find`, `send` und `receive` unter Windows dasselbe tun wie unter Linux. Hilfreich bei Fehlern: die vollständige Ausgabe von `espdrop check` und des fehlgeschlagenen Befehls (ohne Gerätenamen und From 5589d854459f1ad6557fdb22059a5644e740e3a3 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 24 Oct 2026 09:32:59 +0000 Subject: [PATCH 77/99] feat(gui-core): long-running session, accept prompt, receiver choice Session (host/espdrop/session.py) is the core a GUI builds on: one owner of the serial port that stays. Receiving is ready the whole time, sends run alongside, browsing is on demand with the ESP's BLE wake, and when the ESP goes away the session reconnects and restarts receiving. Every change is an event (device, power, browse, receiver, send with progress, receive with progress and the files it left). - Receiving asks first: a handler subclass replaces OpenDrop's accept-all /Ask with on_ask(IncomingAsk: sender, files, links, thumbnail); no answer in time is a no. The upload reports bytes and the new files. - Receivers get their names from /Discover; send picks by name, id or part of the name, and the CLI lists them to choose from when several answer. - Sends can be cancelled (TcpConn.abort) and report progress against the archive OpenDrop actually uploads. - OpenDrop's HTTPS connections are routed by peer address, so several /Discovers and sends can run at once. - The mDNS browser and responder share UDP 5353 on the device. CLI: receive asks y/N (--yes accepts all, -d DIR), find shows names, send -r NAME. 43 host tests. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01XzgaN4Bv1m8Ka9iqo7QVUN --- host/README.md | 8 +- host/espdrop/airdrop.py | 315 +++++++++++++++++++++------- host/espdrop/cli.py | 145 +++++++++++-- host/espdrop/device.py | 43 +++- host/espdrop/mdns.py | 4 +- host/espdrop/proxy.py | 24 +++ host/espdrop/receive.py | 145 ++++++++++++- host/espdrop/session.py | 419 +++++++++++++++++++++++++++++++++++++ host/tests/fake_device.py | 95 +++++++++ host/tests/test_session.py | 298 ++++++++++++++++++++++++++ 10 files changed, 1386 insertions(+), 110 deletions(-) create mode 100644 host/espdrop/session.py create mode 100644 host/tests/test_session.py diff --git a/host/README.md b/host/README.md index 713d5f0..27a6a80 100644 --- a/host/README.md +++ b/host/README.md @@ -14,8 +14,14 @@ how to test each stage: [`docs/SPIKES.md`](../docs/SPIKES.md). | `espdrop/airdrop.py` | `send`: the patched OpenDrop client over the proxy | | `espdrop/receive.py` | `receive`: the patched OpenDrop server on loopback | | `espdrop/env.py` | platform setup: libarchive, TLS certificate, Windows fixes, `check` | +| `espdrop/session.py` | one long-running link for a GUI: receiving always ready, sends alongside, events, reconnect | | `espdrop/cli.py` | `find`, `send`, `receive`, `status`, `cmd`, `check` | -| `tests/` | simulated device with iPad-shaped mDNS answers, end-to-end via OpenDrop | +| `tests/` | simulated devices with iPad-shaped mDNS answers, end-to-end via OpenDrop | + +`receive` asks before each transfer (`y`/`n` within 60 s, else declined; +`--yes` accepts all, `-d DIR` picks the folder). `find` shows each receiver's +name from its `/Discover` answer; `send` takes `-r NAME` (or id, or part of +the name) and lists the receivers to choose from when it finds several. Only one program can own the serial port: stop `tools/awdl_bridge.py` first. diff --git a/host/espdrop/airdrop.py b/host/espdrop/airdrop.py index c17629d..13b6751 100644 --- a/host/espdrop/airdrop.py +++ b/host/espdrop/airdrop.py @@ -13,12 +13,14 @@ import ipaddress import logging import os -import sys +import plistlib +import threading import time from . import env env.load_opendrop() # libarchive and the Windows fixes first; ImportError with advice +from opendrop import client as odc # noqa: E402 from opendrop.client import AirDropClient # noqa: E402 from opendrop.config import AirDropConfig # noqa: E402 @@ -32,97 +34,264 @@ def _addr16(text: str) -> bytes: return ipaddress.IPv6Address(text.split("%", 1)[0]).packed -def find_receiver(dev, timeout=30.0, want=None, on_packet=None): - """Browse for an AirDrop receiver on the device's AWDL link. +def match_receiver(found, want): + """The (receiver, info) entries of `found` that `want` names: the id or the + name exactly (ignoring case), else the receivers whose name contains it.""" + w = want.lower() + exact = [e for e in found if w in (e[0].id.lower(), (e[1].get("name") or "").lower())] + return exact or [e for e in found if w in (e[1].get("name") or "").lower()] - Returns the first complete receiver (or the one whose id/name matches - `want`), or None. The caller sends the BLE wake.""" - hit = {} - done = __import__("threading").Event() - def found(r): - if want and want.lower() not in (r.id.lower(), (r.server or "").lower()): - return - hit.setdefault("r", r) - done.set() +def find_receivers(dev, timeout=30.0, want=None, settle=3.0, on_packet=None, name="EspDrop", + on_found=None): + """Browse for AirDrop receivers on the device's AWDL link, asking each for + its name (/Discover). Returns [(mdns Receiver, {"name", "model"} or {})]. + + With `want` (an id or name): returns the matches as soon as there are any. + Without: everything seen by `settle` seconds after the first receiver, so a + second device nearby gets its chance. The caller sends the BLE wake. + on_found(receiver, info) runs once a receiver's /Discover has answered.""" + found = {} # id -> (receiver, info or None while asking, since) + ask_secs = 10.0 # a /Discover still open after this does not hold us up + lock = threading.Lock() + changed = threading.Event() - b = Browser(dev, on_found=found, on_packet=on_packet) + def ask_name(r): + try: + info = discover(dev, r, name=name) + except Exception as e: # noqa: BLE001 - keep it, by id + logger.info("/Discover %s: %s", r.id, e) + info = {} + with lock: + found[r.id] = (r, info, found[r.id][2]) + if on_found: + on_found(r, info) + changed.set() + + def on_mdns(r): + with lock: + if r.id in found: + return + found[r.id] = (r, None, time.monotonic()) + threading.Thread(target=ask_name, args=(r,), daemon=True).start() + + b = Browser(dev, on_found=on_mdns, on_packet=on_packet) b.start() + end = time.monotonic() + timeout + first = None try: - end = time.monotonic() + timeout # Short waits: a long one cannot be interrupted with Ctrl+C on Windows. - while not done.wait(0.25) and time.monotonic() < end: - pass + while time.monotonic() < end: + changed.wait(0.25) + changed.clear() + now = time.monotonic() + with lock: + done = [(r, i) for r, i, _ in found.values() if i is not None] + pending = any(i is None and now - t < ask_secs for _, i, t in found.values()) + if want and match_receiver(done, want): + return match_receiver(done, want) + if done and first is None: + first = time.monotonic() + if not want and first is not None and not pending \ + and time.monotonic() >= first + settle: + break finally: b.stop() - return hit.get("r") or (b.receivers()[0] if b.receivers() and not want else None) - - -def _bind_client(client, dev, peer16, name=""): - """Make `client`'s HTTPS connections go through the device's proxy.""" - def factory(address, timeout=None, source_address=None): - port = address[1] - conn = dev.tcp.connect(peer16, port, name=name or f"airdrop:{port}") - if not conn.opened.wait(timeout or 30): - raise OSError(f"no connection to [{ipaddress.IPv6Address(peer16)}]:{port} " - f"(the device did not open it)") - if conn.error: - raise OSError(f"connection to port {port} failed: {conn.error}") - return conn.app - - # AirDropClient builds its HTTPSConnectionAWDL lazily inside send_POST, so we - # cannot reach the instance beforehand. Wrap the class __init__ to install - # our factory on every connection it opens; http.client calls - # self._create_connection((host, port), timeout, src) in connect(), and - # HTTPSConnection then wraps the returned socket in TLS itself. Restored on - # teardown so nothing else in the process is affected. - from opendrop import client as odc - base_init = odc.HTTPSConnectionAWDL.__init__ - - def init(self, *a, **kw): - base_init(self, *a, **kw) - self._create_connection = factory - connect = self.connect - - def timed_connect(): - t = time.monotonic() - connect() - logger.info("connected (TCP + TLS) in %.1f s", time.monotonic() - t) - self.connect = timed_connect # http.client calls self.connect() - - odc.HTTPSConnectionAWDL.__init__ = init - client._restore_conn_init = lambda: setattr(odc.HTTPSConnectionAWDL, "__init__", base_init) - - -def send_files(dev, receiver, files, name="EspDrop", timeout=60.0): - """Send files to a Receiver through the device. Returns 0 on success.""" + with lock: + done = [(r, i or {}) for r, i, _ in found.values()] + return match_receiver(done, want) if want else done + + +def find_receiver(dev, timeout=30.0, want=None, on_packet=None): + """The first receiver found (or the one `want` names), or None.""" + hits = find_receivers(dev, timeout=timeout, want=want, settle=0.0, on_packet=on_packet) + return hits[0][0] if hits else None + + +# ---- OpenDrop's HTTPS connections through the device ------------------------- +# +# AirDropClient builds its HTTPSConnectionAWDL lazily inside send_POST (and +# again after a failed request), so there is no instance to configure +# beforehand. Its __init__ is wrapped once, for good: a connection to an address +# a device was registered for (_route) gets our connection factory; http.client +# calls self._create_connection((host, port), timeout, src) in connect() and +# then wraps the returned socket in TLS itself. Any other address is left to +# OpenDrop. Several sends and /Discovers can run at once (the registry, not a +# swapped class attribute, decides), and each thread's connections are +# collected in its SendJob for progress and cancelling. + +_routes = {} # peer16 -> device +_tls = threading.local() # .job: the SendJob of the calling thread, if any +_installed = False +_install_lock = threading.Lock() + + +def _connect_via(dev, peer16, address, timeout=None, source_address=None): + port = address[1] + conn = dev.tcp.connect(peer16, port, name=f"airdrop:{port}") + job = getattr(_tls, "job", None) + if job is not None: + job.conns.append(conn) + if job.cancelled: + conn.abort() + if not conn.opened.wait(timeout or 15): + conn.abort() + raise OSError(f"no connection to [{ipaddress.IPv6Address(peer16)}]:{port} " + f"(the device did not open it)") + if conn.error: + raise OSError(f"connection to port {port} failed: {conn.error}") + return conn.app + + +def _install(): + global _installed + with _install_lock: + if _installed: + return + base_init = odc.HTTPSConnectionAWDL.__init__ + + def init(self, *a, **kw): + base_init(self, *a, **kw) + try: + peer16 = _addr16(self.host) + except ValueError: + return + dev = _routes.get(peer16) + if dev is None: + return # not one of ours + self._create_connection = ( + lambda address, timeout=None, source_address=None: + _connect_via(dev, peer16, address, timeout, source_address)) + connect = self.connect + + def timed_connect(): + t = time.monotonic() + connect() + logger.info("connected (TCP + TLS) in %.1f s", time.monotonic() - t) + self.connect = timed_connect # http.client calls self.connect() + + odc.HTTPSConnectionAWDL.__init__ = init + _installed = True + + +def _client(dev, receiver, name): + """An AirDropClient for `receiver` whose connections go through `dev`.""" + _install() peer16 = _addr16(receiver.parsed_addresses()[0]) + _routes[peer16] = dev env.ensure_identity(name) # before OpenDrop would call openssl config = AirDropConfig(computer_name=name, interface="awdl0") - client = AirDropClient(config, (str(ipaddress.IPv6Address(peer16)), int(receiver.port))) - _bind_client(client, dev, peer16, name=name) - size = sum(os.path.getsize(f) for f in files) + return AirDropClient(config, (str(ipaddress.IPv6Address(peer16)), int(receiver.port))) + + +def _close(client): + if client.http_conn is not None: + try: + client.http_conn.close() + except OSError: + pass + + +def discover(dev, receiver, name="EspDrop"): + """/Discover a receiver: {"name": ..., "model": ...} as it introduces itself, + {} if it does not answer with a name (not discoverable to us).""" + client = _client(dev, receiver, name) + try: + body = {} + if client.config.record_data: + body["SenderRecordData"] = client.config.record_data + ok, resp = client.send_POST("/Discover", plistlib.dumps(body, fmt=plistlib.FMT_BINARY)) + answer = plistlib.loads(resp) if ok and resp else {} + finally: + _close(client) + if not answer.get("ReceiverComputerName"): + return {} + return {"name": answer["ReceiverComputerName"], "model": answer.get("ReceiverModelName")} + + +class SendJob: + """A send in progress: its connections, progress and a way to cancel it.""" + + def __init__(self, files): + self.files = list(files) + self.size = sum(os.path.getsize(f) for f in self.files) + self.conns = [] + self.state = "new" # asking, uploading, done, declined, failed, cancelled + self.error = None + self.cancelled = False + self._mark = 0 # bytes up when the upload began + self.payload = None # bytes of the /Upload body (the archive), once known + + def bytes_up(self): + return sum(c.bytes_up for c in self.conns) + + def sent(self): + """Bytes of the upload handed to the device so far.""" + return max(0, self.bytes_up() - self._mark) if self.state in ("uploading", "done") else 0 + + def progress(self): + """0..1 of the upload. Measured against the archive OpenDrop sends, + which is compressed and so can be much smaller than the files.""" + if self.state == "done": + return 1.0 + total = self.payload or self.size + return min(1.0, self.sent() / total) if total else 0.0 + + def cancel(self): + self.cancelled = True + for c in list(self.conns): + c.abort() + + +def send_files(dev, receiver, files, name="EspDrop", job=None, on_state=None): + """Send files to a Receiver through the device. Returns 0 on success. + + job: a SendJob to track (one is made if None); on_state(job) is called on + every state change.""" + job = job or SendJob(files) + notify = on_state or (lambda j: None) + + def state(s, error=None): + job.state, job.error = s, error + notify(job) + + client = _client(dev, receiver, name) + post = client.send_POST + + def send_post(url, body, *a, **kw): + # The archive's size, and the byte count where the upload starts (a + # re-Ask may come before it), for progress. + if url == "/Upload": + job.payload = len(body) if isinstance(body, (bytes, bytearray)) else None + job._mark = job.bytes_up() + return post(url, body, *a, **kw) + client.send_POST = send_post + _tls.job = job try: t = time.monotonic() + state("asking") logger.info("asking the receiver (tap Accept on the device) ...") - if not client.send_ask(files): + if not client.send_ask(job.files): + state("cancelled" if job.cancelled else "declined") logger.warning("the receiver declined") return 1 - logger.info("accepted after %.1f s; uploading %.2f MB ...", time.monotonic() - t, size / 1e6) + logger.info("accepted after %.1f s; uploading %.2f MB ...", time.monotonic() - t, + job.size / 1e6) + job._mark = job.bytes_up() # until /Upload sets it exactly + state("uploading") t = time.monotonic() - if not client.send_upload(files, reask=lambda: client.send_ask(files)): + if not client.send_upload(job.files, reask=lambda: client.send_ask(job.files)): + state("cancelled" if job.cancelled else "failed", "the upload failed") logger.warning("the upload failed") return 1 secs = time.monotonic() - t - logger.info("uploaded %.2f MB in %.1f s (%.2f MB/s)", size / 1e6, secs, - size / 1e6 / max(secs, 1e-3)) + logger.info("uploaded %.2f MB in %.1f s (%.2f MB/s)", job.size / 1e6, secs, + job.size / 1e6 / max(secs, 1e-3)) + state("done") return 0 + except Exception as e: + state("cancelled" if job.cancelled else "failed", str(e)) + raise finally: - restore = getattr(client, "_restore_conn_init", None) - if restore: - restore() - if client.http_conn is not None: - try: - client.http_conn.close() - except OSError: - pass + _tls.job = None + _close(client) diff --git a/host/espdrop/cli.py b/host/espdrop/cli.py index 13169ca..8343bba 100644 --- a/host/espdrop/cli.py +++ b/host/espdrop/cli.py @@ -1,8 +1,8 @@ # EspDrop - command line of the M4 host app (docs/M4_ENTWURF.md, section 5). # # espdrop find [-t SECS] [--no-wake] [-v] AirDrop receivers on the AWDL link -# espdrop send FILE... [-r ID] [-n NAME] send files to a receiver -# espdrop receive [-n NAME] receive files (into the current folder) +# espdrop send FILE... [-r NAME] [-n NAME] send files to a receiver (pick one if several) +# espdrop receive [-n NAME] [-d DIR] [-y] receive files, asking before each transfer # espdrop status device, interface, counters # espdrop cmd TEXT... one device command (as in the bridge) # espdrop check is this PC ready (no device needed) @@ -17,6 +17,7 @@ import logging import os import sys +import threading import time from . import link @@ -57,9 +58,29 @@ def cmd_find(args, transport=None): print("no Bluetooth wake in this firmware: the target must already be " "awake (or use --no-wake and wake it otherwise)", flush=True) + try: + from .airdrop import discover # needs OpenDrop; without it ids only + except ImportError as e: + discover = None + print(f"(no names: {e})", flush=True) + + def ask_name(r): + try: + info = discover(dev, r) + except Exception as e: # noqa: BLE001 + print(f" name {r.id} ? ({e})", flush=True) + return + if info: + model = f" ({info['model']})" if info.get("model") else "" + print(f" name {r.id} {info['name']!r}{model}", flush=True) + else: + print(f" name {r.id} ? (no name in its /Discover answer)", flush=True) + def found(r): print(f" found {r.id} [{r.parsed_addresses()[0]}]:{r.port} flags {r.flags}" f" host {r.server}", flush=True) + if discover: + threading.Thread(target=ask_name, args=(r,), daemon=True).start() def gone(r): print(f" gone {r.id}", flush=True) @@ -99,8 +120,32 @@ def packet(src, mcast, what): dev.close() +def _label(r, info): + model = f" ({info['model']})" if info.get("model") else "" + return f"{info['name']!r}{model} [{r.id}]" if info.get("name") else f"[{r.id}] (no name)" + + +def _pick(hits): + """Several receivers: let the user choose (only from a terminal).""" + print(f"{len(hits)} receivers:", flush=True) + for n, (r, info) in enumerate(hits, 1): + print(f" {n}) {_label(r, info)}", flush=True) + if not sys.stdin.isatty(): + print("error: several receivers; pick one with -r NAME (or its id)", file=sys.stderr) + return None + while True: + try: + answer = input(f"send to [1-{len(hits)}, empty = cancel]: ").strip() + except EOFError: + return None + if not answer: + return None + if answer.isdigit() and 1 <= int(answer) <= len(hits): + return hits[int(answer) - 1] + + def cmd_send(args, transport=None): - from .airdrop import find_receiver, send_files + from .airdrop import find_receivers, send_files for f in args.file: if not os.path.isfile(f): print(f"error: no such file: {f}", file=sys.stderr) @@ -121,16 +166,20 @@ def packet(src, mcast, what): print(f" mdns {src or '-'}: {what}", flush=True) t0 = time.monotonic() - print(f"looking for {args.receiver or 'a receiver'} " + print(f"looking for {args.receiver or 'receivers'} " f"(target: AirDrop Everyone, unlocked, share sheet OPEN) ...", flush=True) - r = find_receiver(dev, timeout=args.time, want=args.receiver, - on_packet=packet if args.verbose else None) - if r is None: - print("no AirDrop receiver found; `espdrop find -v` shows what the browse saw", - file=sys.stderr) + hits = find_receivers(dev, timeout=args.time, want=args.receiver, name=args.name, + on_packet=packet if args.verbose else None) + if not hits: + print(f"no AirDrop receiver {'matching ' + repr(args.receiver) + ' ' if args.receiver else ''}" + f"found; `espdrop find -v` shows what the browse saw", file=sys.stderr) + return 1 + chosen = hits[0] if len(hits) == 1 else _pick(hits) + if chosen is None: return 1 - print(f"found {r.id} [{r.parsed_addresses()[0]}]:{r.port} after " - f"{time.monotonic() - t0:.1f} s; sending {len(args.file)} file(s)", flush=True) + r, info = chosen + print(f"found {_label(r, info)} after {time.monotonic() - t0:.1f} s; " + f"sending {len(args.file)} file(s)", flush=True) rc = send_files(dev, r, args.file, name=args.name) print("sent." if rc == 0 else "send failed.", flush=True) return rc @@ -143,16 +192,75 @@ def packet(src, mcast, what): dev.close() +class _Prompt: + """Yes/no questions on the terminal while other threads run: one reader + thread owns stdin, and a question that gets no answer in time is a no.""" + + def __init__(self): + import queue + self._q = queue.Queue() + self._lock = threading.Lock() + threading.Thread(target=self._read, name="espdrop-stdin", daemon=True).start() + + def _read(self): + for line in sys.stdin: + self._q.put(line.strip()) + self._q.put(None) + + def ask(self, text, timeout): + import queue + with self._lock: # one question at a time + while not self._q.empty(): + self._q.get_nowait() # nothing typed earlier counts + print(f"{text} [y/N, {timeout:.0f} s] ", end="", flush=True) + try: + answer = self._q.get(timeout=timeout) + except queue.Empty: + print("\n (no answer: declined)", flush=True) + return False + return (answer or "").lower() in ("y", "yes", "j", "ja") + + def cmd_receive(args, transport=None): - import time from .receive import Receiver + if args.dir: + os.makedirs(args.dir, exist_ok=True) + os.chdir(args.dir) # OpenDrop's server writes into cwd + if args.yes: + on_ask = None + print("accepting every transfer without asking (--yes)", flush=True) + elif sys.stdin.isatty(): + prompt = _Prompt() + + def on_ask(ask): + return prompt.ask(f"accept from {ask.summary()}?", args.ask_timeout) + else: + print("stdin is not a terminal: every transfer will be declined " + "(--yes accepts all)", flush=True) + + def on_ask(ask): + print(f"declined (no terminal to ask): {ask.summary()}", flush=True) + return False + + def on_event(kind, **info): + if kind == "received": + ask = info.get("ask") + who = ask.sender if ask else "?" + rate = info["size"] / 1e6 / max(info["secs"], 1e-3) + if info["ok"]: + print(f"received from {who}: {', '.join(info['files'])} " + f"({info['size'] / 1e6:.2f} MB, {rate:.2f} MB/s)", flush=True) + else: + print(f"transfer from {who} incomplete: files {info['files'] or 'none'}, " + f"raw archive kept: {info['raw'] or 'no'}", flush=True) + dev = _open(args, transport) - rx = Receiver(dev, name=args.name) + rx = Receiver(dev, name=args.name, on_ask=on_ask, on_event=on_event) try: _hello(dev) rx.start() - print(f"ready: on the iPhone/iPad, share a file and pick {args.name!r}. " - f"Ctrl+C to stop.", flush=True) + print(f"ready: on the iPhone/iPad, share a file and pick {args.name!r}; files go to " + f"{os.getcwd()}. Ctrl+C to stop.", flush=True) try: while not dev.error: time.sleep(0.3) @@ -212,7 +320,8 @@ def main(argv=None, transport=None): f.set_defaults(name="EspDrop") s = sub.add_parser("send", help="send files to an AirDrop receiver") s.add_argument("file", nargs="+") - s.add_argument("-r", "--receiver", help="receiver id or host name (default: the first found)") + s.add_argument("-r", "--receiver", help="receiver name or id (default: the only one found, " + "else you pick from a list)") s.add_argument("-n", "--name", default="EspDrop", help="sender name shown on the device") s.add_argument("-t", "--time", type=float, default=30.0, help="seconds to look for the receiver") s.add_argument("--no-wake", action="store_true", help="do not send the BLE wake") @@ -221,6 +330,10 @@ def main(argv=None, transport=None): rv = sub.add_parser("receive", help="receive files from an Apple device") rv.add_argument("-n", "--name", default="EspDrop", help="name shown on the sender") rv.add_argument("-v", "--verbose", action="store_true", help="debug log with times") + rv.add_argument("-y", "--yes", action="store_true", help="accept every transfer without asking") + rv.add_argument("-d", "--dir", help="folder for received files (default: the current one)") + rv.add_argument("--ask-timeout", type=float, default=60.0, + help="seconds to answer before a transfer is declined") sub.add_parser("status", help="device, interface and counters") c = sub.add_parser("cmd", help="send one device command") diff --git a/host/espdrop/device.py b/host/espdrop/device.py index ef8154b..bc2121d 100644 --- a/host/espdrop/device.py +++ b/host/espdrop/device.py @@ -30,7 +30,8 @@ def __init__(self, port, on_log=None): self._hello_event = threading.Event() self._results = {} # (request type, id) -> err, for waiters self._result_cond = threading.Condition() - self._udp = {} # port -> handler(src16, sport, multicast, data) + self._udp = {} # port -> [handler(src16, sport, multicast, data)] + self._udp_lock = threading.Lock() self.tcp = Manager(self) # proxied TCP connections self._stop = threading.Event() self.error = None # set if the reader thread died @@ -73,8 +74,7 @@ def _dispatch(self, t, p): elif t == link.MSG_UDP_RECV: u = link.parse_udp_recv(p) if u: - handler = self._udp.get(u[0]) - if handler: + for handler in list(self._udp.get(u[0], ())): handler(*u[1:]) elif t in (link.MSG_TCP_OPENED, link.MSG_TCP_DATA, link.MSG_TCP_CREDIT, link.MSG_TCP_CLOSE): @@ -132,22 +132,45 @@ def _wait_result(self, req, ident, timeout): return self._results.pop((req, ident)) def udp_open(self, port, handler, join_mdns=True, timeout=2.0): - """Open a UDP port on the AWDL interface; handler(src16, sport, mcast, data).""" - self._udp[port] = handler + """Open a UDP port on the AWDL interface; handler(src16, sport, mcast, data). + + Several handlers may share a port (the mDNS browser and responder both + use 5353): the device opens it once, every handler gets each datagram, + and it closes when the last one leaves (udp_close).""" + with self._udp_lock: + if self._udp.get(port): + self._udp[port].append(handler) + return + self._udp[port] = [handler] with self._result_cond: self._results.pop((link.MSG_UDP_OPEN, port), None) - self._write(link.udp_open(port, join_mdns)) - err = self._wait_result(link.MSG_UDP_OPEN, port, timeout) + try: + self._write(link.udp_open(port, join_mdns)) + err = self._wait_result(link.MSG_UDP_OPEN, port, timeout) + except Exception: + with self._udp_lock: + self._udp.pop(port, None) + raise if err: - del self._udp[port] + with self._udp_lock: + self._udp.pop(port, None) raise DeviceError(f"UDP port {port}: {link.ERRORS.get(err, err)}") def udp_send(self, port, dst16: bytes, dst_port, data: bytes): self._check() self._write(link.udp_send(port, dst16, dst_port, data)) - def udp_close(self, port): - self._udp.pop(port, None) + def udp_close(self, port, handler=None): + """Remove `handler` (all handlers if None); the port closes with the last.""" + with self._udp_lock: + hs = self._udp.get(port, []) + if handler is not None and handler in hs: + hs.remove(handler) + else: + hs.clear() + if hs: + return + self._udp.pop(port, None) self._write(link.udp_close(port)) def listen(self, port, on_accept): diff --git a/host/espdrop/mdns.py b/host/espdrop/mdns.py index b118a20..9251b99 100644 --- a/host/espdrop/mdns.py +++ b/host/espdrop/mdns.py @@ -106,7 +106,7 @@ def stop(self): if self._thread: self._thread.join(timeout=2) try: - self.device.udp_close(MDNS_PORT) + self.device.udp_close(MDNS_PORT, self._on_udp) except Exception: pass @@ -275,6 +275,6 @@ def stop(self): except Exception: pass try: - self.device.udp_close(MDNS_PORT) + self.device.udp_close(MDNS_PORT, self._on_udp) except Exception: pass diff --git a/host/espdrop/proxy.py b/host/espdrop/proxy.py index 28bf6fc..a620519 100644 --- a/host/espdrop/proxy.py +++ b/host/espdrop/proxy.py @@ -95,6 +95,30 @@ def on_close(self, kind, err): self.opened.set() # a failed connect: wake the waiter self._finish() + def abort(self): + """Cancel the connection from our side: RST to the peer, and the + application's blocked send/recv on `app` returns with an error.""" + if self.closed.is_set(): + return + self.error = self.error or "cancelled" + with self._cv: + self._dead = True + self._cv.notify_all() + try: + self.dev._send(link.tcp_close(self.id, link.TCP_RST)) + except Exception: + pass + for s in (self.app, self._own): + try: + s.shutdown(socket.SHUT_RDWR) # wakes a blocked send/recv + except OSError: + pass + if self.opened.is_set(): + self._down.put(_RST) # the downlink pump closes up + else: + self.opened.set() + self._finish() + # ---- pumps ------------------------------------------------------------- def _uplink(self): diff --git a/host/espdrop/receive.py b/host/espdrop/receive.py index 27eac91..cd9fc65 100644 --- a/host/espdrop/receive.py +++ b/host/espdrop/receive.py @@ -8,27 +8,33 @@ # firewall rule either, on Windows and Linux. # # The server's TLS accept stays on its own listening socket (so it wraps each -# connection itself); we only move bytes, we do not re-implement TLS. +# connection itself); we only move bytes, we do not re-implement TLS. A handler +# subclass asks our caller before a transfer is accepted (on_ask; OpenDrop on +# its own accepts every /Ask) and reports the upload's progress and the files it +# left in the current directory (on_event). # # SPDX-License-Identifier: GPL-3.0-or-later # Copyright (C) 2026 EspDrop contributors import logging import os +import plistlib import socket import threading +import time from http.server import ThreadingHTTPServer from . import env env.load_opendrop() # libarchive and the Windows fixes first; ImportError with advice from opendrop.config import AirDropConfig # noqa: E402 -from opendrop.server import AirDropServerHandler # noqa: E402 +from opendrop.server import AirDropServerHandler, ask_items # noqa: E402 from . import mdns # noqa: E402 logger = logging.getLogger(__name__) AIRDROP_PORT = 8771 +RAW_PREFIX = "airdrop-received-" # OpenDrop keeps an archive it could not unpack under this class _V4Server(ThreadingHTTPServer): @@ -44,37 +50,157 @@ def eui64_linklocal(mac6: bytes) -> bytes: return b"\xfe\x80\x00\x00\x00\x00\x00\x00" + iid +class IncomingAsk: + """What a sender's /Ask says: who, which files (or links), a thumbnail.""" + + def __init__(self, body: bytes): + try: + req = plistlib.loads(body) + except Exception: # noqa: BLE001 - ask anyway, with what we have + req = {} + self.sender = str(req.get("SenderComputerName") or "unknown sender") + self.model = req.get("SenderModelName") + self.files = [str(f.get("FileName", "?")) for f in (req.get("Files") or []) + if isinstance(f, dict)] + self.urls = ask_items(req) if req else [] + icon = req.get("FileIcon") + self.icon = bytes(icon) if isinstance(icon, (bytes, bytearray)) else None + + def summary(self): + what = self.files or self.urls + text = ", ".join(what[:3]) + (f" (+{len(what) - 3} more)" if len(what) > 3 else "") + return f"{self.sender}: {text or 'nothing named'}" + + +class _Counting: + """A request body reader that reports how many bytes went through.""" + + def __init__(self, f, on_bytes): + self._f, self._on = f, on_bytes + + def read(self, *a): + b = self._f.read(*a) + self._on(len(b)) + return b + + def readline(self, *a): + b = self._f.readline(*a) + self._on(len(b)) + return b + + def readinto(self, buf): + n = self._f.readinto(buf) + self._on(n or 0) + return n + + def __getattr__(self, name): + return getattr(self._f, name) + + class Receiver: - def __init__(self, dev, name="EspDrop", flags=0x3fb): + """Receive over the device: mDNS responder, listener, local OpenDrop server. + + on_ask(IncomingAsk) -> bool decides each transfer (None accepts all, as + OpenDrop does); it runs on the server thread, which the sender waits on. + on_event(kind, **info) gets "asked" (ask, accepted), "receiving" (ask, + received, total) and "received" (ask, files, ok, raw, size, secs). Files + land in the current directory.""" + + def __init__(self, dev, name="EspDrop", flags=0x3fb, on_ask=None, on_event=None): self.dev = dev env.ensure_identity(name) # before OpenDrop would call openssl self.config = AirDropConfig(computer_name=name) self.config.flags = flags + self.on_ask = on_ask + self.on_event = on_event or (lambda kind, **info: None) self.httpd = None self.loop_port = None self.responder = None + self.accepting = True # False: refuse new AWDL connections + self._last_ask = None self._splices = [] # ---- the local OpenDrop server ----------------------------------------- + def _handler(self): + rx = self + + class Handler(AirDropServerHandler): + config = rx.config + + def _ask_user(self, post_data): + return rx._decide(post_data) + + def handle_upload(self): + rx._upload(self, super().handle_upload) + + return Handler + def _start_server(self): - handler = AirDropServerHandler - handler.config = self.config - self.httpd = _V4Server(("127.0.0.1", 0), handler) + self.httpd = _V4Server(("127.0.0.1", 0), self._handler()) self.httpd.socket = self.config.get_ssl_context().wrap_socket( self.httpd.socket, server_side=True) self.loop_port = self.httpd.socket.getsockname()[1] threading.Thread(target=self.httpd.serve_forever, name="espdrop-httpd", daemon=True).start() + def _decide(self, post_data): + ask = IncomingAsk(post_data) + try: + ok = True if self.on_ask is None else bool(self.on_ask(ask)) + except Exception as e: # noqa: BLE001 - an unaskable question is a no + logger.warning("asking about %s failed (%s): declined", ask.summary(), e) + ok = False + logger.info("%s %s", "accepted" if ok else "declined", ask.summary()) + if ok: + self._last_ask = ask + self.on_event("asked", ask=ask, accepted=ok) + return ok + + def _upload(self, handler, base): + ask = self._last_ask + try: + total = int(handler.headers.get("TotalBytes") or 0) or None + except ValueError: + total = None + got = [0] + last = [0.0] + + def on_bytes(n): + got[0] += n + now = time.monotonic() + if now - last[0] >= 0.25: + last[0] = now + self.on_event("receiving", ask=ask, received=got[0], total=total) + + here = os.getcwd() + before = set(os.listdir(here)) + start = time.monotonic() + rfile = handler.rfile + handler.rfile = _Counting(rfile, on_bytes) + try: + base() + finally: + handler.rfile = rfile + new = sorted(set(os.listdir(here)) - before) + raw = [n for n in new if n.startswith(RAW_PREFIX)] + files = [os.path.join(here, n) for n in new if not n.startswith(RAW_PREFIX)] + ok = bool(files) and not raw and not any(f.endswith(".partial") for f in files) + self.on_event("received", ask=ask, files=files, ok=ok, + raw=os.path.join(here, raw[0]) if raw else None, + size=got[0], secs=time.monotonic() - start) + # ---- one accepted AWDL connection -------------------------------------- def _on_accept(self, conn): + if not self.accepting: + conn.abort() + return try: local = socket.create_connection(("127.0.0.1", self.loop_port)) except OSError as e: logger.warning("could not reach the local server (%s)", e) - conn.app.close() + conn.abort() return self._splice(conn.app, local) self._splice(local, conn.app) @@ -117,12 +243,15 @@ def start(self): host=f"{instance}.local.") self.responder.start() self.dev.listen(AIRDROP_PORT, self._on_accept) - logger.info("receiving as %r on the ESP's AWDL link (port %d); Ctrl+C to stop", + logger.info("receiving as %r on the ESP's AWDL link (port %d)", self.config.computer_name, AIRDROP_PORT) def stop(self): + self.accepting = False if self.responder: self.responder.stop() + self.responder = None if self.httpd: self.httpd.shutdown() self.httpd.server_close() + self.httpd = None diff --git a/host/espdrop/session.py b/host/espdrop/session.py new file mode 100644 index 0000000..9040d21 --- /dev/null +++ b/host/espdrop/session.py @@ -0,0 +1,419 @@ +# EspDrop - one long-running link to the device, for the GUI (and any UI). +# +# The CLI opens the port for each command; a GUI needs one owner of the port +# that stays: receiving is ready the whole time, sends run alongside, and when +# the ESP is unplugged the session waits and connects again. Session wraps the +# pieces the CLI uses (Device, Receiver, Browser, airdrop.send_files) in that +# shape and reports everything as events: +# +# on_event(kind, **info), from worker threads (a GUI hands it to its own): +# "device" state="connecting"|"connected"|"lost"|"stopped", info, error +# "power" text= +# "browse" on=True|False +# "receiver" action="found"|"updated"|"gone", receiver=ReceiverInfo +# "send" transfer=Transfer, state, progress (0..1), error +# "receive" ask, phase="asked"|"receiving"|"received", plus Receiver's info +# "log" text +# on_ask(IncomingAsk) -> bool, on the server thread while the sender waits; +# None accepts everything. Unanswered after ask_timeout: declined. +# +# SPDX-License-Identifier: GPL-3.0-or-later +# Copyright (C) 2026 EspDrop contributors + +import itertools +import logging +import os +import threading +import time + +from . import link +from .device import Device, DeviceError + +logger = logging.getLogger(__name__) + +RECONNECT_SECS = 2.0 +KEEPALIVE_SECS = 30.0 +BROWSE_SECS = 60.0 + + +class ReceiverInfo: + """An AirDrop receiver seen by the browse, with its name once /Discover told it.""" + + def __init__(self, mdns_receiver): + self.mdns = mdns_receiver + self.id = mdns_receiver.id + self.name = None # ReceiverComputerName from /Discover + self.model = None + self.discovered = False # /Discover answered with a name + self.present = True # False after a goodbye or a new browse + + @property + def label(self): + return self.name or self.id + + def __repr__(self): + return f"" + + +class Transfer: + """One send, as the UI sees it.""" + _ids = itertools.count(1) + + def __init__(self, receiver, files, job): + self.id = next(self._ids) + self.receiver = receiver + self.files = list(files) + self.job = job + self.started = time.monotonic() + + @property + def state(self): + return self.job.state + + @property + def error(self): + return self.job.error + + @property + def size(self): + return self.job.size + + def sent(self): + return self.job.sent() + + def progress(self): + return self.job.progress() + + +class Session: + def __init__(self, port=None, name="EspDrop", receive=True, download_dir=None, + on_event=None, on_ask=None, ask_timeout=60.0, transport_factory=None): + self.port_name = port + self.name = name + self.download_dir = download_dir + self.on_event = on_event or (lambda kind, **info: None) + self.on_ask = on_ask + self.ask_timeout = ask_timeout + self._factory = transport_factory # tests: a callable returning a transport + self._receive = receive + self._lock = threading.RLock() + self._stop = threading.Event() + self._thread = None + self.dev = None + self.state = "stopped" + self._receiver = None + self._browser = None + self._browse_until = 0.0 + self._receivers = {} # id -> ReceiverInfo + self._transfers = {} # id -> Transfer + self._wake_until = 0.0 + + # ---- public API -------------------------------------------------------- + + def start(self): + if self._thread: + return + if self.download_dir: + os.makedirs(self.download_dir, exist_ok=True) + os.chdir(self.download_dir) # OpenDrop's server writes into cwd + self._stop.clear() + self._thread = threading.Thread(target=self._run, name="espdrop-session", daemon=True) + self._thread.start() + + def stop(self): + self._stop.set() + if self._thread: + self._thread.join(timeout=10) + self._thread = None + + @property + def receiving(self): + return self._receive + + def set_receiving(self, on): + with self._lock: + self._receive = bool(on) + if self.state != "connected": + return + if on and not self._receiver: + self._start_receiver() + elif not on and self._receiver: + self._stop_receiver() + + def set_name(self, name): + """The name others see; restarts receiving if it runs.""" + with self._lock: + self.name = name + if self._receiver: + self._stop_receiver() + self._start_receiver() + + def receivers(self): + with self._lock: + return list(self._receivers.values()) + + def browse(self, seconds=BROWSE_SECS): + """Look for receivers for `seconds` (BLE wake from the ESP meanwhile).""" + with self._lock: + if self.state != "connected": + raise DeviceError("no device connected") + self._browse_until = time.monotonic() + seconds + if self._browser: + self._wake(seconds) + return + for r in self._receivers.values(): + r.present = False + from .mdns import Browser + self._browser = Browser(self.dev, on_found=self._found, on_gone=self._gone) + self._wake(seconds) + self._browser.start() + self._emit("browse", on=True) + + def stop_browse(self): + with self._lock: + b, self._browser = self._browser, None + self._browse_until = 0.0 + if b: + b.stop() + self._emit("browse", on=False) + self._unwake() + + @property + def browsing(self): + return self._browser is not None + + def send(self, receiver_id, files): + """Start sending files to a receiver from the list; returns a Transfer.""" + from .airdrop import SendJob + files = [os.path.abspath(f) for f in files] + for f in files: + if not os.path.isfile(f): + raise FileNotFoundError(f) + with self._lock: + r = self._receivers.get(receiver_id) + if r is None: + raise KeyError(f"no receiver {receiver_id!r}") + if self.state != "connected": + raise DeviceError("no device connected") + t = Transfer(r, files, SendJob(files)) + self._transfers[t.id] = t + dev = self.dev + self._wake(180) # also covers Accept and the upload + threading.Thread(target=self._send, args=(dev, t), name=f"espdrop-send{t.id}", + daemon=True).start() + return t + + def cancel(self, transfer_id): + t = self._transfers.get(transfer_id) + if t: + t.job.cancel() + + def transfers(self): + return list(self._transfers.values()) + + def command(self, text, timeout=3.0): + """A device command (as in the bridge); returns its reply.""" + dev = self.dev + if not dev or self.state != "connected": + raise DeviceError("no device connected") + return dev.cmd(text, timeout=timeout) + + # ---- the supervisor ------------------------------------------------------ + + def _emit(self, kind, **info): + try: + self.on_event(kind, **info) + except Exception: # noqa: BLE001 - a UI bug must not kill us + logger.exception("event handler failed for %s", kind) + + def _set_state(self, state, **info): + self.state = state + self._emit("device", state=state, **info) + + def _open(self): + if self._factory: + return self._factory() + from .transport import open_port + return open_port(self.port_name) + + def _run(self): + while not self._stop.is_set(): + self._set_state("connecting") + try: + dev = Device(self._open(), on_log=lambda text: self._emit("log", text=text)) + try: + info = dev.hello(link.MODE_IP) + except Exception: + dev.close() + raise + except Exception as e: # noqa: BLE001 - not there yet: try again + self._set_state("lost", error=str(e)) + self._stop.wait(RECONNECT_SECS) + continue + with self._lock: + self.dev = dev + self._set_state("connected", info=info) + if self._receive: + try: + self._start_receiver() + except Exception as e: # noqa: BLE001 + self._emit("log", text=f"receiving did not start: {e}") + self._poll_power() + self._serve(dev) + with self._lock: + self._teardown(dev) + if not self._stop.is_set(): + self._set_state("lost", error=str(dev.error or "closed")) + self._stop.wait(RECONNECT_SECS) + self._set_state("stopped") + + def _serve(self, dev): + next_keepalive = time.monotonic() + KEEPALIVE_SECS + while not self._stop.wait(0.25): + if dev.error: + return + now = time.monotonic() + if self._browser and now >= self._browse_until: + self.stop_browse() + for t in list(self._transfers.values()): + if t.state == "uploading": + self._emit_send(t) # progress, 4 times a second + if now >= next_keepalive: + next_keepalive = now + KEEPALIVE_SECS + self._poll_power() + + def _poll_power(self): + """The device's power state; also tells it the host is still there.""" + try: + reply = self.dev.cmd("power") + except Exception: # noqa: BLE001 - lost link + return + if not reply.startswith("unknown"): # firmware without a sleep mode + self._emit("power", text=reply) + + def _teardown(self, dev): + b, self._browser = self._browser, None + if b: + b.stop() + self._emit("browse", on=False) + if self._receiver: + self._stop_receiver() + for t in self._transfers.values(): + if t.state in ("new", "asking", "uploading"): + t.job.cancel() + try: + dev.close() + except Exception: # noqa: BLE001 + pass + self.dev = None + + # ---- receiving ----------------------------------------------------------- + + def _start_receiver(self): + from .receive import Receiver + self._receiver = Receiver(self.dev, name=self.name, on_ask=self._ask, + on_event=self._on_receive) + self._receiver.start() + + def _stop_receiver(self): + rx, self._receiver = self._receiver, None + if rx: + rx.stop() + + def _ask(self, ask): + if self.on_ask is None: + return True + result = {} + done = threading.Event() + + def run(): + try: + result["ok"] = bool(self.on_ask(ask)) + finally: + done.set() + threading.Thread(target=run, name="espdrop-ask", daemon=True).start() + if not done.wait(self.ask_timeout): + self._emit("log", text=f"no answer within {self.ask_timeout:.0f} s: declined " + f"{ask.summary()}") + return False + return result.get("ok", False) + + def _on_receive(self, phase, **info): + self._emit("receive", phase=phase, **info) + + # ---- browsing and sending ------------------------------------------------ + + def _found(self, r): + with self._lock: + info = self._receivers.get(r.id) + new = info is None + if new: + info = self._receivers[r.id] = ReceiverInfo(r) + info.mdns, info.present = r, True + dev = self.dev + self._emit("receiver", action="found" if new else "updated", receiver=info) + if not info.discovered and dev: + threading.Thread(target=self._discover, args=(dev, info), daemon=True).start() + + def _gone(self, r): + with self._lock: + info = self._receivers.get(r.id) + if info: + info.present = False + if info: + self._emit("receiver", action="gone", receiver=info) + + def _discover(self, dev, info): + from .airdrop import discover + try: + d = discover(dev, info.mdns, name=self.name) + except Exception as e: # noqa: BLE001 - the list keeps the id + self._emit("log", text=f"/Discover {info.id}: {e}") + return + if d: + info.name, info.model, info.discovered = d["name"], d.get("model"), True + self._emit("receiver", action="updated", receiver=info) + + def _send(self, dev, t): + from .airdrop import send_files + try: + send_files(dev, t.receiver.mdns, t.files, name=self.name, job=t.job, + on_state=lambda job: self._emit_send(t)) + except Exception as e: # noqa: BLE001 - state says failed + if t.job.state not in ("failed", "cancelled"): + t.job.state, t.job.error = "failed", str(e) + self._emit_send(t) + finally: + self._unwake() + + def _emit_send(self, t): + # state and progress as of now: the Transfer itself keeps changing + self._emit("send", transfer=t, state=t.state, progress=t.progress(), error=t.error) + + def _wake(self, seconds): + """BLE wake from the ESP for at least `seconds` (sends and browsing).""" + dev = self.dev + if not dev or not (dev.hello_info or {}).get("caps", 0) & link.CAP_BLE: + return + until = time.monotonic() + seconds + if until <= self._wake_until: + return + self._wake_until = until + try: + dev.cmd(f"wake {int(min(600, seconds))}") + except Exception as e: # noqa: BLE001 + self._emit("log", text=f"wake: {e}") + + def _unwake(self): + """End the BLE wake once neither a browse nor a send needs it.""" + with self._lock: + busy = self._browser or any(t.state in ("new", "asking", "uploading") + for t in self._transfers.values()) + dev = self.dev + if busy or not dev: + return + self._wake_until = 0.0 + try: + dev.cmd("wake off") + except Exception: # noqa: BLE001 + pass diff --git a/host/tests/fake_device.py b/host/tests/fake_device.py index f6517a8..ff46800 100644 --- a/host/tests/fake_device.py +++ b/host/tests/fake_device.py @@ -247,3 +247,98 @@ def pump(): pass threading.Thread(target=pump, daemon=True).start() return a + + +class DuplexBridgeDevice(FakeDevice): + """Both directions at once, as the session needs them: TCP_CONNECT goes to + a loopback target (an OpenDrop receiver), open_incoming() simulates a peer + connecting to our listener. unplug() makes the transport fail like a + pulled USB cable.""" + + def __init__(self, target=None, mdns_port=8770, **kw): + super().__init__(**kw) + self.target = target + self.mdns_port = mdns_port + self.listening = None + self._socks = {} # conn id -> device-side socket + self._next = 0x8000 + self._unplugged = False + + # transport API: an unplugged device fails reads and writes + def read(self, timeout): + if self._unplugged: + raise OSError("device unplugged") + return super().read(min(timeout, 0.05)) + + def write(self, data, timeout=5.0): + if self._unplugged: + raise OSError("device unplugged") + super().write(data, timeout) + + def unplug(self): + self._unplugged = True + + def _handle(self, t, p): + import socket + if t == link.MSG_TCP_CONNECT and len(p) >= 20: + cid = struct.unpack_from("= 2: + self.listening = struct.unpack_from("= 2: + sk = self._socks.get(struct.unpack_from("= 3: + sk = self._socks.get(struct.unpack_from(" Date: Sat, 24 Oct 2026 09:45:06 +0000 Subject: [PATCH 78/99] feat(power): Wi-Fi off while nothing needs AWDL AWDL kept the radio receiving and sending all the time, so the C5 ran warm even unused. The radio now sleeps (AWDL task paused, esp_wifi_stop) unless something needs it, and wakes with channel, TX power and promiscuous mode re-applied. - Policy in components/awdl/awdl_power.c, with host tests: ON mode never sleeps; AUTO keeps the radio on for a host hold (power hold), the BLE wake of a send, or an open TCP connection plus 20 s. A host that stays silent for 90 s loses its requests. - main/power.c runs it on a small task; `power` shows the state, `power hold ` / `power release` / `power on` / `power auto` set it. - Boot: asleep until a host says otherwise. HELLO L2 (awdl_bridge.py) sets ON, as before; HELLO IP sets AUTO. - Host: the session holds the radio while receiving (with an optional window that closes by itself), browsing or sending, and releases it otherwise; the CLI holds it for find, send and receive. No listening for other devices' Bluetooth: receiving works while the app keeps a receive window open. Builds for the C5 (bridge and console) and the C3 without warnings. 44 host tests, C host tests pass. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01XzgaN4Bv1m8Ka9iqo7QVUN --- docs/SPIKES.md | 34 +++++ .../spikes/components/awdl/CMakeLists.txt | 2 +- firmware/spikes/components/awdl/awdl_power.c | 60 ++++++++ .../components/awdl/include/awdl_power.h | 80 ++++++++++ .../spikes/components/awdl/test/host/Makefile | 8 +- .../awdl/test/host/test_awdl_power.c | 76 ++++++++++ firmware/spikes/main/CMakeLists.txt | 2 +- firmware/spikes/main/app_main.c | 7 + firmware/spikes/main/awdl_app.c | 25 +++- firmware/spikes/main/awdl_app.h | 6 + firmware/spikes/main/bridge.c | 26 ++++ firmware/spikes/main/power.c | 138 ++++++++++++++++++ firmware/spikes/main/power.h | 41 ++++++ firmware/spikes/main/radio.c | 35 +++++ firmware/spikes/main/radio.h | 11 ++ firmware/spikes/main/sockproxy.c | 7 + firmware/spikes/main/sockproxy.h | 3 + host/espdrop/cli.py | 23 ++- host/espdrop/session.py | 61 +++++++- host/tests/test_host.py | 3 +- host/tests/test_session.py | 15 +- tools/awdl_bridge.py | 2 +- 22 files changed, 646 insertions(+), 19 deletions(-) create mode 100644 firmware/spikes/components/awdl/awdl_power.c create mode 100644 firmware/spikes/components/awdl/include/awdl_power.h create mode 100644 firmware/spikes/components/awdl/test/host/test_awdl_power.c create mode 100644 firmware/spikes/main/power.c create mode 100644 firmware/spikes/main/power.h diff --git a/docs/SPIKES.md b/docs/SPIKES.md index aafabc7..3141ca7 100644 --- a/docs/SPIKES.md +++ b/docs/SPIKES.md @@ -494,6 +494,40 @@ komplett durch (frische venv, 17 Patches, alle Prüfsummen gleich, `check` ok); erneutes Ausführen erkennt die fertige Installation, eine mit `opendrop_install.sh` gebaute auch. Zusammen 34 Host-Tests. +## Schlafmodus: WLAN aus, solange niemand AWDL braucht + +AWDL hält das WLAN des ESP ständig im Empfang und sendet alle paar +Millisekunden; deshalb wurde der C5 auch unbenutzt warm. Die Firmware schaltet +das WLAN jetzt ab, solange nichts es braucht (`main/power.c`, Regeln in +`components/awdl/awdl_power.c` mit Host-Tests). Bluetooth bleibt davon +unberührt. + +- **Nach dem Start** schläft das WLAN sofort, bis eine Host-App sich meldet. +- **L2-Betrieb** (`awdl_bridge.py`, `HELLO L2`): WLAN immer an, wie bisher. +- **IP-Betrieb** (`espdrop`, `HELLO IP`): WLAN nur an, solange die Host-App es + hält: beim Suchen und Senden (`wake ` hält es mit), in einem + Empfangsfenster (`power hold `, die App erneuert das alle 30 s) und + solange eine TCP-Verbindung offen ist (plus 20 s). Meldet sich die Host-App + 90 s nicht mehr (beendet, abgesteckt), verfallen ihre Anforderungen. +- **Befehl `power`:** zeigt `power auto|on awake|asleep (grund) hold=… wake=… + tcp=… sleeps=… asleep=…%`; `power hold <1..3600>`, `power release`, + `power on`, `power auto`. Für Diagnose im IP-Betrieb (z. B. `ping6`) vorher + `power hold 120`. + +Die Firmware lauscht dabei nicht auf Bluetooth-Signale anderer Geräte. +Empfangen ist deshalb nur möglich, solange die App ein Empfangsfenster offen +hat, ähnlich Apples „Für alle für 10 Minuten“. `espdrop receive` hält das +Fenster offen, solange es läuft. + +Test (Firmware neu flashen, danach den ESP einmal ab- und anstecken): +```sh +~/opendrop-venv/bin/python tools/espdrop.py cmd power # asleep (none), kurz nach dem Start +~/opendrop-venv/bin/python tools/espdrop.py find -t 20 # wacht auf, findet das iPad +~/opendrop-venv/bin/python tools/espdrop.py cmd power # nach dem Ende wieder asleep +``` +Der ESP sollte im Leerlauf spürbar kühler bleiben. `asleep=` zeigt, welchen +Anteil der Zeit seit dem Start das WLAN aus war. + ## Danach Ergebnisse (auch Fehlschläge) in `docs/UEBERBLICK.md` unter „Offene Fragen" diff --git a/firmware/spikes/components/awdl/CMakeLists.txt b/firmware/spikes/components/awdl/CMakeLists.txt index 5adfe9e..ae1ae9e 100644 --- a/firmware/spikes/components/awdl/CMakeLists.txt +++ b/firmware/spikes/components/awdl/CMakeLists.txt @@ -1,5 +1,5 @@ idf_component_register( SRCS "awdl_frame.c" "awdl_sync.c" "awdl_election.c" "awdl_peers.c" "awdl_node.c" "awdl_cobs.c" - "awdl_dedup.c" + "awdl_dedup.c" "awdl_power.c" INCLUDE_DIRS "include" ) diff --git a/firmware/spikes/components/awdl/awdl_power.c b/firmware/spikes/components/awdl/awdl_power.c new file mode 100644 index 0000000..910d654 --- /dev/null +++ b/firmware/spikes/components/awdl/awdl_power.c @@ -0,0 +1,60 @@ +/* + * EspDrop - when the AWDL radio may sleep. See awdl_power.h. + * + * SPDX-License-Identifier: GPL-3.0-or-later + * Copyright (C) 2026 EspDrop contributors + */ +#include "awdl_power.h" + +void awdl_power_init(awdl_power_t *p, awdl_power_mode_t mode, int64_t now) { + p->mode = mode; + p->hold_until = p->wake_until = p->busy_until = now; + p->host_seen = now; +} + +void awdl_power_set_mode(awdl_power_t *p, awdl_power_mode_t mode) { p->mode = mode; } + +void awdl_power_hold(awdl_power_t *p, int64_t now, uint32_t secs) { + p->hold_until = now + (int64_t)secs * 1000000; +} + +void awdl_power_wake(awdl_power_t *p, int64_t now, uint32_t secs) { + p->wake_until = now + (int64_t)secs * 1000000; +} + +void awdl_power_host_seen(awdl_power_t *p, int64_t now) { p->host_seen = now; } + +void awdl_power_busy(awdl_power_t *p, int64_t now, int open_tcp) { + if (open_tcp > 0 && now + AWDL_POWER_BUSY_GRACE_US > p->busy_until) + p->busy_until = now + AWDL_POWER_BUSY_GRACE_US; +} + +/* A host request counts while the host is around: a deadline beyond the host + * timeout is cut back to it, so a closed app does not keep the radio on. */ +static int64_t effective(const awdl_power_t *p, int64_t deadline) { + int64_t cap = p->host_seen + AWDL_POWER_HOST_TIMEOUT_US; + return deadline < cap ? deadline : cap; +} + +awdl_awake_reason_t awdl_power_reason(const awdl_power_t *p, int64_t now) { + if (p->mode == AWDL_POWER_ON) return AWDL_AWAKE_ON; + if (now < p->busy_until) return AWDL_AWAKE_BUSY; + if (now < effective(p, p->hold_until)) return AWDL_AWAKE_HOLD; + if (now < effective(p, p->wake_until)) return AWDL_AWAKE_WAKE; + return AWDL_AWAKE_NONE; +} + +uint32_t awdl_power_left_s(const awdl_power_t *p, int64_t deadline, int64_t now) { + int64_t d = effective(p, deadline) - now; + return d > 0 ? (uint32_t)((d + 999999) / 1000000) : 0; +} + +const char *awdl_power_reason_name(awdl_awake_reason_t r) { + switch (r) { + case AWDL_AWAKE_ON: return "on"; + case AWDL_AWAKE_HOLD: return "hold"; + case AWDL_AWAKE_WAKE: return "wake"; + case AWDL_AWAKE_BUSY: return "busy"; + default: return "none"; + } +} diff --git a/firmware/spikes/components/awdl/include/awdl_power.h b/firmware/spikes/components/awdl/include/awdl_power.h new file mode 100644 index 0000000..226db33 --- /dev/null +++ b/firmware/spikes/components/awdl/include/awdl_power.h @@ -0,0 +1,80 @@ +/* + * EspDrop - when the AWDL radio may sleep (platform-independent policy). + * + * AWDL keeps the Wi-Fi radio receiving and sending all the time, which makes + * the chip warm even when nobody uses it. In AUTO mode the radio only runs + * while something needs it, and is off otherwise: + * - a host request: `power hold ` (browsing, a receive window the user + * opened, a send), or the BLE wake for a send (`wake `); + * - an open TCP connection through the socket proxy, plus a short grace + * period after the last one, so a transfer is never cut by sleep. + * Requests lapse when the host goes quiet (closed app, unplugged PC side) + * for longer than AWDL_POWER_HOST_TIMEOUT_US. ON mode never sleeps (the L2 + * bridge and the console expect AWDL all the time). + * + * This file only decides; main/power.c switches the radio. Times are in + * microseconds of a monotonic clock (esp_timer_get_time on the device). + * + * SPDX-License-Identifier: GPL-3.0-or-later + * Copyright (C) 2026 EspDrop contributors + */ +#ifndef ESPDROP_AWDL_POWER_H +#define ESPDROP_AWDL_POWER_H + +#include +#include + +#ifdef __cplusplus +extern "C" { +#endif + +#define AWDL_POWER_BUSY_GRACE_US (20LL * 1000000) /* after the last TCP connection */ +#define AWDL_POWER_HOST_TIMEOUT_US (90LL * 1000000) /* host silent: its requests lapse */ + +typedef enum { + AWDL_POWER_AUTO = 0, /* radio on only while needed */ + AWDL_POWER_ON = 1, /* radio always on */ +} awdl_power_mode_t; + +/* Why the radio is (or would be) on; for the `power` status line. */ +typedef enum { + AWDL_AWAKE_NONE = 0, /* asleep */ + AWDL_AWAKE_ON, /* ON mode */ + AWDL_AWAKE_HOLD, /* power hold */ + AWDL_AWAKE_WAKE, /* BLE wake for a send */ + AWDL_AWAKE_BUSY, /* TCP connection (or its grace period) */ +} awdl_awake_reason_t; + +typedef struct { + awdl_power_mode_t mode; + int64_t hold_until; /* power hold deadline */ + int64_t wake_until; /* BLE wake deadline */ + int64_t busy_until; /* last TCP connection + grace */ + int64_t host_seen; /* last message from the host */ +} awdl_power_t; + +void awdl_power_init(awdl_power_t *p, awdl_power_mode_t mode, int64_t now); +void awdl_power_set_mode(awdl_power_t *p, awdl_power_mode_t mode); + +/* Keep the radio on for at least `secs` from now (0 releases the hold). */ +void awdl_power_hold(awdl_power_t *p, int64_t now, uint32_t secs); +/* The BLE wake runs for `secs` from now (0: it was stopped). */ +void awdl_power_wake(awdl_power_t *p, int64_t now, uint32_t secs); +/* Any message from the host. */ +void awdl_power_host_seen(awdl_power_t *p, int64_t now); +/* Call regularly with the number of open TCP connections. */ +void awdl_power_busy(awdl_power_t *p, int64_t now, int open_tcp); + +/* Should the radio be on now, and why (AWDL_AWAKE_NONE: let it sleep). */ +awdl_awake_reason_t awdl_power_reason(const awdl_power_t *p, int64_t now); + +/* Seconds left on a deadline (0 when passed, or lapsed with the host). */ +uint32_t awdl_power_left_s(const awdl_power_t *p, int64_t deadline, int64_t now); + +const char *awdl_power_reason_name(awdl_awake_reason_t r); + +#ifdef __cplusplus +} +#endif + +#endif /* ESPDROP_AWDL_POWER_H */ diff --git a/firmware/spikes/components/awdl/test/host/Makefile b/firmware/spikes/components/awdl/test/host/Makefile index 081714a..c2e4738 100644 --- a/firmware/spikes/components/awdl/test/host/Makefile +++ b/firmware/spikes/components/awdl/test/host/Makefile @@ -11,10 +11,10 @@ INCLUDE = -I$(COMP)/include # Platform-independent sources under test. LIB = $(COMP)/awdl_frame.c $(COMP)/awdl_sync.c $(COMP)/awdl_election.c \ $(COMP)/awdl_peers.c $(COMP)/awdl_node.c $(COMP)/awdl_cobs.c \ - $(COMP)/awdl_dedup.c + $(COMP)/awdl_dedup.c $(COMP)/awdl_power.c HEADERS = $(wildcard $(COMP)/include/*.h) -BINS = test_awdl_frame test_awdl_cobs +BINS = test_awdl_frame test_awdl_cobs test_awdl_power .PHONY: all run clean all: run @@ -25,9 +25,13 @@ test_awdl_frame: $(LIB) test_awdl_frame.c $(HEADERS) test_awdl_cobs: $(LIB) test_awdl_cobs.c $(HEADERS) $(CC) $(CFLAGS) $(INCLUDE) $(LIB) test_awdl_cobs.c -o $@ +test_awdl_power: $(LIB) test_awdl_power.c $(HEADERS) + $(CC) $(CFLAGS) $(INCLUDE) $(LIB) test_awdl_power.c -o $@ + run: $(BINS) ./test_awdl_frame ./test_awdl_cobs + ./test_awdl_power clean: rm -f $(BINS) diff --git a/firmware/spikes/components/awdl/test/host/test_awdl_power.c b/firmware/spikes/components/awdl/test/host/test_awdl_power.c new file mode 100644 index 0000000..f1bdd71 --- /dev/null +++ b/firmware/spikes/components/awdl/test/host/test_awdl_power.c @@ -0,0 +1,76 @@ +/* + * Host tests for awdl_power: when the AWDL radio may sleep. + * + * SPDX-License-Identifier: GPL-3.0-or-later + */ +#include +#include + +#include "awdl_power.h" + +#define S(x) ((int64_t)(x) * 1000000) + +static int failures; +#define CHECK(cond) do { \ + if (!(cond)) { printf("FAIL %s:%d: %s\n", __FILE__, __LINE__, #cond); failures++; } \ +} while (0) + +static void test_auto_sleeps_without_requests(void) { + awdl_power_t p; + awdl_power_init(&p, AWDL_POWER_AUTO, S(100)); + CHECK(awdl_power_reason(&p, S(100)) == AWDL_AWAKE_NONE); + awdl_power_set_mode(&p, AWDL_POWER_ON); + CHECK(awdl_power_reason(&p, S(5000)) == AWDL_AWAKE_ON); +} + +static void test_hold_and_release(void) { + awdl_power_t p; + awdl_power_init(&p, AWDL_POWER_AUTO, 0); + awdl_power_hold(&p, S(10), 60); + awdl_power_host_seen(&p, S(10)); + CHECK(awdl_power_reason(&p, S(69)) == AWDL_AWAKE_HOLD); + CHECK(awdl_power_left_s(&p, p.hold_until, S(40)) == 30); + CHECK(awdl_power_reason(&p, S(70)) == AWDL_AWAKE_NONE); + awdl_power_hold(&p, S(80), 60); + awdl_power_hold(&p, S(81), 0); /* release */ + CHECK(awdl_power_reason(&p, S(82)) == AWDL_AWAKE_NONE); +} + +static void test_wake_and_busy(void) { + awdl_power_t p; + awdl_power_init(&p, AWDL_POWER_AUTO, 0); + awdl_power_wake(&p, S(1), 30); + CHECK(awdl_power_reason(&p, S(20)) == AWDL_AWAKE_WAKE); + awdl_power_busy(&p, S(25), 1); /* a transfer is running */ + CHECK(awdl_power_reason(&p, S(40)) == AWDL_AWAKE_BUSY); + awdl_power_busy(&p, S(40), 0); /* it ended at ~25 s */ + CHECK(awdl_power_reason(&p, S(44)) == AWDL_AWAKE_BUSY); /* grace: 25 + 20 */ + CHECK(awdl_power_reason(&p, S(45)) == AWDL_AWAKE_NONE); +} + +static void test_requests_lapse_with_the_host(void) { + awdl_power_t p; + awdl_power_init(&p, AWDL_POWER_AUTO, 0); + awdl_power_host_seen(&p, S(10)); + awdl_power_hold(&p, S(10), 600); /* a long receive window */ + CHECK(awdl_power_reason(&p, S(99)) == AWDL_AWAKE_HOLD); + CHECK(awdl_power_reason(&p, S(100)) == AWDL_AWAKE_NONE); /* host silent 90 s */ + awdl_power_host_seen(&p, S(120)); /* it is back: hold counts again */ + CHECK(awdl_power_reason(&p, S(121)) == AWDL_AWAKE_HOLD); + /* An open connection keeps the radio on even when the host is quiet. */ + awdl_power_busy(&p, S(300), 2); + CHECK(awdl_power_reason(&p, S(310)) == AWDL_AWAKE_BUSY); +} + +int main(void) { + test_auto_sleeps_without_requests(); + test_hold_and_release(); + test_wake_and_busy(); + test_requests_lapse_with_the_host(); + if (failures) { + printf("test_awdl_power: %d failure(s)\n", failures); + return EXIT_FAILURE; + } + printf("test_awdl_power: all passed\n"); + return EXIT_SUCCESS; +} diff --git a/firmware/spikes/main/CMakeLists.txt b/firmware/spikes/main/CMakeLists.txt index 03ab48c..0c95fa5 100644 --- a/firmware/spikes/main/CMakeLists.txt +++ b/firmware/spikes/main/CMakeLists.txt @@ -26,7 +26,7 @@ message(STATUS "EspDrop: target '${IDF_TARGET}', USB-Serial/JTAG via '${ESPDROP_ idf_component_register( SRCS "app_main.c" "radio.c" "console.c" "awdl_app.c" "bridge.c" "ble_wake.c" - "awdl_netif.c" "sockproxy.c" + "awdl_netif.c" "sockproxy.c" "power.c" INCLUDE_DIRS "." REQUIRES awdl esp_wifi esp_event esp_netif nvs_flash console esp_timer esp_hw_support bt lwip ${ESPDROP_USJ_COMPONENT} ${ESPDROP_USJ_LL_COMPONENT} diff --git a/firmware/spikes/main/app_main.c b/firmware/spikes/main/app_main.c index 76739a4..1fa0641 100644 --- a/firmware/spikes/main/app_main.c +++ b/firmware/spikes/main/app_main.c @@ -38,6 +38,7 @@ static uint8_t boot_channel(void) { #include "bridge.h" #include "awdl_app.h" #include "ble_wake.h" +#include "power.h" /* AWDL peer appeared/left -> tell the host so it can manage the awdl0 neighbour * table. Runs on the AWDL timing task, so it only enqueues (bridge_notify_peer @@ -89,6 +90,12 @@ void app_main(void) { if (radio_promisc(true) != ESP_OK) bridge_log("warning: promiscuous RX did not enable"); bridge_log("awdl rx/tx up on %s ch %u", CONFIG_IDF_TARGET, chan); + + /* Nobody needs AWDL until a host says so: the radio goes to sleep right + * after boot (Wi-Fi off, the chip stays cool) and a HELLO sets the mode - + * L2 always on, IP on while the host holds it (power.h). */ + if (power_init(AWDL_POWER_AUTO) != 0) + bridge_log("warning: no power task, the radio stays on"); } #else /* console mode */ diff --git a/firmware/spikes/main/awdl_app.c b/firmware/spikes/main/awdl_app.c index 9a21a0a..08ba5c6 100644 --- a/firmware/spikes/main/awdl_app.c +++ b/firmware/spikes/main/awdl_app.c @@ -63,6 +63,7 @@ static TaskHandle_t s_task; static bool s_started; static char s_name[AWDL_NAME_MAX + 1]; /* kept for awdl_app_set_self */ static volatile bool s_tx_paused; /* awdl_app_pause_tx: no PSF/MIF/data TX */ +static volatile bool s_asleep; /* awdl_app_sleep: radio off, nothing sent or queued */ /* Automatic channel: follow the master onto its preferred social channel. Off * by default (set on for 5 GHz chips at start); a manual `chan` turns it off. */ @@ -201,9 +202,9 @@ static void awdl_task(void *arg) { /* Only advance the timer once the frame actually went out; if the * current slot is one we advertise as absent, retry on the next loop * (~20 ms) until we reach an advertised-present slot. */ - if (!s_tx_paused && now >= next_psf && send_frame(AWDL_SUBTYPE_PSF, false)) + if (!s_tx_paused && !s_asleep && now >= next_psf && send_frame(AWDL_SUBTYPE_PSF, false)) next_psf = now + PSF_INTERVAL_US; - if (!s_tx_paused && now >= next_mif && send_frame(AWDL_SUBTYPE_MIF, false)) + if (!s_tx_paused && !s_asleep && now >= next_mif && send_frame(AWDL_SUBTYPE_MIF, false)) next_mif = now + MIF_INTERVAL_US; if (now >= next_tick) { uint8_t cur[AWDL_MAX_PEERS][6]; @@ -230,7 +231,7 @@ static void awdl_task(void *arg) { /* Flush queued host data frames, but only during a slot we advertise as * present (= the master's ch6 slots), so our data TX lands when the peer * is on our channel. Build+send under the same lock that guards self. */ - if (!s_tx_paused && s_tx_q && uxQueueMessagesWaiting(s_tx_q) > 0) { + if (!s_tx_paused && !s_asleep && s_tx_q && uxQueueMessagesWaiting(s_tx_q) > 0) { static uint8_t txbuf[1600]; tx_data_t td; now = esp_timer_get_time(); @@ -262,6 +263,7 @@ int awdl_app_tx_ethernet(const uint8_t *eth, size_t len, void *ctx) { if (!s_started || !s_tx_q || !s_tx_stage_lock || len < 14) return -1; uint16_t et = (uint16_t)(((uint16_t)eth[12] << 8) | eth[13]); if (et != ETHERTYPE_IPV6) return 0; /* awdl0 is IPv6-only; ignore the rest */ + if (s_asleep) return 0; /* radio off: dropped, as on a link that is down */ size_t plen = len - 14; if (plen > TX_PAYLOAD_MAX) return -1; xSemaphoreTake(s_tx_stage_lock, portMAX_DELAY); @@ -387,6 +389,23 @@ void awdl_app_pause_tx(bool paused) { if (!paused && s_task) xTaskNotifyGive(s_task); /* catch up on due frames */ } +void awdl_app_sleep(bool asleep) { + if (!s_started) return; + if (asleep) { + s_asleep = true; + /* A frame being built or sent holds s_lock; once we have it, nothing + * more goes out. Queued host data would only be stale on waking. */ + xSemaphoreTake(s_lock, portMAX_DELAY); + xQueueReset(s_tx_q); + xSemaphoreGive(s_lock); + } else { + s_asleep = false; + if (s_task) xTaskNotifyGive(s_task); /* PSF/MIF are due at once */ + } +} + +bool awdl_app_asleep(void) { return s_asleep; } + void awdl_app_tx(uint8_t subtype) { if (!s_started) return; send_frame(subtype, true); /* console `tx`: send now regardless of slot */ diff --git a/firmware/spikes/main/awdl_app.h b/firmware/spikes/main/awdl_app.h index c95b298..d62a6f6 100644 --- a/firmware/spikes/main/awdl_app.h +++ b/firmware/spikes/main/awdl_app.h @@ -47,6 +47,12 @@ void awdl_app_tx(uint8_t subtype); * channels, so nothing goes out on a channel we only pass through. */ void awdl_app_pause_tx(bool paused); +/* Sleep (true): send nothing more (PSF, MIF, data), drop queued host data and + * ignore new frames, so main/power.c can switch the radio off; the node keeps + * running and its peers age out as they go quiet. Wake (false): resume. */ +void awdl_app_sleep(bool asleep); +bool awdl_app_asleep(void); + /* * Retune the radio and the AWDL node to a channel at runtime, keeping sync and * the peer table (unlike awdl_app_set_self, which resets the node). keep_auto diff --git a/firmware/spikes/main/bridge.c b/firmware/spikes/main/bridge.c index 0df2d15..9e57dc6 100644 --- a/firmware/spikes/main/bridge.c +++ b/firmware/spikes/main/bridge.c @@ -27,6 +27,7 @@ #include "ble_wake.h" /* `wake`: the AirDrop wake over our own Bluetooth */ #include "awdl_netif.h" /* IP mode: the AWDL link as an lwIP interface */ #include "sockproxy.h" /* IP mode: host sockets on that interface */ +#include "power.h" /* `power`: the radio sleeps while nothing needs it */ #include "radio.h" /* radio_get_stats, for the `radio` diagnostic command */ #define BRIDGE_MAX_PAYLOAD 1600 @@ -495,6 +496,7 @@ static void handle_cmd(const uint8_t *p, size_t n) { else snprintf(out, sizeof(out), "wake off"); } else if (strcmp(arg, "off") == 0) { int rc = ble_wake_stop(); + power_wake(0); if (rc) snprintf(out, sizeof(out), "wake error %d", rc); else snprintf(out, sizeof(out), "wake off"); } else { @@ -518,11 +520,31 @@ static void handle_cmd(const uint8_t *p, size_t n) { "`wake <1..%d> [16 hex digits]`", BLE_WAKE_MAX_SECS); } else { int rc = ble_wake_start((unsigned)secs, have_h ? h : NULL); + if (!rc) power_wake((uint32_t)secs); /* AWDL up for the send it announces */ if (rc) snprintf(out, sizeof(out), "wake error %d", rc); else snprintf(out, sizeof(out), "wake on %u s left", ble_wake_remaining()); } } cmd_reply(out); + } else if (strcmp(cmd, "power") == 0 || strncmp(cmd, "power ", 6) == 0) { + /* `power` shows whether the radio is on and why; `power hold ` + * keeps it on (browsing, a receive window), `power release` ends that, + * `power on` / `power auto` set the mode (HELLO sets it too). */ + const char *arg = cmd[5] == '\0' ? "" : cmd + 6; + bool bad = false; + if (strcmp(arg, "on") == 0) power_set_mode(AWDL_POWER_ON); + else if (strcmp(arg, "auto") == 0) power_set_mode(AWDL_POWER_AUTO); + else if (strcmp(arg, "release") == 0) power_hold(0); + else if (strncmp(arg, "hold ", 5) == 0) { + char *end; + long secs = strtol(arg + 5, &end, 10); + bad = end == arg + 5 || *end || secs < 1 || secs > 3600; + if (!bad) power_hold((uint32_t)secs); + } else if (arg[0]) bad = true; + if (bad) snprintf(out, sizeof(out), "power: use `power`, `power hold <1..3600>`, " + "`power release`, `power on` or `power auto`"); + else power_format_status(out, sizeof(out)); + cmd_reply(out); } else if (strcmp(cmd, "mode") == 0 || strncmp(cmd, "mode ", 5) == 0) { /* `mode` shows L2 (frames to the host's awdl0) or IP (the device's own * IPv6 stack); `mode l2` / `mode ip` switch, as HELLO does. */ @@ -626,6 +648,7 @@ static void handle_msg(const uint8_t *msg, size_t len) { uint8_t type = msg[0]; const uint8_t *payload = msg + 1; size_t plen = len - 1; + power_host_seen(); /* the host is there; its requests stay valid */ switch (type) { case BRIDGE_MSG_ECHO: @@ -652,6 +675,9 @@ static void handle_msg(const uint8_t *msg, size_t len) { set_mode(payload[1]); else set_mode(BRIDGE_MODE_L2); + /* L2 (the host's awdl0) expects AWDL all the time; in IP mode the + * radio runs only while the host asks for it (power.h). */ + power_set_mode(s_mode == BRIDGE_MODE_IP ? AWDL_POWER_AUTO : AWDL_POWER_ON); /* A new host session starts with no sockets, and says how much each * connection may send it before it grants more. */ sockproxy_reset(plen >= 6 ? (uint32_t)(payload[2] | (payload[3] << 8) | diff --git a/firmware/spikes/main/power.c b/firmware/spikes/main/power.c new file mode 100644 index 0000000..8b81ae5 --- /dev/null +++ b/firmware/spikes/main/power.c @@ -0,0 +1,138 @@ +/* + * EspDrop - switch the AWDL radio off while nothing needs it. See power.h. + * + * SPDX-License-Identifier: GPL-3.0-or-later + * Copyright (C) 2026 EspDrop contributors + */ +#include "power.h" + +#include + +#include "esp_err.h" +#include "esp_timer.h" +#include "freertos/FreeRTOS.h" +#include "freertos/task.h" + +#include "awdl_app.h" +#include "bridge.h" +#include "radio.h" +#include "sockproxy.h" + +#define POWER_TICK_MS 500 + +static awdl_power_t s_pol; +static portMUX_TYPE s_mux = portMUX_INITIALIZER_UNLOCKED; +static TaskHandle_t s_task; +static volatile bool s_awake = true; /* the radio comes up awake at boot */ +static uint32_t s_sleeps, s_wakes, s_errors; +static int64_t s_asleep_us, s_since; /* time asleep, last switch */ + +static int64_t now_us(void) { return esp_timer_get_time(); } + +static void kick(void) { + if (s_task) xTaskNotifyGive(s_task); +} + +/* Power task only: bring the radio to the wanted state. */ +static void apply(awdl_awake_reason_t why) { + bool want = why != AWDL_AWAKE_NONE; + if (want == s_awake) return; + int64_t t = now_us(); + if (want) { + int err = radio_wake(); + awdl_app_sleep(false); + s_awake = true; + s_wakes++; + s_asleep_us += t - s_since; + if (err != ESP_OK) s_errors++; + bridge_log("power: awake (%s)%s", awdl_power_reason_name(why), + err == ESP_OK ? "" : ", radio error"); + } else { + awdl_app_sleep(true); /* nothing is sent from here on */ + int err = radio_sleep(); + if (err != ESP_OK) { /* stays on: try again next tick */ + awdl_app_sleep(false); + s_errors++; + return; + } + s_awake = false; + s_sleeps++; + bridge_log("power: asleep (Wi-Fi off)"); + } + s_since = t; +} + +static void power_task(void *arg) { + (void)arg; + for (;;) { + (void)ulTaskNotifyTake(pdTRUE, pdMS_TO_TICKS(POWER_TICK_MS)); + int open = sockproxy_tcp_open(); + int64_t t = now_us(); + portENTER_CRITICAL(&s_mux); + awdl_power_busy(&s_pol, t, open); + awdl_awake_reason_t why = awdl_power_reason(&s_pol, t); + portEXIT_CRITICAL(&s_mux); + apply(why); + } +} + +int power_init(awdl_power_mode_t mode) { + if (s_task) return 0; + s_since = now_us(); + awdl_power_init(&s_pol, mode, s_since); + if (xTaskCreate(power_task, "power", 3072, NULL, 4, &s_task) != pdPASS) return -1; + return 0; +} + +void power_set_mode(awdl_power_mode_t mode) { + portENTER_CRITICAL(&s_mux); + awdl_power_set_mode(&s_pol, mode); + portEXIT_CRITICAL(&s_mux); + kick(); +} + +void power_hold(uint32_t secs) { + int64_t t = now_us(); + portENTER_CRITICAL(&s_mux); + awdl_power_hold(&s_pol, t, secs); + awdl_power_host_seen(&s_pol, t); + portEXIT_CRITICAL(&s_mux); + kick(); +} + +void power_wake(uint32_t secs) { + int64_t t = now_us(); + portENTER_CRITICAL(&s_mux); + awdl_power_wake(&s_pol, t, secs); + portEXIT_CRITICAL(&s_mux); + kick(); +} + +void power_host_seen(void) { + int64_t t = now_us(); + portENTER_CRITICAL(&s_mux); + awdl_power_host_seen(&s_pol, t); + portEXIT_CRITICAL(&s_mux); +} + +bool power_awake(void) { return s_awake; } + +int power_format_status(char *buf, size_t cap) { + int64_t t = now_us(); + portENTER_CRITICAL(&s_mux); + awdl_power_t p = s_pol; + portEXIT_CRITICAL(&s_mux); + awdl_awake_reason_t why = awdl_power_reason(&p, t); + int64_t asleep = s_asleep_us + (s_awake ? 0 : t - s_since); + int64_t up = t > 0 ? t : 1; + return snprintf(buf, cap, + "power %s %s (%s) hold=%us wake=%us tcp=%d host=%us ago " + "sleeps=%u wakes=%u asleep=%u%% err=%u", + p.mode == AWDL_POWER_ON ? "on" : "auto", + s_awake ? "awake" : "asleep", awdl_power_reason_name(why), + (unsigned)awdl_power_left_s(&p, p.hold_until, t), + (unsigned)awdl_power_left_s(&p, p.wake_until, t), + sockproxy_tcp_open(), (unsigned)((t - p.host_seen) / 1000000), + (unsigned)s_sleeps, (unsigned)s_wakes, + (unsigned)(asleep * 100 / up), (unsigned)s_errors); +} diff --git a/firmware/spikes/main/power.h b/firmware/spikes/main/power.h new file mode 100644 index 0000000..ab54f9d --- /dev/null +++ b/firmware/spikes/main/power.h @@ -0,0 +1,41 @@ +/* + * EspDrop - switch the AWDL radio off while nothing needs it. + * + * The policy (when the radio may sleep) is components/awdl/awdl_power.h; this + * runs it on a small task and switches the radio: asleep means the AWDL task + * sends nothing (awdl_app_sleep) and Wi-Fi is stopped (radio_sleep). Bluetooth + * is untouched, so the BLE wake for a send keeps running either way. + * + * The bridge feeds it: every host message (power_host_seen), `power hold`, + * `wake `, and the mode from HELLO (L2: always on, IP: auto). + * + * SPDX-License-Identifier: GPL-3.0-or-later + * Copyright (C) 2026 EspDrop contributors + */ +#pragma once + +#include +#include +#include + +#include "awdl_power.h" + +#ifdef __cplusplus +extern "C" { +#endif + +/* Start the task in `mode`. Call once, after awdl_app_start and bridge_start. */ +int power_init(awdl_power_mode_t mode); + +void power_set_mode(awdl_power_mode_t mode); +void power_hold(uint32_t secs); /* 0 releases */ +void power_wake(uint32_t secs); /* BLE wake running for secs; 0 stopped */ +void power_host_seen(void); +bool power_awake(void); + +/* One-line status for the `power` command. */ +int power_format_status(char *buf, size_t cap); + +#ifdef __cplusplus +} +#endif diff --git a/firmware/spikes/main/radio.c b/firmware/spikes/main/radio.c index 03e0cbb..f84699b 100644 --- a/firmware/spikes/main/radio.c +++ b/firmware/spikes/main/radio.c @@ -26,6 +26,8 @@ static const char *TAG = "radio"; static radio_stats_t s_stats; static portMUX_TYPE s_stats_mux = portMUX_INITIALIZER_UNLOCKED; static volatile bool s_promisc; +static volatile bool s_asleep; /* radio_sleep: Wi-Fi stopped */ +static bool s_wake_promisc; /* promiscuous again on radio_wake */ static uint8_t s_channel = 6; static radio_action_handler_t s_action_handler; static void *s_action_ctx; @@ -325,9 +327,41 @@ int radio_set_self_mac(const uint8_t mac[6]) { int radio_set_channel(uint8_t channel) { if (!radio_channel_supported(channel)) return ESP_ERR_INVALID_ARG; + if (s_asleep) { /* applied by radio_wake */ + s_channel = channel; + return ESP_OK; + } return set_channel_and_power(channel); } +int radio_sleep(void) { + if (s_asleep) return ESP_OK; + s_wake_promisc = s_promisc; + if (s_promisc) radio_promisc(false); + esp_err_t err = esp_wifi_stop(); + if (err != ESP_OK) { + if (s_wake_promisc) radio_promisc(true); + return err; + } + s_asleep = true; + return ESP_OK; +} + +int radio_wake(void) { + if (!s_asleep) return ESP_OK; + esp_err_t err = esp_wifi_start(); + if (err != ESP_OK) return err; + s_asleep = false; + err = apply_rf(s_channel); /* power save off, country, band, channel, TX power */ + if (s_wake_promisc) { + esp_err_t e = radio_promisc(true); + if (err == ESP_OK) err = e; + } + return err; +} + +bool radio_asleep(void) { return s_asleep; } + uint8_t radio_get_channel(void) { return s_channel; } int radio_set_max_tx_power(int8_t qdbm) { @@ -342,6 +376,7 @@ int8_t radio_get_max_tx_power(void) { } int radio_tx(const uint8_t *buf, int len) { + if (s_asleep) return ESP_ERR_WIFI_NOT_STARTED; esp_err_t err = esp_wifi_80211_tx(WIFI_IF_STA, buf, len, false /* keep our seq */); if (err == ESP_OK) stat_inc(&s_stats.tx_ok); else stat_inc(&s_stats.tx_err); diff --git a/firmware/spikes/main/radio.h b/firmware/spikes/main/radio.h index 9562186..42ed3c1 100644 --- a/firmware/spikes/main/radio.h +++ b/firmware/spikes/main/radio.h @@ -127,6 +127,17 @@ int radio_set_self_mac(const uint8_t mac[6]); int radio_set_channel(uint8_t channel); uint8_t radio_get_channel(void); +/* + * Switch the Wi-Fi radio off (radio_sleep: promiscuous off, esp_wifi_stop) and + * on again (radio_wake: esp_wifi_start, power save off, country, band, channel + * and TX power re-applied, promiscuous back if it was on). For main/power.c; + * the AWDL task must be paused first (awdl_app_sleep). While asleep, + * radio_set_channel only records the channel and radio_tx fails. + */ +int radio_sleep(void); +int radio_wake(void); +bool radio_asleep(void); + /* * TX power ceiling in 0.25 dBm steps (8..84, e.g. 80 = 20 dBm), as for * esp_wifi_set_max_tx_power. The effective ceiling is the lower of this and the diff --git a/firmware/spikes/main/sockproxy.c b/firmware/spikes/main/sockproxy.c index c9918d4..980a882 100644 --- a/firmware/spikes/main/sockproxy.c +++ b/firmware/spikes/main/sockproxy.c @@ -582,6 +582,13 @@ void sockproxy_reset(uint32_t host_credit) { xSemaphoreTake(s_reset_done, pdMS_TO_TICKS(1000)); } +int sockproxy_tcp_open(void) { + int open = 0; + for (int i = 0; i < SOCKPROXY_TCP_SLOTS; i++) + if (s_tcp[i].used) open++; + return open; +} + int sockproxy_format_status(char *buf, size_t cap) { int o = snprintf(buf, cap, "udp:"); for (int i = 0; i < UDP_SLOTS && o > 0 && (size_t)o < cap; i++) diff --git a/firmware/spikes/main/sockproxy.h b/firmware/spikes/main/sockproxy.h index 7c93def..6c9bd89 100644 --- a/firmware/spikes/main/sockproxy.h +++ b/firmware/spikes/main/sockproxy.h @@ -45,6 +45,9 @@ void sockproxy_handle(uint8_t type, const uint8_t *payload, size_t len); * credit). Waits for the lwIP thread. */ void sockproxy_reset(uint32_t host_credit); +/* Open TCP connections (any task; a snapshot, for main/power.c). */ +int sockproxy_tcp_open(void); + /* One-line status for the `ip` command. */ int sockproxy_format_status(char *buf, size_t cap); diff --git a/host/espdrop/cli.py b/host/espdrop/cli.py index 8343bba..1aebb4f 100644 --- a/host/espdrop/cli.py +++ b/host/espdrop/cli.py @@ -42,12 +42,22 @@ def _hello(dev): return h +def _hold(dev, secs): + """Keep the ESP's radio on for `secs` (0: let it sleep again). Firmware + without a sleep mode answers `unknown cmd`: its radio is always on.""" + try: + dev.cmd(f"power hold {int(secs)}" if secs else "power release") + except DeviceError: + pass + + def cmd_find(args, transport=None): from .mdns import AIRDROP, Browser dev = _open(args, transport) woke = False try: h = _hello(dev) + _hold(dev, args.time + 10) if not args.no_wake: if h["caps"] & link.CAP_BLE: secs = min(600, int(args.time) + 10) @@ -117,6 +127,8 @@ def packet(src, mcast, what): dev.cmd("wake off") except DeviceError: pass + if not dev.error: + _hold(dev, 0) dev.close() @@ -154,6 +166,7 @@ def cmd_send(args, transport=None): woke = False try: h = _hello(dev) + _hold(dev, min(3600, args.time + 600)) # browse, Accept and the upload if not args.no_wake and h["caps"] & link.CAP_BLE: secs = min(600, int(args.time) + 120) # also covers Accept and the upload reply = dev.cmd(f"wake {secs}") @@ -189,6 +202,8 @@ def packet(src, mcast, what): dev.cmd("wake off") except DeviceError: pass + if not dev.error: + _hold(dev, 0) dev.close() @@ -262,13 +277,19 @@ def on_event(kind, **info): print(f"ready: on the iPhone/iPad, share a file and pick {args.name!r}; files go to " f"{os.getcwd()}. Ctrl+C to stop.", flush=True) try: + renew = 0.0 while not dev.error: + if time.monotonic() >= renew: # the radio stays on while we receive + _hold(dev, 90) + renew = time.monotonic() + 30 time.sleep(0.3) except KeyboardInterrupt: print() return 0 finally: rx.stop() + if not dev.error: + _hold(dev, 0) dev.close() @@ -276,7 +297,7 @@ def cmd_status(args, transport=None): dev = _open(args, transport) try: _hello(dev) - for c in ("node", "ip", "stats", "wake"): + for c in ("node", "ip", "stats", "wake", "power"): print(f"{c}: {dev.cmd(c)}", flush=True) return 0 finally: diff --git a/host/espdrop/session.py b/host/espdrop/session.py index 9040d21..47d1ec1 100644 --- a/host/espdrop/session.py +++ b/host/espdrop/session.py @@ -10,6 +10,7 @@ # "device" state="connecting"|"connected"|"lost"|"stopped", info, error # "power" text= # "browse" on=True|False +# "receiving" on=True|False, until= # "receiver" action="found"|"updated"|"gone", receiver=ReceiverInfo # "send" transfer=Transfer, state, progress (0..1), error # "receive" ask, phase="asked"|"receiving"|"received", plus Receiver's info @@ -34,6 +35,7 @@ RECONNECT_SECS = 2.0 KEEPALIVE_SECS = 30.0 BROWSE_SECS = 60.0 +HOLD_SECS = 90 # `power hold` per keepalive: the radio stays on while needed class ReceiverInfo: @@ -96,6 +98,8 @@ def __init__(self, port=None, name="EspDrop", receive=True, download_dir=None, self.ask_timeout = ask_timeout self._factory = transport_factory # tests: a callable returning a transport self._receive = receive + self._receive_until = None # end of a receive window (monotonic) + self._held = None # last power request sent: True hold, False release self._lock = threading.RLock() self._stop = threading.Event() self._thread = None @@ -130,15 +134,24 @@ def stop(self): def receiving(self): return self._receive - def set_receiving(self, on): + def set_receiving(self, on, for_secs=None): + """Receive (others see us and can send) or not. for_secs: a window that + closes by itself, like Apple's "Everyone for 10 minutes"; the ESP's + radio is only on while receiving, browsing or sending.""" with self._lock: self._receive = bool(on) - if self.state != "connected": - return - if on and not self._receiver: - self._start_receiver() - elif not on and self._receiver: - self._stop_receiver() + self._receive_until = time.monotonic() + for_secs if on and for_secs else None + if self.state == "connected": + if on and not self._receiver: + self._start_receiver() + elif not on and self._receiver: + self._stop_receiver() + self._emit("receiving", on=self._receive, until=self._receive_until) + self._power() + + @property + def receive_until(self): + return self._receive_until def set_name(self, name): """The name others see; restarts receiving if it runs.""" @@ -166,7 +179,10 @@ def browse(self, seconds=BROWSE_SECS): from .mdns import Browser self._browser = Browser(self.dev, on_found=self._found, on_gone=self._gone) self._wake(seconds) - self._browser.start() + self._power() + with self._lock: + if self._browser: + self._browser.start() self._emit("browse", on=True) def stop_browse(self): @@ -177,6 +193,7 @@ def stop_browse(self): b.stop() self._emit("browse", on=False) self._unwake() + self._power() @property def browsing(self): @@ -199,6 +216,7 @@ def send(self, receiver_id, files): self._transfers[t.id] = t dev = self.dev self._wake(180) # also covers Accept and the upload + self._power() threading.Thread(target=self._send, args=(dev, t), name=f"espdrop-send{t.id}", daemon=True).start() return t @@ -258,6 +276,8 @@ def _run(self): self._start_receiver() except Exception as e: # noqa: BLE001 self._emit("log", text=f"receiving did not start: {e}") + self._held = None + self._power() self._poll_power() self._serve(dev) with self._lock: @@ -275,11 +295,15 @@ def _serve(self, dev): now = time.monotonic() if self._browser and now >= self._browse_until: self.stop_browse() + if self._receive_until and now >= self._receive_until: + self.set_receiving(False) # the receive window closed for t in list(self._transfers.values()): if t.state == "uploading": self._emit_send(t) # progress, 4 times a second if now >= next_keepalive: next_keepalive = now + KEEPALIVE_SECS + self._held = None # renew the hold + self._power() self._poll_power() def _poll_power(self): @@ -385,6 +409,7 @@ def _send(self, dev, t): self._emit_send(t) finally: self._unwake() + self._power() def _emit_send(self, t): # state and progress as of now: the Transfer itself keeps changing @@ -404,6 +429,26 @@ def _wake(self, seconds): except Exception as e: # noqa: BLE001 self._emit("log", text=f"wake: {e}") + def _busy(self): + return bool(self._receive or self._browser or any( + t.state in ("new", "asking", "uploading") for t in self._transfers.values())) + + def _power(self): + """Ask the ESP to keep its radio on while we receive, browse or send, + and to let it sleep otherwise (firmware without a sleep mode ignores + it: its radio is always on).""" + dev = self.dev + if not dev or self.state != "connected": + return + need = self._busy() + if need == self._held: + return + self._held = need + try: + dev.cmd(f"power hold {HOLD_SECS}" if need else "power release") + except Exception as e: # noqa: BLE001 + self._emit("log", text=f"power: {e}") + def _unwake(self): """End the BLE wake once neither a browse nor a send needs it.""" with self._lock: diff --git a/host/tests/test_host.py b/host/tests/test_host.py index b1bed20..7001cfd 100644 --- a/host/tests/test_host.py +++ b/host/tests/test_host.py @@ -170,7 +170,8 @@ def test_find(self): self.assertEqual(rc, 0, out + err) self.assertIn(f"found {PAD_ID} [{PAD_IP}]:8770 flags 111611", out) self.assertIn("wake 12", fake.commands) - self.assertEqual(fake.commands[-1], "wake off") + self.assertEqual(fake.commands[-2:], ["wake off", "power release"]) + self.assertIn("power hold 12", fake.commands) # the radio is on while we look self.assertTrue(fake.closed) def test_find_nothing(self): diff --git a/host/tests/test_session.py b/host/tests/test_session.py index 3834941..ce2cefe 100644 --- a/host/tests/test_session.py +++ b/host/tests/test_session.py @@ -101,7 +101,8 @@ def test_browse_names_and_send(self): self.assertEqual([x for i, x in enumerate(states) if i == 0 or states[i - 1] != x], ["asking", "uploading", "done"]) s.stop_browse() - self.assertEqual(fake.commands[-1], "wake off") + self.assertEqual(fake.commands[-2:], ["wake off", "power release"]) + self.assertIn("power hold 90", fake.commands) finally: rx.stop() got = [f for f in os.listdir(dl) if open(os.path.join(dl, f), "rb").read() == payload] @@ -191,6 +192,18 @@ def test_accepted_file_arrives(self): with open(done["files"][0], "rb") as f: self.assertEqual(f.read(), payload) + def test_receive_window_closes_and_radio_sleeps(self): + fake, dl = self.start(lambda ask: True) + self.assertEqual(fake.commands.count("power hold 90"), 1) # held while receiving + self.session.set_receiving(True, for_secs=0.6) + self.events.wait(lambda k, i: k == "receiving" and not i["on"], secs=5) + self.assertFalse(self.session.receiving) + for _ in range(40): + if fake.commands[-1:] == ["power release"]: + break + time.sleep(0.05) + self.assertEqual(fake.commands[-1], "power release") + def test_declined_and_unanswered(self): fake, dl = self.start(lambda ask: False) self.assertFalse(self.incoming(fake, "nein.txt", b"no")) diff --git a/tools/awdl_bridge.py b/tools/awdl_bridge.py index 467620a..94fa59a 100644 --- a/tools/awdl_bridge.py +++ b/tools/awdl_bridge.py @@ -66,7 +66,7 @@ CTL_DIR = "/run/espdrop" # Device commands the control socket passes on (no usbtest: it floods the link). CTL_COMMANDS = {"wake", "mac", "stats", "radio", "tx", "node", "chan", "seq", "checks", "ping", - "mode", "ip", "ping6"} + "mode", "ip", "ping6", "power"} CTL_TIMEOUT = 3.0 # seconds to wait for the device's reply to a socket command From e98e76cf9c71f59f30231b66490b3aacc43a97f8 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 24 Oct 2026 09:51:35 +0000 Subject: [PATCH 79/99] feat(gui): PySide6 desktop app on the session core espdrop gui (Windows: tools\espdrop-gui.cmd, no console) is one window that owns the ESP while it runs: - device and radio state (connected, radio on and why, or asleep); - receiving in a window that closes by itself (10 min, 1 h, or until off), the name others see, and the download folder; - a dialog before every incoming transfer: sender, files, thumbnail, Annehmen / Ablehnen, declined after 60 s without an answer; - search with the BLE wake, receivers listed by their /Discover names, send by dropping files on one or with a file dialog; - transfers with progress, cancel and open; tray icon, notifications, reconnect after unplugging. Session events reach the GUI thread through a Qt signal; calls that talk to the device run off it. The installer adds PySide6 and espdrop check reports it. Offscreen GUI tests drive search and send, accept and decline through the dialog. 48 host tests. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01XzgaN4Bv1m8Ka9iqo7QVUN --- docs/SPIKES.md | 42 +++ host/README.md | 32 +- host/espdrop/cli.py | 14 +- host/espdrop/env.py | 6 + host/espdrop/gui.py | 665 ++++++++++++++++++++++++++++++++++++++ host/espdrop/session.py | 7 + host/tests/test_gui.py | 173 ++++++++++ tools/espdrop-gui.cmd | 15 + tools/opendrop_install.py | 16 +- 9 files changed, 958 insertions(+), 12 deletions(-) create mode 100644 host/espdrop/gui.py create mode 100644 host/tests/test_gui.py create mode 100644 tools/espdrop-gui.cmd diff --git a/docs/SPIKES.md b/docs/SPIKES.md index 3141ca7..a62de15 100644 --- a/docs/SPIKES.md +++ b/docs/SPIKES.md @@ -528,6 +528,48 @@ Test (Firmware neu flashen, danach den ESP einmal ab- und anstecken): Der ESP sollte im Leerlauf spürbar kühler bleiben. `asleep=` zeigt, welchen Anteil der Zeit seit dem Start das WLAN aus war. +## Desktop-App (PySide6) + +Die Host-App hat jetzt einen gemeinsamen Kern (`host/espdrop/session.py`) und +darauf ein Fenster (`host/espdrop/gui.py`). Der Kern hält den ESP dauerhaft +und baut die Verbindung nach dem Abstecken selbst wieder auf. Er empfängt in +einem Zeitfenster und sendet nebenher; alle Ereignisse gehen an die +Oberfläche. Neu dabei: + +- **Annehmen-Abfrage:** Jede eingehende Übertragung fragt zuerst (Absender, + Dateien, Vorschaubild). Ohne Antwort in 60 s wird abgelehnt. OpenDrop allein + nimmt alles an. Auch `espdrop receive` fragt jetzt `y/N` (`--yes` nimmt alles + an, `-d ORDNER` legt das Ziel fest). +- **Empfängerauswahl:** Gefundene Geräte erscheinen mit ihrem Namen aus + `/Discover` („Bens iPad“), nicht nur mit der Kennung. Im Fenster wählen oder + Dateien auf ein Gerät ziehen; `espdrop send -r NAME` (Name, Teil davon oder + Kennung), bei mehreren ohne `-r` eine Liste zum Auswählen. +- **Empfangsfenster** statt dauernd empfangsbereit (10 Minuten, 1 Stunde oder + dauerhaft), passend zum Schlafmodus der Firmware. + +Start: Linux `~/opendrop-venv/bin/python tools/espdrop.py gui`, Windows +`.\tools\espdrop-gui.cmd` (ohne Konsolenfenster). PySide6 installiert +`tools/opendrop_install.py`; eine mit `opendrop_install.sh` gebaute venv +ergänzt `python3 tools/opendrop_install.py` (nur was fehlt) oder +`~/opendrop-venv/bin/pip install PySide6`. + +Test auf der Hardware (Firmware mit Schlafmodus geflasht): +1. App starten: oben „ESP verbunden“, rechts „Funk aus (spart Strom)“. +2. „Geräte suchen“, am iPad Teilen-Menü öffnen: das iPad erscheint mit Namen. + Eine Datei darauf ziehen, am iPad annehmen: „gesendet“. +3. „Empfangsbereit machen“, am iPad etwas teilen und „EspDrop“ wählen: der + Dialog fragt, „Annehmen“, die Datei liegt in `Downloads/EspDrop`. + Einmal „Ablehnen“: das iPad meldet die Ablehnung. +4. Fenster schließen (läuft im Tray weiter), ESP ab- und anstecken: die App + verbindet sich wieder. +5. Nach dem Ende des Empfangsfensters und ohne Suche: „Funk aus“, der ESP + bleibt kühl. + +Tests ohne Hardware: `test_session.py` (Suche mit Namen, Senden mit +Fortschritt und Abbrechen, Empfangen mit Annehmen/Ablehnen/Zeitüberschreitung, +Empfangsfenster, Neuverbinden) und `test_gui.py` (das Fenster offscreen: +suchen und senden, Dialog annehmen und ablehnen). Zusammen 48 Host-Tests. + ## Danach Ergebnisse (auch Fehlschläge) in `docs/UEBERBLICK.md` unter „Offene Fragen" diff --git a/host/README.md b/host/README.md index 27a6a80..f998445 100644 --- a/host/README.md +++ b/host/README.md @@ -14,8 +14,9 @@ how to test each stage: [`docs/SPIKES.md`](../docs/SPIKES.md). | `espdrop/airdrop.py` | `send`: the patched OpenDrop client over the proxy | | `espdrop/receive.py` | `receive`: the patched OpenDrop server on loopback | | `espdrop/env.py` | platform setup: libarchive, TLS certificate, Windows fixes, `check` | -| `espdrop/session.py` | one long-running link for a GUI: receiving always ready, sends alongside, events, reconnect | -| `espdrop/cli.py` | `find`, `send`, `receive`, `status`, `cmd`, `check` | +| `espdrop/session.py` | one long-running link for a GUI: receive windows, sends alongside, events, reconnect | +| `espdrop/gui.py` | the desktop app (PySide6) on the session | +| `espdrop/cli.py` | `find`, `send`, `receive`, `status`, `cmd`, `check`, `gui` | | `tests/` | simulated devices with iPad-shaped mDNS answers, end-to-end via OpenDrop | `receive` asks before each transfer (`y`/`n` within 60 s, else declined; @@ -25,6 +26,27 @@ the name) and lists the receivers to choose from when it finds several. Only one program can own the serial port: stop `tools/awdl_bridge.py` first. +## Desktop app + +`espdrop gui` (Windows: `.\tools\espdrop-gui.cmd`, no console window) opens +one window that owns the ESP for as long as it runs: + +- **Empfangen:** "Empfangsbereit machen" for 10 minutes, an hour or until + switched off; others see the name set at the top. Every incoming transfer + asks first (sender, files, thumbnail; no answer in 60 s = declined). Files go + to the folder shown (default `Downloads/EspDrop`). +- **Senden:** "Geräte suchen" (60 s, with the ESP's Bluetooth wake) lists the + receivers by name; drop files on one, or pick one and "Dateien senden …". +- **Übertragungen:** progress, cancel and open via the context menu. +- The ESP's radio is only on while receiving, searching or sending (firmware + with the sleep mode, docs/SPIKES.md); the label top right says which. +- Closing the window keeps it in the tray; "Beenden" ends it. Unplugging the + ESP is fine: it reconnects when it comes back. + +It needs PySide6 in the venv: `tools/opendrop_install.py` installs it; for a +venv made by `opendrop_install.sh`, run `python3 tools/opendrop_install.py` +once (it only adds what is missing) or `~/opendrop-venv/bin/pip install PySide6`. + ## Linux The OpenDrop venv from `tools/opendrop_install.sh` (needs python3, git, @@ -51,13 +73,15 @@ py tools\opendrop_install.py .\tools\espdrop.cmd find .\tools\espdrop.cmd send C:\path\photo.jpg .\tools\espdrop.cmd receive +.\tools\espdrop-gui.cmd ``` `opendrop_install.py` builds `%USERPROFILE%\opendrop-venv`: OpenDrop 0.13.0 from PyPI, the 17 airdrop-mt7921 patches (downloaded at the pinned revision; every patch and every resulting file is checked against pinned digests, so the code -is byte-identical to the Linux install), pyserial and cryptography (the TLS -certificate, which OpenDrop would otherwise make with openssl). It ends with +is byte-identical to the Linux install), pyserial, cryptography (the TLS +certificate, which OpenDrop would otherwise make with openssl) and PySide6 +(the desktop app). It ends with `espdrop check`. `espdrop.cmd` runs the app with that venv (`OPENDROP_VENV` picks another) and works in cmd and PowerShell. Received files land in the current folder. The port is found by its Espressif USB id, else `-p COM5`. diff --git a/host/espdrop/cli.py b/host/espdrop/cli.py index 1aebb4f..92f78b4 100644 --- a/host/espdrop/cli.py +++ b/host/espdrop/cli.py @@ -6,6 +6,7 @@ # espdrop status device, interface, counters # espdrop cmd TEXT... one device command (as in the bridge) # espdrop check is this PC ready (no device needed) +# espdrop gui the desktop app (gui.py, PySide6) # # The device is switched into IP mode (HELLO); it stays there until the next # session (tools/awdl_bridge.py switches it back to L2 itself). @@ -324,6 +325,15 @@ def cmd_check(args, transport=None): return 0 if n == 0 else 1 +def cmd_gui(args, transport=None): + try: + from . import gui + except ImportError as e: + raise ImportError(f"the GUI needs PySide6 ({e}): pip install PySide6 into the venv, " + f"or run tools/opendrop_install.py again") from e + return gui.main(port=args.port) + + def main(argv=None, transport=None): # Device and file names can be anything; a Windows console or a redirect in # its code page must not end the run over one character. @@ -363,6 +373,7 @@ def main(argv=None, transport=None): c.add_argument("--wait", type=float, default=12.0, help="ping6: seconds to show replies") c.set_defaults(verbose=True) sub.add_parser("check", help="is this PC ready: Python, pyserial, OpenDrop, libarchive, TLS") + sub.add_parser("gui", help="the desktop app (PySide6)") args = ap.parse_args(argv) # Our steps (connect, accept, upload rate) at INFO; OpenDrop's own only from # WARNING on, unless -v asks for everything, with milliseconds since start. @@ -373,7 +384,8 @@ def main(argv=None, transport=None): if not verbose: logging.getLogger("opendrop").setLevel(logging.WARNING) handlers = {"find": cmd_find, "send": cmd_send, "receive": cmd_receive, - "status": cmd_status, "cmd": cmd_cmd, "check": cmd_check} + "status": cmd_status, "cmd": cmd_cmd, "check": cmd_check, + "gui": cmd_gui} try: return handlers[args.action](args, transport) except ImportError as e: # OpenDrop or libarchive (env.py says what to do) diff --git a/host/espdrop/env.py b/host/espdrop/env.py index 3868cf4..9da954c 100644 --- a/host/espdrop/env.py +++ b/host/espdrop/env.py @@ -351,6 +351,12 @@ def line(ok, what, detail): line(True, "zeroconf", zeroconf.__version__) except ImportError as e: line(False, "zeroconf", f"{e}; {INSTALL_HINT}") + try: + import PySide6 + line(True, "pyside6", f"{PySide6.__version__} (espdrop gui)") + except ImportError: + line(None, "pyside6", "not installed: the command line works, the desktop app " + "(espdrop gui) needs it") try: path, detail = prepare_libarchive() line(True, "libarchive", detail) diff --git a/host/espdrop/gui.py b/host/espdrop/gui.py new file mode 100644 index 0000000..7cfaf1e --- /dev/null +++ b/host/espdrop/gui.py @@ -0,0 +1,665 @@ +# EspDrop - the desktop app (PySide6) on top of the Session. +# +# espdrop gui [-p PORT] (Windows: .\tools\espdrop-gui.cmd) +# +# One window: the ESP's state, a receive window that closes by itself (like +# Apple's "Everyone for 10 minutes"), the receivers a search found with their +# names, sending by button or by dropping files on a receiver, every transfer +# with its progress, and a dialog that asks before anything is received. It +# keeps running in the tray when the window is closed. +# +# Session events arrive on worker threads; a Qt signal carries them to the GUI +# thread. Session calls that talk to the device run on a short-lived thread so +# a slow device never freezes the window. +# +# SPDX-License-Identifier: GPL-3.0-or-later +# Copyright (C) 2026 EspDrop contributors + +import os +import sys +import threading +import time + +from PySide6.QtCore import QObject, QPoint, QSettings, Qt, QTimer, QUrl, Signal +from PySide6.QtGui import (QAction, QColor, QDesktopServices, QFont, QIcon, QPainter, + QPixmap, QPolygon) +from PySide6.QtWidgets import (QApplication, QComboBox, QDialog, QDialogButtonBox, + QFileDialog, QGroupBox, QHBoxLayout, QHeaderView, QLabel, + QLineEdit, QListWidget, QListWidgetItem, QMainWindow, QMenu, + QMessageBox, QPlainTextEdit, QProgressBar, QPushButton, + QSystemTrayIcon, QTreeWidget, QTreeWidgetItem, QVBoxLayout, + QWidget) + +from .session import Session + +APP = "EspDrop" +WINDOWS = {"10 Minuten": 600, "1 Stunde": 3600, "dauerhaft": None} + +SEND_STATES = {"new": "wartet", "asking": "wartet auf Annehmen", "uploading": "sendet", + "done": "gesendet", "declined": "abgelehnt", "failed": "fehlgeschlagen", + "cancelled": "abgebrochen"} + + +def app_icon(): + """A drawn icon (no image files to ship): a blue tile with a white drop.""" + pm = QPixmap(64, 64) + pm.fill(Qt.transparent) + p = QPainter(pm) + p.setRenderHint(QPainter.Antialiasing) + p.setBrush(QColor("#1f6feb")) + p.setPen(Qt.NoPen) + p.drawRoundedRect(2, 2, 60, 60, 14, 14) + p.setBrush(QColor("white")) + p.drawEllipse(18, 26, 28, 28) + p.drawPolygon(QPolygon([QPoint(32, 8), QPoint(20, 34), QPoint(44, 34)])) + p.end() + return QIcon(pm) + + +def run_bg(fn, *args, on_error=None): + """Run a session call off the GUI thread.""" + def run(): + try: + fn(*args) + except Exception as e: # noqa: BLE001 - shown, not fatal + if on_error: + on_error(str(e)) + threading.Thread(target=run, daemon=True).start() + + +def human(n): + for unit in ("B", "KB", "MB", "GB"): + if n < 1000 or unit == "GB": + return f"{n:.0f} {unit}" if unit == "B" else f"{n:.1f} {unit}" + n /= 1000 + + +class Bridge(QObject): + """Session callbacks (any thread) -> Qt signals (GUI thread).""" + event = Signal(str, object) + ask = Signal(object, object) # IncomingAsk, reply dict + + def __init__(self, ask_timeout): + super().__init__() + self.ask_timeout = ask_timeout + + def on_event(self, kind, **info): + self.event.emit(kind, info) + + def on_ask(self, ask): + reply = {"done": threading.Event(), "ok": False} + self.ask.emit(ask, reply) + reply["done"].wait(self.ask_timeout + 1) + return reply["ok"] + + +class AskDialog(QDialog): + """'Bens iPhone möchte 2 Dateien senden' - Annehmen / Ablehnen, with a countdown.""" + + def __init__(self, parent, ask, reply, timeout): + super().__init__(parent) + self.reply = reply + self.setWindowTitle("AirDrop") + self.setWindowFlag(Qt.WindowStaysOnTopHint) + lay = QVBoxLayout(self) + top = QHBoxLayout() + if ask.icon: + pm = QPixmap() + if pm.loadFromData(ask.icon): + thumb = QLabel() + thumb.setPixmap(pm.scaled(96, 96, Qt.KeepAspectRatio, Qt.SmoothTransformation)) + top.addWidget(thumb) + what = ask.files or ask.urls + noun = ("Link" if len(what) == 1 else "Links") if ask.urls and not ask.files else \ + ("Datei" if len(what) == 1 else "Dateien") + text = QLabel(f"{_esc(ask.sender)} möchte {len(what) or ''} {noun} senden:" + f"
{'
'.join(_esc(w) for w in what[:8])}" + f"{'
…' if len(what) > 8 else ''}") + text.setTextFormat(Qt.RichText) + text.setWordWrap(True) + top.addWidget(text, 1) + lay.addLayout(top) + self.left = QLabel() + lay.addWidget(self.left) + buttons = QDialogButtonBox() + self.yes = buttons.addButton("Annehmen", QDialogButtonBox.AcceptRole) + self.no = buttons.addButton("Ablehnen", QDialogButtonBox.RejectRole) + buttons.accepted.connect(self.accept) + buttons.rejected.connect(self.reject) + lay.addWidget(buttons) + self.end = time.monotonic() + timeout + self.timer = QTimer(self, interval=500, timeout=self._tick) + self.timer.start() + self._tick() + self.finished.connect(self._answer) + + def _tick(self): + left = self.end - time.monotonic() + if left <= 0: + self.reject() + return + self.left.setText(f"Ohne Antwort abgelehnt in {left:.0f} s") + + def _answer(self, result): + self.timer.stop() + self.reply["ok"] = result == QDialog.Accepted + self.reply["done"].set() + + +def _esc(text): + return (str(text).replace("&", "&").replace("<", "<").replace(">", ">")) + + +class ReceiverList(QListWidget): + """The receivers; files dropped on one are sent to it.""" + dropped = Signal(str, list) # receiver id, paths + + def __init__(self): + super().__init__() + self.setAcceptDrops(True) + self.setDropIndicatorShown(True) + + def dragEnterEvent(self, e): + if e.mimeData().hasUrls(): + e.acceptProposedAction() + + def dragMoveEvent(self, e): + item = self.itemAt(e.position().toPoint()) + if e.mimeData().hasUrls() and item is not None and item.data(Qt.UserRole): + self.setCurrentItem(item) + e.acceptProposedAction() + else: + e.ignore() + + def dropEvent(self, e): + item = self.itemAt(e.position().toPoint()) + paths = [u.toLocalFile() for u in e.mimeData().urls() if u.isLocalFile()] + if item is not None and paths: + self.dropped.emit(item.data(Qt.UserRole), paths) + e.acceptProposedAction() + + +class MainWindow(QMainWindow): + def __init__(self, port=None, settings=None, session_factory=None, ask_timeout=60.0, + tray=True): + super().__init__() + self.settings = settings or QSettings(APP, APP) + self.setWindowTitle(APP) + self.setWindowIcon(app_icon()) + self.resize(720, 640) + self.port = port or self.settings.value("port", "") or None + self.bridge = Bridge(ask_timeout) + self.bridge.event.connect(self._on_event) + self.bridge.ask.connect(self._on_ask) + # session_factory(window) -> Session, for tests; it must use window.bridge + self._make_session = ((lambda: session_factory(self)) if session_factory + else self._default_session) + self._rows = {} # ("send", id) / ("recv", n) -> QTreeWidgetItem + self._recv_n = 0 + self._recv_row = None # the row of the transfer being received + self._quitting = False + self._build() + self.tray = self._make_tray() if tray and QSystemTrayIcon.isSystemTrayAvailable() else None + self.session = self._make_session() + self.session.start() + self._update_dir() + self.clock = QTimer(self, interval=1000, timeout=self._tick) + self.clock.start() + + # ---- setup --------------------------------------------------------------- + + def _default_session(self): + d = self.settings.value("download_dir", "") or default_download_dir() + return Session(port=self.port, name=self.name_edit.text() or APP, receive=False, + download_dir=d, on_event=self.bridge.on_event, + on_ask=self.bridge.on_ask, ask_timeout=self.bridge.ask_timeout) + + def _build(self): + root = QWidget() + lay = QVBoxLayout(root) + + # device and name + head = QHBoxLayout() + self.device_label = QLabel("Suche ESP …") + self.power_label = QLabel("") + self.power_label.setStyleSheet("color: gray") + head.addWidget(self.device_label, 1) + head.addWidget(self.power_label) + lay.addLayout(head) + name_row = QHBoxLayout() + name_row.addWidget(QLabel("Name für andere Geräte:")) + self.name_edit = QLineEdit(self.settings.value("name", "") or APP) + self.name_edit.editingFinished.connect(self._name_changed) + name_row.addWidget(self.name_edit, 1) + lay.addLayout(name_row) + + # receiving + rbox = QGroupBox("Empfangen") + rl = QVBoxLayout(rbox) + r1 = QHBoxLayout() + self.receive_btn = QPushButton("Empfangsbereit machen") + self.receive_btn.setCheckable(True) + self.receive_btn.toggled.connect(self._receive_toggled) + self.window_combo = QComboBox() + self.window_combo.addItems(list(WINDOWS)) + self.window_combo.setCurrentText(self.settings.value("window", "10 Minuten")) + self.receive_left = QLabel("aus") + r1.addWidget(self.receive_btn) + r1.addWidget(self.window_combo) + r1.addWidget(self.receive_left, 1) + rl.addLayout(r1) + r2 = QHBoxLayout() + self.dir_label = QLabel() + self.dir_label.setTextInteractionFlags(Qt.TextSelectableByMouse) + pick = QPushButton("Ordner …") + pick.clicked.connect(self._pick_dir) + show = QPushButton("Öffnen") + show.clicked.connect(lambda: QDesktopServices.openUrl( + QUrl.fromLocalFile(self.session.download_dir or os.getcwd()))) + r2.addWidget(QLabel("Ziel:")) + r2.addWidget(self.dir_label, 1) + r2.addWidget(pick) + r2.addWidget(show) + rl.addLayout(r2) + lay.addWidget(rbox) + + # sending + sbox = QGroupBox("Senden") + sl = QVBoxLayout(sbox) + s1 = QHBoxLayout() + self.search_btn = QPushButton("Geräte suchen") + self.search_btn.clicked.connect(self._search) + self.search_label = QLabel("Am iPhone/iPad: AirDrop „Für alle“, entsperrt, " + "Teilen-Menü offen.") + self.search_label.setWordWrap(True) + s1.addWidget(self.search_btn) + s1.addWidget(self.search_label, 1) + sl.addLayout(s1) + self.receivers_list = ReceiverList() + self.receivers_list.dropped.connect(self._send_to) + self.receivers_list.itemDoubleClicked.connect(lambda item: self._send_dialog()) + sl.addWidget(self.receivers_list, 1) + s2 = QHBoxLayout() + self.send_btn = QPushButton("Dateien senden …") + self.send_btn.clicked.connect(self._send_dialog) + s2.addWidget(QLabel("Dateien auf ein Gerät ziehen oder:")) + s2.addStretch(1) + s2.addWidget(self.send_btn) + sl.addLayout(s2) + lay.addWidget(sbox, 2) + + # transfers + tbox = QGroupBox("Übertragungen") + tl = QVBoxLayout(tbox) + self.transfers = QTreeWidget() + self.transfers.setHeaderLabels(["", "Gerät", "Dateien", "Status"]) + self.transfers.setRootIsDecorated(False) + self.transfers.header().setSectionResizeMode(2, QHeaderView.Stretch) + self.transfers.setColumnWidth(0, 28) + self.transfers.setColumnWidth(1, 160) + self.transfers.itemDoubleClicked.connect(self._open_transfer) + self.transfers.setContextMenuPolicy(Qt.CustomContextMenu) + self.transfers.customContextMenuRequested.connect(self._transfer_menu) + tl.addWidget(self.transfers) + lay.addWidget(tbox, 2) + + self.log = QPlainTextEdit() + self.log.setReadOnly(True) + self.log.setMaximumBlockCount(500) + self.log.setFont(QFont("monospace", 8)) + self.log.setVisible(False) + lay.addWidget(self.log, 1) + + menu = self.menuBar().addMenu("&EspDrop") + a = QAction("Protokoll anzeigen", self, checkable=True) + a.toggled.connect(self.log.setVisible) + menu.addAction(a) + a = QAction("Port …", self) + a.triggered.connect(self._pick_port) + menu.addAction(a) + menu.addSeparator() + a = QAction("Beenden", self) + a.triggered.connect(self.quit) + menu.addAction(a) + self.setCentralWidget(root) + self._update_dir() + self._set_device_ui(False) + + def _make_tray(self): + tray = QSystemTrayIcon(app_icon(), self) + menu = QMenu() + menu.addAction("Anzeigen", self._show) + self.tray_receive = menu.addAction("Empfangsbereit machen", + lambda: self.receive_btn.setChecked( + not self.receive_btn.isChecked())) + menu.addSeparator() + menu.addAction("Beenden", self.quit) + tray.setContextMenu(menu) + tray.activated.connect(lambda reason: self._show() + if reason == QSystemTrayIcon.Trigger else None) + tray.setToolTip(APP) + tray.show() + return tray + + # ---- window life ----------------------------------------------------------- + + def _show(self): + self.showNormal() + self.raise_() + self.activateWindow() + + def closeEvent(self, e): + if self.tray and not self._quitting: + e.ignore() + self.hide() + self.tray.showMessage(APP, "Läuft im Hintergrund weiter (Symbol in der " + "Taskleiste).", QSystemTrayIcon.Information, 3000) + return + self._shutdown() + e.accept() + + def quit(self): + self._quitting = True + self._shutdown() + QApplication.quit() + + def _shutdown(self): + self.clock.stop() + self.session.stop() + if self.tray: + self.tray.hide() + + # ---- session events ---------------------------------------------------------- + + def _on_event(self, kind, info): + if kind == "device": + state = info.get("state") + if state == "connected": + mac = (info.get("info") or {}).get("mac", "") + self.device_label.setText(f"ESP verbunden ({mac})") + self._set_device_ui(True) # the session restarts receiving itself + elif state == "connecting": + pass + elif state == "lost": + err = info.get("error") or "" + self.device_label.setText(f"Kein ESP: {err}" if err else "Kein ESP") + self._set_device_ui(False) + elif state == "stopped": + self.device_label.setText("beendet") + elif kind == "power": + self.power_label.setText(power_text(info.get("text", ""))) + elif kind == "browse": + self.search_btn.setEnabled(not info["on"] and self.session.state == "connected") + self.search_btn.setText("Suche läuft …" if info["on"] else "Geräte suchen") + elif kind == "receiver": + self._receiver_item(info["receiver"]) + elif kind == "receiving": + self._receiving_ui(info["on"]) + elif kind == "send": + self._send_row(info) + elif kind == "receive": + self._receive_row(info) + elif kind == "log": + self._log(info.get("text", "")) + + def _log(self, text): + self.log.appendPlainText(f"{time.strftime('%H:%M:%S')} {text}") + + def _set_device_ui(self, connected): + self.search_btn.setEnabled(connected and not self.session_browsing()) + self.send_btn.setEnabled(connected) + if not connected: + self.power_label.setText("") + + def session_browsing(self): + s = getattr(self, "session", None) + return bool(s and s.browsing) + + def _tick(self): + until = self.session.receive_until + if self.session.receiving: + if until: + left = max(0, until - time.monotonic()) + self.receive_left.setText(f"empfangsbereit, noch {int(left // 60)}:" + f"{int(left % 60):02d}") + else: + self.receive_left.setText("empfangsbereit (dauerhaft)") + + # ---- receiving ------------------------------------------------------------- + + def _receive_toggled(self, on): + secs = WINDOWS.get(self.window_combo.currentText()) + self.settings.setValue("window", self.window_combo.currentText()) + self._receiving_ui(on) + run_bg(self.session.set_receiving, on, secs, on_error=self._log) + + def _receiving_ui(self, on): + if self.receive_btn.isChecked() != on: + self.receive_btn.blockSignals(True) + self.receive_btn.setChecked(on) + self.receive_btn.blockSignals(False) + self.receive_btn.setText("Empfangen beenden" if on else "Empfangsbereit machen") + self.window_combo.setEnabled(not on) + if not on: + self.receive_left.setText("aus") + if self.tray: + self.tray_receive.setText("Empfangen beenden" if on else "Empfangsbereit machen") + + def _on_ask(self, ask, reply): + self._show() + if self.tray: + self.tray.showMessage(APP, f"{ask.sender} möchte etwas senden", + QSystemTrayIcon.Information, 5000) + dlg = AskDialog(self, ask, reply, self.bridge.ask_timeout) + dlg.show() + dlg.raise_() + self._last_ask_dialog = dlg # for tests + + def _receive_row(self, info): + phase = info["phase"] + ask = info.get("ask") + if phase == "asked": + if not info.get("accepted"): + self._log(f"abgelehnt: {ask.summary() if ask else '?'}") + return + self._recv_n += 1 + item = QTreeWidgetItem(["↓", ask.sender if ask else "?", + ", ".join(ask.files or ask.urls) if ask else "", ""]) + self.transfers.insertTopLevelItem(0, item) + bar = QProgressBar() + bar.setRange(0, 0) # busy until sizes are known + self.transfers.setItemWidget(item, 3, bar) + self._rows[("recv", self._recv_n)] = item + self._recv_row = item + elif phase == "receiving" and self._recv_row is not None: + bar = self.transfers.itemWidget(self._recv_row, 3) + total = info.get("total") + if isinstance(bar, QProgressBar): + if total: + bar.setRange(0, 1000) + bar.setValue(int(1000 * min(1.0, info["received"] / total))) + bar.setFormat(human(info["received"])) + elif phase == "received": + item = self._recv_row + self._recv_row = None + if item is None: + return + self.transfers.removeItemWidget(item, 3) + files = info.get("files") or [] + if info.get("ok"): + item.setText(3, f"empfangen ({human(info.get('size', 0))})") + item.setData(0, Qt.UserRole, files) + item.setText(2, ", ".join(os.path.basename(f) for f in files)) + if self.tray: + self.tray.showMessage(APP, f"Empfangen: {item.text(2)}", + QSystemTrayIcon.Information, 5000) + else: + item.setText(3, "unvollständig") + item.setData(0, Qt.UserRole, files + ([info["raw"]] if info.get("raw") else [])) + + def _pick_dir(self): + d = QFileDialog.getExistingDirectory(self, "Ordner für empfangene Dateien", + self.session.download_dir or os.getcwd()) + if d: + self.session.set_download_dir(d) + self.settings.setValue("download_dir", d) + self._update_dir() + + def _update_dir(self): + s = getattr(self, "session", None) + d = (s and s.download_dir) or self.settings.value("download_dir", "") \ + or default_download_dir() + self.dir_label.setText(d) + + def _name_changed(self): + name = self.name_edit.text().strip() or APP + if name == self.session.name: + return + self.settings.setValue("name", name) + run_bg(self.session.set_name, name, on_error=self._log) + + # ---- sending --------------------------------------------------------------- + + def _search(self): + self.search_btn.setEnabled(False) + run_bg(self.session.browse, 60, on_error=self._log) + + def _receiver_item(self, r): + item = None + for i in range(self.receivers_list.count()): + if self.receivers_list.item(i).data(Qt.UserRole) == r.id: + item = self.receivers_list.item(i) + if item is None: + item = QListWidgetItem() + item.setData(Qt.UserRole, r.id) + self.receivers_list.addItem(item) + if self.receivers_list.currentItem() is None: + self.receivers_list.setCurrentItem(item) + model = f" ({r.model})" if r.model else "" + item.setText(f"{r.label}{model}" if r.present else f"{r.label}{model} – nicht mehr da") + item.setForeground(QColor("black") if r.present else QColor("gray")) + + def _selected_receiver(self): + item = self.receivers_list.currentItem() + return item.data(Qt.UserRole) if item else None + + def _send_dialog(self): + rid = self._selected_receiver() + if not rid: + QMessageBox.information(self, APP, "Zuerst „Geräte suchen“ und ein Gerät wählen.") + return + files, _ = QFileDialog.getOpenFileNames(self, "Dateien senden") + if files: + self._send_to(rid, files) + + def _send_to(self, rid, files): + files = [f for f in files if os.path.isfile(f)] + if not files: + QMessageBox.information(self, APP, "Ordner lassen sich noch nicht senden, nur Dateien.") + return + try: + t = self.session.send(rid, files) + except Exception as e: # noqa: BLE001 + QMessageBox.warning(self, APP, f"Senden geht nicht: {e}") + return + self._send_row({"transfer": t, "state": t.state, "progress": 0.0, "error": None}) + + def _send_row(self, info): + t = info["transfer"] + key = ("send", t.id) + item = self._rows.get(key) + if item is None: + item = QTreeWidgetItem(["↑", t.receiver.label, + ", ".join(os.path.basename(f) for f in t.files), ""]) + item.setData(0, Qt.UserRole + 1, t.id) + self.transfers.insertTopLevelItem(0, item) + self._rows[key] = item + state = info["state"] + if state == "uploading": + bar = self.transfers.itemWidget(item, 3) + if not isinstance(bar, QProgressBar): + bar = QProgressBar() + bar.setRange(0, 1000) + self.transfers.setItemWidget(item, 3, bar) + bar.setValue(int(1000 * info["progress"])) + else: + if self.transfers.itemWidget(item, 3) is not None: + self.transfers.removeItemWidget(item, 3) + text = SEND_STATES.get(state, state) + if state == "failed" and info.get("error"): + text += f": {info['error']}" + item.setText(3, text) + + def _transfer_menu(self, pos): + item = self.transfers.itemAt(pos) + if item is None: + return + tid = item.data(0, Qt.UserRole + 1) + menu = QMenu(self) + if tid is not None: + menu.addAction("Abbrechen", lambda: run_bg(self.session.cancel, tid)) + if item.data(0, Qt.UserRole): + menu.addAction("Öffnen", lambda: self._open_transfer(item)) + menu.addAction("Aus der Liste entfernen", + lambda: self.transfers.takeTopLevelItem( + self.transfers.indexOfTopLevelItem(item))) + menu.exec(self.transfers.viewport().mapToGlobal(pos)) + + def _open_transfer(self, item, column=0): + files = item.data(0, Qt.UserRole) or [] + target = files[0] if len(files) == 1 else (os.path.dirname(files[0]) if files else None) + if target: + QDesktopServices.openUrl(QUrl.fromLocalFile(target)) + + # ---- settings -------------------------------------------------------------- + + def _pick_port(self): + from PySide6.QtWidgets import QInputDialog + from .transport import candidates + ports = ["automatisch"] + candidates() + current = self.port or "automatisch" + port, ok = QInputDialog.getItem(self, APP, "Serieller Port des ESP:", ports, + ports.index(current) if current in ports else 0, True) + if not ok: + return + self.port = None if port == "automatisch" else port + self.settings.setValue("port", self.port or "") + receiving = self.session.receiving + secs = WINDOWS.get(self.window_combo.currentText()) + old, new = self.session, self._make_session() + self.session = new + + def restart(): + old.stop() # frees the port for the new one + new.start() + if receiving: + new.set_receiving(True, secs) + run_bg(restart, on_error=self._log) + + +def power_text(reply): + """The device's `power` line, short: 'Funk aus' / 'Funk an (…)'.""" + words = reply.split() + if len(words) < 3 or words[0] != "power": + return "" + if words[1] == "on": + return "Funk immer an" + why = {"(hold)": "für die App", "(wake)": "zum Senden", "(busy)": "Übertragung"}.get( + words[3] if len(words) > 3 else "", "") + return "Funk aus (spart Strom)" if words[2] == "asleep" else f"Funk an ({why})" if why \ + else "Funk an" + + +def default_download_dir(): + home = os.path.expanduser("~") + downloads = os.path.join(home, "Downloads") + return os.path.join(downloads if os.path.isdir(downloads) else home, "EspDrop") + + +def main(port=None): + app = QApplication.instance() or QApplication(sys.argv) + app.setApplicationName(APP) + app.setQuitOnLastWindowClosed(False) + w = MainWindow(port=port) + w.show() + return app.exec() diff --git a/host/espdrop/session.py b/host/espdrop/session.py index 47d1ec1..bd4799c 100644 --- a/host/espdrop/session.py +++ b/host/espdrop/session.py @@ -161,6 +161,13 @@ def set_name(self, name): self._stop_receiver() self._start_receiver() + def set_download_dir(self, path): + """Where received files go (OpenDrop's server writes into the current + directory, so this changes it for the whole process).""" + os.makedirs(path, exist_ok=True) + os.chdir(path) + self.download_dir = path + def receivers(self): with self._lock: return list(self._receivers.values()) diff --git a/host/tests/test_gui.py b/host/tests/test_gui.py new file mode 100644 index 0000000..9d46297 --- /dev/null +++ b/host/tests/test_gui.py @@ -0,0 +1,173 @@ +# EspDrop tests - the PySide6 app, offscreen, on simulated devices: search and +# send, receive through the accept dialog, decline. Skipped without PySide6. +# +# ~/opendrop-venv/bin/python -m unittest discover -s host/tests +# +# SPDX-License-Identifier: GPL-3.0-or-later +# Copyright (C) 2026 EspDrop contributors + +import ipaddress +import os +import sys +import tempfile +import threading +import time +import unittest + +os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") +HERE = os.path.dirname(os.path.abspath(__file__)) +sys.path.insert(0, os.path.join(HERE, "..")) +sys.path.insert(0, HERE) + +try: + from PySide6.QtCore import QSettings + from PySide6.QtWidgets import QApplication + HAVE_QT = True +except ImportError: # pragma: no cover - no PySide6 here + HAVE_QT = False + +from fake_device import DuplexBridgeDevice # noqa: E402 + + +def incoming(fake, path, sender="Sender"): + """A real OpenDrop sender dialling in through the fake device.""" + from espdrop.receive import eui64_linklocal + from opendrop import client as odc + from opendrop.client import AirDropClient + from opendrop.config import AirDropConfig + sock = fake.open_incoming() + client = AirDropClient(AirDropConfig(computer_name=sender), + (str(ipaddress.IPv6Address(eui64_linklocal(fake.mac))), 8771)) + base = odc.HTTPSConnectionAWDL.__init__ + + def init(self, *a, **kw): + base(self, *a, **kw) + self._create_connection = lambda *a, **kw: sock + odc.HTTPSConnectionAWDL.__init__ = init + try: + return client.send_ask([path]) and client.send_upload([path]) + finally: + odc.HTTPSConnectionAWDL.__init__ = base + if client.http_conn is not None: + client.http_conn.close() + sock.close() + + +@unittest.skipUnless(HAVE_QT, "PySide6 not installed") +class GuiTest(unittest.TestCase): + @classmethod + def setUpClass(cls): + cls.app = QApplication.instance() or QApplication([]) + + def setUp(self): + from espdrop import gui + self.gui = gui + self.cwd = os.getcwd() + self.tmp = tempfile.TemporaryDirectory() + self.dl = os.path.join(self.tmp.name, "empfangen") + self.settings = QSettings(os.path.join(self.tmp.name, "espdrop.ini"), QSettings.IniFormat) + self.w = None + + def tearDown(self): + if self.w: + self.w.quit() + os.chdir(self.cwd) + self.tmp.cleanup() + + def pump(self, cond, secs=15.0): + end = time.monotonic() + secs + while time.monotonic() < end: + self.app.processEvents() + if cond(): + return True + time.sleep(0.02) + return cond() + + def window(self, fake): + from espdrop.session import Session + + def factory(w): + return Session(transport_factory=lambda: fake, name="MeinPC", receive=False, + download_dir=self.dl, on_event=w.bridge.on_event, + on_ask=w.bridge.on_ask, ask_timeout=w.bridge.ask_timeout) + self.w = self.gui.MainWindow(settings=self.settings, session_factory=factory, + ask_timeout=20.0, tray=False) + self.w.show() + self.assertTrue(self.pump(lambda: self.w.device_label.text().startswith("ESP verbunden")), + self.w.device_label.text()) + return self.w + + def file(self, name, data): + path = os.path.join(self.tmp.name, name) + with open(path, "wb") as f: + f.write(data) + return path + + def test_search_and_send(self): + from loopback_receiver import LoopbackReceiver + pad = os.path.join(self.tmp.name, "pad") + os.makedirs(pad) + rx = LoopbackReceiver(name="TestPad") + port = rx.start() + try: + fake = DuplexBridgeDevice(target=("127.0.0.1", port), mdns_port=port, + answers={1: ["small"]}) + w = self.window(fake) + src = self.file("foto.jpg", b"\xff\xd8 gui send " * 500) + w.search_btn.click() + self.assertTrue(self.pump(lambda: w.receivers_list.count() == 1 + and "TestPad" in w.receivers_list.item(0).text())) + os.chdir(pad) # the loopback receiver writes into cwd + w._send_to(w._selected_receiver(), [src]) + row = lambda: w.transfers.topLevelItem(0) # noqa: E731 + self.assertTrue(self.pump(lambda: row() and row().text(3) in + ("gesendet", "fehlgeschlagen", "abgelehnt"))) + self.assertEqual(row().text(3), "gesendet") + self.assertEqual((row().text(1), row().text(2)), ("TestPad", "foto.jpg")) + finally: + rx.stop() + self.assertIn("foto.jpg", os.listdir(pad)) + + def answer_incoming(self, accept): + fake = DuplexBridgeDevice() + w = self.window(fake) + w.receive_btn.click() # empfangsbereit (10 Minuten) + self.assertTrue(self.pump(lambda: fake.listening == 8771)) + self.assertTrue(w.session.receiving) + self.assertIsNotNone(w.session.receive_until) + src = self.file("bild.jpg", b"incoming via the dialog " * 300) + result = {} + th = threading.Thread(target=lambda: result.setdefault("ok", incoming(fake, src))) + th.start() + self.assertTrue(self.pump(lambda: getattr(w, "_last_ask_dialog", None) is not None)) + dlg = w._last_ask_dialog + self.assertIn("Sender", dlg.findChildren(type(w.device_label))[0].text() + + "".join(lbl.text() for lbl in dlg.findChildren(type(w.device_label)))) + (dlg.yes if accept else dlg.no).click() + self.assertTrue(self.pump(lambda: not th.is_alive(), 20)) + return w, result.get("ok") + + def test_receive_through_the_dialog(self): + w, ok = self.answer_incoming(True) + self.assertTrue(ok) + row = lambda: w.transfers.topLevelItem(0) # noqa: E731 + self.assertTrue(self.pump(lambda: row() and row().text(3).startswith("empfangen"))) + self.assertEqual(row().text(2), "bild.jpg") + self.assertIn("bild.jpg", os.listdir(self.dl)) + + def test_declined_in_the_dialog(self): + w, ok = self.answer_incoming(False) + self.assertFalse(ok) + self.assertEqual(os.listdir(self.dl), []) + self.assertEqual(w.transfers.topLevelItemCount(), 0) + + def test_power_text(self): + t = self.gui.power_text + self.assertEqual(t("power auto asleep (none) hold=0s wake=0s"), "Funk aus (spart Strom)") + self.assertEqual(t("power auto awake (hold) hold=80s"), "Funk an (für die App)") + self.assertEqual(t("power on awake (on)"), "Funk immer an") + self.assertEqual(t("ok power"), "") + + +if __name__ == "__main__": + unittest.main() diff --git a/tools/espdrop-gui.cmd b/tools/espdrop-gui.cmd new file mode 100644 index 0000000..4bc4e75 --- /dev/null +++ b/tools/espdrop-gui.cmd @@ -0,0 +1,15 @@ +@echo off +rem EspDrop - start the desktop app on Windows, without a console window. +rem +rem .\tools\espdrop-gui.cmd (or double-click it in Explorer) +rem +rem Uses the venv from tools\opendrop_install.py: OPENDROP_VENV if set, else +rem %USERPROFILE%\opendrop-venv. +rem +rem SPDX-License-Identifier: GPL-3.0-or-later +rem Copyright (C) 2026 EspDrop contributors +setlocal +set "VENV=%OPENDROP_VENV%" +if not defined VENV set "VENV=%USERPROFILE%\opendrop-venv" +if not exist "%VENV%\Scripts\pythonw.exe" echo error: no OpenDrop venv in "%VENV%" - run: py "%~dp0opendrop_install.py" 1>&2 & pause & exit /b 1 +start "" "%VENV%\Scripts\pythonw.exe" "%~dp0espdrop.py" gui %* diff --git a/tools/opendrop_install.py b/tools/opendrop_install.py index 804694d..575e493 100644 --- a/tools/opendrop_install.py +++ b/tools/opendrop_install.py @@ -6,8 +6,8 @@ # jedbillyb/airdrop-mt7921 (GPLv3) at the pinned revision, in their fixed order. # The patches and every resulting file are checked against pinned digests (those # of the install the bash script makes), so Windows and Linux run byte-identical -# OpenDrop code. On top come pyserial (COM ports) and cryptography (the TLS -# certificate, in place of the openssl command). +# OpenDrop code. On top come pyserial (COM ports), cryptography (the TLS +# certificate, in place of the openssl command) and PySide6 (the desktop app). # # py tools\opendrop_install.py Windows (Python 3.9+ from python.org) # python3 tools/opendrop_install.py works on Linux too @@ -79,7 +79,7 @@ "opendrop/server.py": "be731940c314f2b2409c263d6585c407cb935c93c00de805affd602693d76503", "opendrop/util.py": "390ee35c01c3d332e57be49266610a44bd658dbe1fe16084a5e5bfeccb30fe2c", } -EXTRAS = ["pyserial", "cryptography"] +EXTRAS = ["pyserial", "cryptography", "PySide6"] # Markers in the venv, shared with the bash script: WIP while one of us builds # it, OK (holding the series signature) once every patch landed and was checked. STAMP_WIP = ".espdrop-installing" @@ -379,10 +379,10 @@ def main(): if up_to_date(venv): print(f"\nalready installed and up to date: {venv}") - extras = [m for m in ("serial", "cryptography") + extras = [m for m in ("serial", "cryptography", "PySide6") if subprocess.run([py, "-c", f"import {m}"], capture_output=True).returncode] - if extras: # a venv the bash script made has neither - step("adding pyserial and cryptography") + if extras: # a venv the bash script made, or one from before the GUI + step("adding pyserial, cryptography and PySide6") run([py, "-m", "pip", "install", "-q"] + EXTRAS, "pip install") else: step(f"fetching the patch series (rev {REV})") @@ -434,6 +434,7 @@ def main(): step("espdrop check") rc = subprocess.run([py, os.path.join(HERE, "espdrop.py"), "check"]).returncode launcher = r".\tools\espdrop.cmd" if os.name == "nt" else f"{py} tools/espdrop.py" + gui = r".\tools\espdrop-gui.cmd" if os.name == "nt" else f"{launcher} gui" if rc: print(f"\ninstalled in {venv}, but the check above found problems.\n" f"libarchive: see host/README.md ('Windows'), then run this again with " @@ -441,7 +442,8 @@ def main(): return 1 print(f"\ndone: {venv}\nnext: plug in the ESP (bridge firmware) and run, from the checkout,\n" f" {launcher} status\n {launcher} find\n {launcher} send \n" - f" {launcher} receive") + f" {launcher} receive\n" + f"the desktop app: {gui}") return 0 From d1b039d37e4b25d9f21908cfe5ab9261c7256a36 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 24 Oct 2026 10:12:55 +0000 Subject: [PATCH 80/99] feat(power): listen for AirDrop over BLE and wake AWDL on demand MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The receive side can now keep the Wi-Fi radio asleep and wake it only when a phone nearby actually wants to share, so the chip stays cool without AWDL running all the time. - An iPhone/iPad with its share sheet open sends a Continuity "AirDrop" advert (type 0x05) to find receivers. The firmware runs a passive BLE scan (NimBLE observer role) that watches for it; nothing about the sender is kept, only "an AirDrop advert is in range". Each advert arms a ~25 s wake, re-armed while the share sheet stays open; the busy grace then covers the transfer. - awdl_power gains a listen flag and an advert wake, gated by host presence, with host tests. main/power.c starts/stops the scan to match, and `power listen on|off` plus a listen=/adverts= status drive it. A HELLO cap bit (0x04) tells the host the ESP can wake itself. - Host: when the ESP reports the cap, receiving uses `power listen on` (radio sleeps between adverts) instead of a continuous hold, in the session, the CLI and the GUI (which shows "Funk schläft (empfangsbereit)"); without it, it falls back to the hold. The mDNS responder announces a little faster (2.5 s) so discovery after a wake lands inside the phone's share-sheet window. Builds for the C5 (21% free) and C3 (37% free) with the observer role. 49 host tests, C host tests pass. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01XzgaN4Bv1m8Ka9iqo7QVUN --- docs/SPIKES.md | 49 ++++++++--- firmware/README.md | 12 ++- firmware/spikes/components/awdl/awdl_power.c | 18 +++- .../components/awdl/include/awdl_power.h | 11 +++ .../awdl/test/host/test_awdl_power.c | 26 ++++++ firmware/spikes/main/ble_wake.c | 86 +++++++++++++++++++ firmware/spikes/main/ble_wake.h | 16 ++++ firmware/spikes/main/bridge.c | 18 +++- firmware/spikes/main/power.c | 47 +++++++++- firmware/spikes/main/power.h | 2 + firmware/spikes/sdkconfig.bridge.defaults | 4 +- host/espdrop/cli.py | 20 ++++- host/espdrop/gui.py | 10 ++- host/espdrop/link.py | 5 +- host/espdrop/receive.py | 5 +- host/espdrop/session.py | 50 +++++++---- host/tests/fake_device.py | 4 + host/tests/test_gui.py | 4 + host/tests/test_session.py | 29 +++++++ 19 files changed, 362 insertions(+), 54 deletions(-) diff --git a/docs/SPIKES.md b/docs/SPIKES.md index a62de15..82e3402 100644 --- a/docs/SPIKES.md +++ b/docs/SPIKES.md @@ -509,24 +509,39 @@ unberührt. Empfangsfenster (`power hold `, die App erneuert das alle 30 s) und solange eine TCP-Verbindung offen ist (plus 20 s). Meldet sich die Host-App 90 s nicht mehr (beendet, abgesteckt), verfallen ihre Anforderungen. +- **Empfangen im Schlaf (`power listen on`):** Der ESP lauscht dann per + Bluetooth-Scan auf AirDrop-Signale. Öffnet ein iPhone/iPad in der Nähe sein + Teilen-Menü, sendet es dasselbe Continuity-„AirDrop“-Advert (Typ 0x05), mit + dem es Empfänger sucht. Sieht der ESP es, weckt er das WLAN für ~25 s (jedes + weitere Advert verlängert, solange das Teilen-Menü offen ist), meldet sich + per mDNS und nimmt die Übertragung an; danach schläft das WLAN wieder. So + bleibt der Chip kühl und ist trotzdem empfangsbereit, ohne das WLAN + dauerhaft laufen zu lassen. Es wird nur „AirDrop in Reichweite“ ausgewertet, + nichts über das sendende Gerät gespeichert. `espdrop receive` und das GUI + schalten das automatisch ein (Firmware mit der Observer-Rolle, HELLO-Cap + `0x04`); fehlt sie, fällt die App auf ein durchgehend gehaltenes + Empfangsfenster zurück. - **Befehl `power`:** zeigt `power auto|on awake|asleep (grund) hold=… wake=… - tcp=… sleeps=… asleep=…%`; `power hold <1..3600>`, `power release`, - `power on`, `power auto`. Für Diagnose im IP-Betrieb (z. B. `ping6`) vorher - `power hold 120`. + listen=on|off tcp=… sleeps=… wakes=… adverts=… asleep=…%`; `power hold + <1..3600>`, `power release`, `power listen on|off`, `power on`, `power auto`. + Für Diagnose im IP-Betrieb (z. B. `ping6`) vorher `power hold 120`. -Die Firmware lauscht dabei nicht auf Bluetooth-Signale anderer Geräte. -Empfangen ist deshalb nur möglich, solange die App ein Empfangsfenster offen -hat, ähnlich Apples „Für alle für 10 Minuten“. `espdrop receive` hält das -Fenster offen, solange es läuft. +Das BLE-Advert zum *Senden* (`wake`) und der BLE-Scan zum *Empfangen* +(`listen`) laufen unabhängig; beide teilen sich mit dem WLAN den Funk über die +Koexistenz. Test (Firmware neu flashen, danach den ESP einmal ab- und anstecken): ```sh ~/opendrop-venv/bin/python tools/espdrop.py cmd power # asleep (none), kurz nach dem Start ~/opendrop-venv/bin/python tools/espdrop.py find -t 20 # wacht auf, findet das iPad ~/opendrop-venv/bin/python tools/espdrop.py cmd power # nach dem Ende wieder asleep +~/opendrop-venv/bin/python tools/espdrop.py receive -n MeinPC # power zeigt listen=on +# am iPad Teilen-Menü öffnen: der ESP wacht von selbst auf (adverts= steigt), +# MeinPC erscheint, Datei annehmen; danach wieder asleep. ``` Der ESP sollte im Leerlauf spürbar kühler bleiben. `asleep=` zeigt, welchen -Anteil der Zeit seit dem Start das WLAN aus war. +Anteil der Zeit seit dem Start das WLAN aus war, `adverts=` wie oft ein +AirDrop-Signal in der Nähe den ESP geweckt hat. ## Desktop-App (PySide6) @@ -545,7 +560,10 @@ Oberfläche. Neu dabei: Dateien auf ein Gerät ziehen; `espdrop send -r NAME` (Name, Teil davon oder Kennung), bei mehreren ohne `-r` eine Liste zum Auswählen. - **Empfangsfenster** statt dauernd empfangsbereit (10 Minuten, 1 Stunde oder - dauerhaft), passend zum Schlafmodus der Firmware. + dauerhaft), passend zum Schlafmodus der Firmware. Kann der ESP sich selbst + wecken (`power listen`, siehe oben), schläft das WLAN auch während des + Fensters und wacht erst auf, wenn ein Gerät in der Nähe teilen will; rechts + oben steht dann „Funk schläft (empfangsbereit)“. Start: Linux `~/opendrop-venv/bin/python tools/espdrop.py gui`, Windows `.\tools\espdrop-gui.cmd` (ohne Konsolenfenster). PySide6 installiert @@ -557,9 +575,11 @@ Test auf der Hardware (Firmware mit Schlafmodus geflasht): 1. App starten: oben „ESP verbunden“, rechts „Funk aus (spart Strom)“. 2. „Geräte suchen“, am iPad Teilen-Menü öffnen: das iPad erscheint mit Namen. Eine Datei darauf ziehen, am iPad annehmen: „gesendet“. -3. „Empfangsbereit machen“, am iPad etwas teilen und „EspDrop“ wählen: der - Dialog fragt, „Annehmen“, die Datei liegt in `Downloads/EspDrop`. - Einmal „Ablehnen“: das iPad meldet die Ablehnung. +3. „Empfangsbereit machen“: rechts oben „Funk schläft (empfangsbereit)“ (der + ESP lauscht per Bluetooth). Am iPad etwas teilen: der ESP wacht von selbst + auf, „MeinPC“ erscheint am iPad, wählen; der Dialog fragt, „Annehmen“, die + Datei liegt in `Downloads/EspDrop`. Einmal „Ablehnen“: das iPad meldet die + Ablehnung. 4. Fenster schließen (läuft im Tray weiter), ESP ab- und anstecken: die App verbindet sich wieder. 5. Nach dem Ende des Empfangsfensters und ohne Suche: „Funk aus“, der ESP @@ -567,8 +587,9 @@ Test auf der Hardware (Firmware mit Schlafmodus geflasht): Tests ohne Hardware: `test_session.py` (Suche mit Namen, Senden mit Fortschritt und Abbrechen, Empfangen mit Annehmen/Ablehnen/Zeitüberschreitung, -Empfangsfenster, Neuverbinden) und `test_gui.py` (das Fenster offscreen: -suchen und senden, Dialog annehmen und ablehnen). Zusammen 48 Host-Tests. +Empfangsfenster, `power listen` bei scan-fähiger Firmware, Neuverbinden) und +`test_gui.py` (das Fenster offscreen: suchen und senden, Dialog annehmen und +ablehnen). Zusammen 49 Host-Tests. ## Danach diff --git a/firmware/README.md b/firmware/README.md index 7da9b88..e697830 100644 --- a/firmware/README.md +++ b/firmware/README.md @@ -33,10 +33,14 @@ idf.py -B build_c5_bridge -DSDKCONFIG=build_c5_bridge/sdkconfig -DIDF_TARGET=esp `sdkconfig.defaults.esp32c5` is picked up automatically for the C5. -The bridge firmware includes Bluetooth LE (NimBLE, for the AirDrop wake) and -uses the large single-app partition table. ESP-IDF does not apply changed -defaults to an existing `sdkconfig`, so after pulling such a change delete the -build directory (e.g. `rm -rf build_c5_bridge`) before building again. +The bridge firmware includes Bluetooth LE (NimBLE): the broadcaster role for +the AirDrop wake (`wake`), and the observer role so it can listen for AirDrop +adverts and let the Wi-Fi radio sleep until a device nearby wants to share +(`power listen`, docs/SPIKES.md). It uses the large single-app partition table. +ESP-IDF does not apply changed defaults to an existing `sdkconfig`, so after +pulling a change to `sdkconfig.bridge.defaults` (the observer role was one) +delete the build directory (e.g. `rm -rf build_c5_bridge`) before building +again. ## Host unit test diff --git a/firmware/spikes/components/awdl/awdl_power.c b/firmware/spikes/components/awdl/awdl_power.c index 910d654..b5478cb 100644 --- a/firmware/spikes/components/awdl/awdl_power.c +++ b/firmware/spikes/components/awdl/awdl_power.c @@ -8,7 +8,8 @@ void awdl_power_init(awdl_power_t *p, awdl_power_mode_t mode, int64_t now) { p->mode = mode; - p->hold_until = p->wake_until = p->busy_until = now; + p->listen = false; + p->hold_until = p->wake_until = p->busy_until = p->advert_until = now; p->host_seen = now; } @@ -29,6 +30,13 @@ void awdl_power_busy(awdl_power_t *p, int64_t now, int open_tcp) { p->busy_until = now + AWDL_POWER_BUSY_GRACE_US; } +void awdl_power_set_listen(awdl_power_t *p, bool on) { p->listen = on; } + +void awdl_power_advert(awdl_power_t *p, int64_t now) { + if (now + AWDL_POWER_ADVERT_WAKE_US > p->advert_until) + p->advert_until = now + AWDL_POWER_ADVERT_WAKE_US; +} + /* A host request counts while the host is around: a deadline beyond the host * timeout is cut back to it, so a closed app does not keep the radio on. */ static int64_t effective(const awdl_power_t *p, int64_t deadline) { @@ -41,9 +49,16 @@ awdl_awake_reason_t awdl_power_reason(const awdl_power_t *p, int64_t now) { if (now < p->busy_until) return AWDL_AWAKE_BUSY; if (now < effective(p, p->hold_until)) return AWDL_AWAKE_HOLD; if (now < effective(p, p->wake_until)) return AWDL_AWAKE_WAKE; + if (p->listen && now < effective(p, p->advert_until)) return AWDL_AWAKE_ADVERT; return AWDL_AWAKE_NONE; } +/* The scan runs while the host wants to listen and is still around (a host gone + * quiet for the timeout cannot receive, so there is nothing to wake for). */ +bool awdl_power_want_scan(const awdl_power_t *p, int64_t now) { + return p->listen && now < p->host_seen + AWDL_POWER_HOST_TIMEOUT_US; +} + uint32_t awdl_power_left_s(const awdl_power_t *p, int64_t deadline, int64_t now) { int64_t d = effective(p, deadline) - now; return d > 0 ? (uint32_t)((d + 999999) / 1000000) : 0; @@ -55,6 +70,7 @@ const char *awdl_power_reason_name(awdl_awake_reason_t r) { case AWDL_AWAKE_HOLD: return "hold"; case AWDL_AWAKE_WAKE: return "wake"; case AWDL_AWAKE_BUSY: return "busy"; + case AWDL_AWAKE_ADVERT: return "advert"; default: return "none"; } } diff --git a/firmware/spikes/components/awdl/include/awdl_power.h b/firmware/spikes/components/awdl/include/awdl_power.h index 226db33..35cc4ae 100644 --- a/firmware/spikes/components/awdl/include/awdl_power.h +++ b/firmware/spikes/components/awdl/include/awdl_power.h @@ -43,13 +43,18 @@ typedef enum { AWDL_AWAKE_HOLD, /* power hold */ AWDL_AWAKE_WAKE, /* BLE wake for a send */ AWDL_AWAKE_BUSY, /* TCP connection (or its grace period) */ + AWDL_AWAKE_ADVERT, /* an AirDrop advert was heard while listening */ } awdl_awake_reason_t; +#define AWDL_POWER_ADVERT_WAKE_US (25LL * 1000000) /* awake after an AirDrop advert */ + typedef struct { awdl_power_mode_t mode; + bool listen; /* watch for AirDrop adverts and wake on them */ int64_t hold_until; /* power hold deadline */ int64_t wake_until; /* BLE wake deadline */ int64_t busy_until; /* last TCP connection + grace */ + int64_t advert_until; /* last AirDrop advert heard + wake */ int64_t host_seen; /* last message from the host */ } awdl_power_t; @@ -64,6 +69,12 @@ void awdl_power_wake(awdl_power_t *p, int64_t now, uint32_t secs); void awdl_power_host_seen(awdl_power_t *p, int64_t now); /* Call regularly with the number of open TCP connections. */ void awdl_power_busy(awdl_power_t *p, int64_t now, int open_tcp); +/* Listen mode: watch for AirDrop adverts and wake the radio on them. */ +void awdl_power_set_listen(awdl_power_t *p, bool on); +/* An AirDrop advert was heard: wake for a while (only acts while listening). */ +void awdl_power_advert(awdl_power_t *p, int64_t now); +/* Should the BLE scan run now (listen mode, and the host is still around)? */ +bool awdl_power_want_scan(const awdl_power_t *p, int64_t now); /* Should the radio be on now, and why (AWDL_AWAKE_NONE: let it sleep). */ awdl_awake_reason_t awdl_power_reason(const awdl_power_t *p, int64_t now); diff --git a/firmware/spikes/components/awdl/test/host/test_awdl_power.c b/firmware/spikes/components/awdl/test/host/test_awdl_power.c index f1bdd71..a758c7c 100644 --- a/firmware/spikes/components/awdl/test/host/test_awdl_power.c +++ b/firmware/spikes/components/awdl/test/host/test_awdl_power.c @@ -62,11 +62,37 @@ static void test_requests_lapse_with_the_host(void) { CHECK(awdl_power_reason(&p, S(310)) == AWDL_AWAKE_BUSY); } +static void test_listen_and_advert(void) { + awdl_power_t p; + awdl_power_init(&p, AWDL_POWER_AUTO, 0); + awdl_power_host_seen(&p, S(10)); + /* Listen off: no scan, and an advert does not wake the radio. */ + CHECK(!awdl_power_want_scan(&p, S(11))); + awdl_power_advert(&p, S(11)); + CHECK(awdl_power_reason(&p, S(12)) == AWDL_AWAKE_NONE); + /* Listen on: the scan runs; that earlier advert does not linger past its window. */ + awdl_power_set_listen(&p, true); + CHECK(awdl_power_want_scan(&p, S(12))); + CHECK(awdl_power_reason(&p, S(40)) == AWDL_AWAKE_NONE); /* 11 + 25 s passed */ + /* A fresh advert wakes it, and a later one re-arms it (share sheet still open). */ + awdl_power_advert(&p, S(40)); + CHECK(awdl_power_reason(&p, S(50)) == AWDL_AWAKE_ADVERT); + CHECK(awdl_power_reason(&p, S(66)) == AWDL_AWAKE_NONE); /* 40 + 25 s passed */ + awdl_power_advert(&p, S(60)); + CHECK(awdl_power_reason(&p, S(80)) == AWDL_AWAKE_ADVERT); + /* Host goes quiet: no scan, and adverts stop keeping it awake. */ + CHECK(awdl_power_want_scan(&p, S(10 + 89))); + CHECK(!awdl_power_want_scan(&p, S(10 + 90))); + awdl_power_advert(&p, S(200)); + CHECK(awdl_power_reason(&p, S(205)) == AWDL_AWAKE_NONE); /* host silent since 10 s */ +} + int main(void) { test_auto_sleeps_without_requests(); test_hold_and_release(); test_wake_and_busy(); test_requests_lapse_with_the_host(); + test_listen_and_advert(); if (failures) { printf("test_awdl_power: %d failure(s)\n", failures); return EXIT_FAILURE; diff --git a/firmware/spikes/main/ble_wake.c b/firmware/spikes/main/ble_wake.c index f9b253e..b5a9c8f 100644 --- a/firmware/spikes/main/ble_wake.c +++ b/firmware/spikes/main/ble_wake.c @@ -30,6 +30,15 @@ static volatile bool s_ready; /* host synced with the controller */ static volatile bool s_active; /* our advert is running */ static volatile int64_t s_end_us; /* when it stops by itself */ +#if CONFIG_BT_NIMBLE_ROLE_OBSERVER +static volatile bool s_scanning; /* the AirDrop-advert scan is running */ +static ble_scan_cb_t s_scan_cb; +static void *s_scan_ctx; +/* 40 ms every 320 ms: enough to catch a phone's AirDrop adverts within a + * second or two, low enough that scanning costs little while Wi-Fi is off. */ +#define SCAN_UNITS(ms) ((uint16_t)((ms) * 1000 / 625)) /* ms -> 0.625 ms units */ +#endif + static void on_sync(void) { s_ready = true; } static void on_reset(int reason) { @@ -136,6 +145,79 @@ unsigned ble_wake_remaining(void) { return left > 0 ? (unsigned)((left + 999999) / 1000000) : 0; } +#if CONFIG_BT_NIMBLE_ROLE_OBSERVER + +/* Is this an Apple "AirDrop" Continuity advert (manufacturer 0x004C, type + * 0x05)? Parse AD structures, then the Continuity TLVs inside Apple's + * manufacturer-specific data. We only answer yes/no; nothing is kept. */ +static bool is_airdrop_advert(const uint8_t *d, uint8_t len) { + for (int i = 0; i + 1 < len;) { + uint8_t ad_len = d[i]; + if (ad_len == 0 || i + 1 + ad_len > len) break; + uint8_t ad_type = d[i + 1]; + if (ad_type == 0xff && ad_len >= 4 && d[i + 2] == 0x4c && d[i + 3] == 0x00) { + int j = i + 4, end = i + 1 + ad_len; /* Continuity TLVs: type, len, value */ + while (j + 1 < end) { + uint8_t t = d[j], l = d[j + 1]; + if (t == 0x05) return true; /* 0x05 = AirDrop */ + j += 2 + l; + } + } + i += 1 + ad_len; + } + return false; +} + +/* NimBLE host task. An advert arrived while scanning. */ +static int on_disc(struct ble_gap_event *ev, void *arg) { + (void)arg; + if (ev->type == BLE_GAP_EVENT_DISC) { + if (s_scan_cb && is_airdrop_advert(ev->disc.data, ev->disc.length_data)) + s_scan_cb(s_scan_ctx); + } else if (ev->type == BLE_GAP_EVENT_DISC_COMPLETE) { + s_scanning = false; /* only if a duration was set; we scan forever */ + } + return 0; +} + +bool ble_scan_supported(void) { return true; } + +int ble_scan_start(ble_scan_cb_t cb, void *ctx) { + if (!s_ready) return BLE_HS_ENOTSYNCED; + s_scan_cb = cb; + s_scan_ctx = ctx; + if (s_scanning) return 0; + struct ble_gap_disc_params p = {0}; + p.passive = 1; /* listen only, never send scan requests */ + p.filter_duplicates = 0; /* repeats re-arm the wake while sharing stays open */ + p.itvl = SCAN_UNITS(320); + p.window = SCAN_UNITS(40); + int rc = ble_gap_disc(BLE_OWN_ADDR_PUBLIC, BLE_HS_FOREVER, &p, on_disc, NULL); + if (rc == 0) s_scanning = true; + return rc; +} + +int ble_scan_stop(void) { + if (!s_scanning) return 0; + int rc = ble_gap_disc_cancel(); + if (rc == 0 || rc == BLE_HS_EALREADY) { + s_scanning = false; + return 0; + } + return rc; +} + +bool ble_scan_active(void) { return s_scanning; } + +#else /* observer role not built: scanning is unavailable */ + +bool ble_scan_supported(void) { return false; } +int ble_scan_start(ble_scan_cb_t cb, void *ctx) { (void)cb; (void)ctx; return BLE_HS_ENOTSUP; } +int ble_scan_stop(void) { return 0; } +bool ble_scan_active(void) { return false; } + +#endif /* CONFIG_BT_NIMBLE_ROLE_OBSERVER */ + #else /* !CONFIG_BT_NIMBLE_ENABLED: firmware built without Bluetooth */ int ble_wake_init(void) { return ESP_ERR_NOT_SUPPORTED; } @@ -147,5 +229,9 @@ int ble_wake_start(unsigned secs, const uint8_t hashes[8]) { } int ble_wake_stop(void) { return 0; } unsigned ble_wake_remaining(void) { return 0; } +bool ble_scan_supported(void) { return false; } +int ble_scan_start(ble_scan_cb_t cb, void *ctx) { (void)cb; (void)ctx; return ESP_ERR_NOT_SUPPORTED; } +int ble_scan_stop(void) { return 0; } +bool ble_scan_active(void) { return false; } #endif diff --git a/firmware/spikes/main/ble_wake.h b/firmware/spikes/main/ble_wake.h index 522498b..0038800 100644 --- a/firmware/spikes/main/ble_wake.h +++ b/firmware/spikes/main/ble_wake.h @@ -51,6 +51,22 @@ int ble_wake_stop(void); /* Seconds the running wake has left, 0 if it is off. */ unsigned ble_wake_remaining(void); +/* + * Listen for AirDrop over Bluetooth, the other way round: an iPhone/iPad with + * its share sheet open sends the same Continuity "AirDrop" advert (type 0x05) + * to find receivers. A passive BLE scan watches for it, so main/power.c can + * keep the Wi-Fi radio asleep and only wake AWDL when a phone nearby actually + * wants to share, instead of keeping AWDL on all the time. cb runs on the + * NimBLE host task for every AirDrop advert seen (no device identity is kept; + * only "an AirDrop advert is in range" matters). Needs the observer role in + * the build; ble_scan_supported() is false otherwise and ble_scan_start fails. + */ +typedef void (*ble_scan_cb_t)(void *ctx); +bool ble_scan_supported(void); +int ble_scan_start(ble_scan_cb_t cb, void *ctx); +int ble_scan_stop(void); +bool ble_scan_active(void); + #ifdef __cplusplus } #endif diff --git a/firmware/spikes/main/bridge.c b/firmware/spikes/main/bridge.c index 9e57dc6..e16c75b 100644 --- a/firmware/spikes/main/bridge.c +++ b/firmware/spikes/main/bridge.c @@ -159,7 +159,8 @@ static void send_hello_reply(void) { r[9] = 1400 & 0xff; /* max TCP_DATA chunk, little endian */ r[10] = 1400 >> 8; r[11] = (ble_wake_ready() ? 0x01 : 0) /* caps: BLE wake */ - | 0x02; /* IP mode */ + | 0x02 /* IP mode */ + | ((ble_wake_ready() && ble_scan_supported()) ? 0x04 : 0); /* listen (BLE scan) */ bridge_send(BRIDGE_MSG_HELLO_REPLY, r, sizeof(r)); } @@ -529,20 +530,29 @@ static void handle_cmd(const uint8_t *p, size_t n) { } else if (strcmp(cmd, "power") == 0 || strncmp(cmd, "power ", 6) == 0) { /* `power` shows whether the radio is on and why; `power hold ` * keeps it on (browsing, a receive window), `power release` ends that, + * `power listen on|off` watches for AirDrop adverts and wakes on them, * `power on` / `power auto` set the mode (HELLO sets it too). */ const char *arg = cmd[5] == '\0' ? "" : cmd + 6; bool bad = false; if (strcmp(arg, "on") == 0) power_set_mode(AWDL_POWER_ON); else if (strcmp(arg, "auto") == 0) power_set_mode(AWDL_POWER_AUTO); else if (strcmp(arg, "release") == 0) power_hold(0); - else if (strncmp(arg, "hold ", 5) == 0) { + else if (strcmp(arg, "listen on") == 0) { + if (power_listen_supported()) power_set_listen(true); + else bad = true; + } else if (strcmp(arg, "listen off") == 0) { + power_set_listen(false); + } else if (strncmp(arg, "hold ", 5) == 0) { char *end; long secs = strtol(arg + 5, &end, 10); bad = end == arg + 5 || *end || secs < 1 || secs > 3600; if (!bad) power_hold((uint32_t)secs); } else if (arg[0]) bad = true; - if (bad) snprintf(out, sizeof(out), "power: use `power`, `power hold <1..3600>`, " - "`power release`, `power on` or `power auto`"); + if (strcmp(arg, "listen on") == 0 && !power_listen_supported()) + snprintf(out, sizeof(out), "power: listen needs the BLE observer role, not in " + "this firmware"); + else if (bad) snprintf(out, sizeof(out), "power: use `power`, `power hold <1..3600>`, " + "`power release`, `power listen on|off`, `power on` or `power auto`"); else power_format_status(out, sizeof(out)); cmd_reply(out); } else if (strcmp(cmd, "mode") == 0 || strncmp(cmd, "mode ", 5) == 0) { diff --git a/firmware/spikes/main/power.c b/firmware/spikes/main/power.c index 8b81ae5..06e251e 100644 --- a/firmware/spikes/main/power.c +++ b/firmware/spikes/main/power.c @@ -14,6 +14,7 @@ #include "freertos/task.h" #include "awdl_app.h" +#include "ble_wake.h" #include "bridge.h" #include "radio.h" #include "sockproxy.h" @@ -24,7 +25,8 @@ static awdl_power_t s_pol; static portMUX_TYPE s_mux = portMUX_INITIALIZER_UNLOCKED; static TaskHandle_t s_task; static volatile bool s_awake = true; /* the radio comes up awake at boot */ -static uint32_t s_sleeps, s_wakes, s_errors; +static volatile bool s_scanning; /* the BLE AirDrop-advert scan is running */ +static uint32_t s_sleeps, s_wakes, s_errors, s_adverts; static int64_t s_asleep_us, s_since; /* time asleep, last switch */ static int64_t now_us(void) { return esp_timer_get_time(); } @@ -33,6 +35,30 @@ static void kick(void) { if (s_task) xTaskNotifyGive(s_task); } +/* NimBLE host task: an AirDrop advert is in range. Arm a short wake and let the + * power task bring the radio up; nothing about the sender is kept. */ +static void on_advert(void *ctx) { + (void)ctx; + int64_t t = now_us(); + portENTER_CRITICAL(&s_mux); + awdl_power_advert(&s_pol, t); + s_adverts++; + portEXIT_CRITICAL(&s_mux); + kick(); +} + +/* Power task only: start/stop the BLE scan to match the wanted listen state. */ +static void apply_scan(bool want) { + if (want == s_scanning) return; + int rc = want ? ble_scan_start(on_advert, NULL) : ble_scan_stop(); + if (rc == 0) { + s_scanning = want; + bridge_log("power: listen %s", want ? "on (watching for AirDrop)" : "off"); + } else { + s_errors++; + } +} + /* Power task only: bring the radio to the wanted state. */ static void apply(awdl_awake_reason_t why) { bool want = why != AWDL_AWAKE_NONE; @@ -71,8 +97,10 @@ static void power_task(void *arg) { portENTER_CRITICAL(&s_mux); awdl_power_busy(&s_pol, t, open); awdl_awake_reason_t why = awdl_power_reason(&s_pol, t); + bool want_scan = awdl_power_want_scan(&s_pol, t); portEXIT_CRITICAL(&s_mux); apply(why); + apply_scan(want_scan); } } @@ -108,6 +136,16 @@ void power_wake(uint32_t secs) { kick(); } +void power_set_listen(bool on) { + portENTER_CRITICAL(&s_mux); + awdl_power_set_listen(&s_pol, on); + awdl_power_host_seen(&s_pol, now_us()); + portEXIT_CRITICAL(&s_mux); + kick(); +} + +bool power_listen_supported(void) { return ble_scan_supported(); } + void power_host_seen(void) { int64_t t = now_us(); portENTER_CRITICAL(&s_mux); @@ -126,13 +164,14 @@ int power_format_status(char *buf, size_t cap) { int64_t asleep = s_asleep_us + (s_awake ? 0 : t - s_since); int64_t up = t > 0 ? t : 1; return snprintf(buf, cap, - "power %s %s (%s) hold=%us wake=%us tcp=%d host=%us ago " - "sleeps=%u wakes=%u asleep=%u%% err=%u", + "power %s %s (%s) hold=%us wake=%us listen=%s tcp=%d host=%us ago " + "sleeps=%u wakes=%u adverts=%u asleep=%u%% err=%u", p.mode == AWDL_POWER_ON ? "on" : "auto", s_awake ? "awake" : "asleep", awdl_power_reason_name(why), (unsigned)awdl_power_left_s(&p, p.hold_until, t), (unsigned)awdl_power_left_s(&p, p.wake_until, t), + p.listen ? (s_scanning ? "on" : "arming") : "off", sockproxy_tcp_open(), (unsigned)((t - p.host_seen) / 1000000), - (unsigned)s_sleeps, (unsigned)s_wakes, + (unsigned)s_sleeps, (unsigned)s_wakes, (unsigned)s_adverts, (unsigned)(asleep * 100 / up), (unsigned)s_errors); } diff --git a/firmware/spikes/main/power.h b/firmware/spikes/main/power.h index ab54f9d..b8c42fa 100644 --- a/firmware/spikes/main/power.h +++ b/firmware/spikes/main/power.h @@ -30,6 +30,8 @@ int power_init(awdl_power_mode_t mode); void power_set_mode(awdl_power_mode_t mode); void power_hold(uint32_t secs); /* 0 releases */ void power_wake(uint32_t secs); /* BLE wake running for secs; 0 stopped */ +void power_set_listen(bool on); /* watch for AirDrop adverts and wake on them */ +bool power_listen_supported(void); /* false without the BLE observer role */ void power_host_seen(void); bool power_awake(void); diff --git a/firmware/spikes/sdkconfig.bridge.defaults b/firmware/spikes/sdkconfig.bridge.defaults index 2ed1240..84b1e22 100644 --- a/firmware/spikes/sdkconfig.bridge.defaults +++ b/firmware/spikes/sdkconfig.bridge.defaults @@ -27,7 +27,9 @@ CONFIG_BT_NIMBLE_50_FEATURE_SUPPORT=n CONFIG_BT_NIMBLE_ROLE_BROADCASTER=y CONFIG_BT_NIMBLE_ROLE_CENTRAL=n CONFIG_BT_NIMBLE_ROLE_PERIPHERAL=n -CONFIG_BT_NIMBLE_ROLE_OBSERVER=n +# Observer: a passive scan for AirDrop adverts, so the radio can sleep and wake +# only when a phone nearby wants to share (main/ble_wake.c ble_scan, power.c). +CONFIG_BT_NIMBLE_ROLE_OBSERVER=y CONFIG_BT_NIMBLE_SECURITY_ENABLE=n CONFIG_BT_NIMBLE_LOG_LEVEL_WARNING=y # Advertising only, so no connections and no ACL data: shrink the buffer pools diff --git a/host/espdrop/cli.py b/host/espdrop/cli.py index 92f78b4..e8af1f3 100644 --- a/host/espdrop/cli.py +++ b/host/espdrop/cli.py @@ -271,17 +271,24 @@ def on_event(kind, **info): f"raw archive kept: {info['raw'] or 'no'}", flush=True) dev = _open(args, transport) - rx = Receiver(dev, name=args.name, on_ask=on_ask, on_event=on_event) + rx = Receiver(dev, name=args.name, on_ask=on_ask, on_event=on_event, + announce_interval=2.5) + listen = False try: - _hello(dev) + h = _hello(dev) + listen = bool(h["caps"] & link.CAP_SCAN) rx.start() + if listen: + dev.cmd("power listen on") # the ESP wakes itself on AirDrop adverts + print("the ESP listens for AirDrop and keeps its Wi-Fi asleep until a device is " + "near.", flush=True) print(f"ready: on the iPhone/iPad, share a file and pick {args.name!r}; files go to " f"{os.getcwd()}. Ctrl+C to stop.", flush=True) try: renew = 0.0 while not dev.error: - if time.monotonic() >= renew: # the radio stays on while we receive - _hold(dev, 90) + if time.monotonic() >= renew: # keep the host's requests from lapsing + _hold(dev, 90) if not listen else dev.cmd("power") renew = time.monotonic() + 30 time.sleep(0.3) except KeyboardInterrupt: @@ -290,6 +297,11 @@ def on_event(kind, **info): finally: rx.stop() if not dev.error: + if listen: + try: + dev.cmd("power listen off") + except DeviceError: + pass _hold(dev, 0) dev.close() diff --git a/host/espdrop/gui.py b/host/espdrop/gui.py index 7cfaf1e..fdb8f3b 100644 --- a/host/espdrop/gui.py +++ b/host/espdrop/gui.py @@ -644,10 +644,12 @@ def power_text(reply): return "" if words[1] == "on": return "Funk immer an" - why = {"(hold)": "für die App", "(wake)": "zum Senden", "(busy)": "Übertragung"}.get( - words[3] if len(words) > 3 else "", "") - return "Funk aus (spart Strom)" if words[2] == "asleep" else f"Funk an ({why})" if why \ - else "Funk an" + listen = next((w[7:] for w in words if w.startswith("listen=")), "off") + why = {"(hold)": "für die App", "(wake)": "zum Senden", "(busy)": "Übertragung", + "(advert)": "Gerät in der Nähe"}.get(words[3] if len(words) > 3 else "", "") + if words[2] == "asleep": + return "Funk schläft (empfangsbereit)" if listen != "off" else "Funk aus (spart Strom)" + return f"Funk an ({why})" if why else "Funk an" def default_download_dir(): diff --git a/host/espdrop/link.py b/host/espdrop/link.py index b5cd9df..bbb1a4e 100644 --- a/host/espdrop/link.py +++ b/host/espdrop/link.py @@ -46,8 +46,9 @@ MODE_L2 = 0 # frames to/from the host's awdl0 (tools/awdl_bridge.py) MODE_IP = 1 # the device's own IPv6 stack (this host app) -CAP_BLE = 0x01 # HELLO_REPLY caps: the BLE AirDrop wake (`wake`) works -CAP_IP = 0x02 # IP mode is available +CAP_BLE = 0x01 # HELLO_REPLY caps: the BLE AirDrop wake (`wake`) works +CAP_IP = 0x02 # IP mode is available +CAP_SCAN = 0x04 # `power listen`: wake AWDL on AirDrop adverts # RESULT error codes - enum sockproxy_err in firmware/spikes/main/sockproxy.h. ERRORS = { diff --git a/host/espdrop/receive.py b/host/espdrop/receive.py index cd9fc65..302bad0 100644 --- a/host/espdrop/receive.py +++ b/host/espdrop/receive.py @@ -106,11 +106,13 @@ class Receiver: received, total) and "received" (ask, files, ok, raw, size, secs). Files land in the current directory.""" - def __init__(self, dev, name="EspDrop", flags=0x3fb, on_ask=None, on_event=None): + def __init__(self, dev, name="EspDrop", flags=0x3fb, on_ask=None, on_event=None, + announce_interval=5.0): self.dev = dev env.ensure_identity(name) # before OpenDrop would call openssl self.config = AirDropConfig(computer_name=name) self.config.flags = flags + self.announce_interval = announce_interval self.on_ask = on_ask self.on_event = on_event or (lambda kind, **info: None) self.httpd = None @@ -240,6 +242,7 @@ def start(self): instance = f"{mac.hex()}" self.responder = mdns.Responder(self.dev, instance, AIRDROP_PORT, eui64_linklocal(mac), flags=self.config.flags, + interval=self.announce_interval, host=f"{instance}.local.") self.responder.start() self.dev.listen(AIRDROP_PORT, self._on_accept) diff --git a/host/espdrop/session.py b/host/espdrop/session.py index bd4799c..a5a8981 100644 --- a/host/espdrop/session.py +++ b/host/espdrop/session.py @@ -99,7 +99,9 @@ def __init__(self, port=None, name="EspDrop", receive=True, download_dir=None, self._factory = transport_factory # tests: a callable returning a transport self._receive = receive self._receive_until = None # end of a receive window (monotonic) - self._held = None # last power request sent: True hold, False release + self._held = None # last hold request sent: True hold, False release + self._listening = None # last listen request sent + self._scan_ok = False # the ESP can wake itself on AirDrop adverts self._lock = threading.RLock() self._stop = threading.Event() self._thread = None @@ -277,13 +279,14 @@ def _run(self): continue with self._lock: self.dev = dev + self._scan_ok = bool(info.get("caps", 0) & link.CAP_SCAN) self._set_state("connected", info=info) if self._receive: try: self._start_receiver() except Exception as e: # noqa: BLE001 self._emit("log", text=f"receiving did not start: {e}") - self._held = None + self._held = self._listening = None # re-send on the new device self._power() self._poll_power() self._serve(dev) @@ -342,8 +345,11 @@ def _teardown(self, dev): def _start_receiver(self): from .receive import Receiver + # Announce a little faster than OpenDrop's 5 s: when the radio wakes on + # an AirDrop advert, the phone should discover us within its share-sheet + # window (it also answers the phone's own queries once awake). self._receiver = Receiver(self.dev, name=self.name, on_ask=self._ask, - on_event=self._on_receive) + on_event=self._on_receive, announce_interval=2.5) self._receiver.start() def _stop_receiver(self): @@ -436,25 +442,39 @@ def _wake(self, seconds): except Exception as e: # noqa: BLE001 self._emit("log", text=f"wake: {e}") - def _busy(self): - return bool(self._receive or self._browser or any( - t.state in ("new", "asking", "uploading") for t in self._transfers.values())) + def _sending(self): + return any(t.state in ("new", "asking", "uploading") for t in self._transfers.values()) + + def _hold_needed(self): + # The radio stays fully on while browsing or sending. For receiving it + # only needs a hold on firmware that cannot wake itself on AirDrop + # adverts; otherwise receiving uses listen (the radio sleeps between). + return bool(self._browser or self._sending() + or (self._receive and not self._scan_ok)) def _power(self): - """Ask the ESP to keep its radio on while we receive, browse or send, - and to let it sleep otherwise (firmware without a sleep mode ignores - it: its radio is always on).""" + """Match the ESP's radio to what we are doing: a hold keeps it on + (browse, send, or receive on firmware without BLE listen); listen lets + it sleep and wake on an AirDrop advert (receive). Firmware without a + sleep mode ignores both: its radio is always on.""" dev = self.dev if not dev or self.state != "connected": return - need = self._busy() - if need == self._held: - return - self._held = need + hold = self._hold_needed() + if hold != self._held: + self._held = hold + self._power_cmd(dev, f"power hold {HOLD_SECS}" if hold else "power release") + if self._scan_ok: + listen = self._receive + if listen != self._listening: + self._listening = listen + self._power_cmd(dev, f"power listen {'on' if listen else 'off'}") + + def _power_cmd(self, dev, text): try: - dev.cmd(f"power hold {HOLD_SECS}" if need else "power release") + dev.cmd(text) except Exception as e: # noqa: BLE001 - self._emit("log", text=f"power: {e}") + self._emit("log", text=f"{text}: {e}") def _unwake(self): """End the BLE wake once neither a browse nor a send needs it.""" diff --git a/host/tests/fake_device.py b/host/tests/fake_device.py index ff46800..be2dce9 100644 --- a/host/tests/fake_device.py +++ b/host/tests/fake_device.py @@ -78,6 +78,10 @@ def _handle(self, t, p): reply = "wake off" if arg == "off" else f"wake on {arg} s left" elif text == "wake": reply = "wake off" + elif text.startswith("power"): + listen = "on" if "listen on" in text else "off" + reply = (f"power auto awake (hold) hold=90s wake=0s listen={listen} " + f"tcp=0 host=0s ago sleeps=0 wakes=0 adverts=0 asleep=0% err=0") else: reply = f"ok {text}" self._emit(link.MSG_CMD_REPLY, reply.encode()) diff --git a/host/tests/test_gui.py b/host/tests/test_gui.py index 9d46297..663de55 100644 --- a/host/tests/test_gui.py +++ b/host/tests/test_gui.py @@ -166,6 +166,10 @@ def test_power_text(self): self.assertEqual(t("power auto asleep (none) hold=0s wake=0s"), "Funk aus (spart Strom)") self.assertEqual(t("power auto awake (hold) hold=80s"), "Funk an (für die App)") self.assertEqual(t("power on awake (on)"), "Funk immer an") + self.assertEqual(t("power auto asleep (none) hold=0s wake=0s listen=on tcp=0"), + "Funk schläft (empfangsbereit)") + self.assertEqual(t("power auto awake (advert) hold=0s wake=0s listen=on tcp=0"), + "Funk an (Gerät in der Nähe)") self.assertEqual(t("ok power"), "") diff --git a/host/tests/test_session.py b/host/tests/test_session.py index ce2cefe..306f619 100644 --- a/host/tests/test_session.py +++ b/host/tests/test_session.py @@ -204,6 +204,35 @@ def test_receive_window_closes_and_radio_sleeps(self): time.sleep(0.05) self.assertEqual(fake.commands[-1], "power release") + def test_listen_mode_when_the_esp_can_wake_itself(self): + # A scan-capable ESP: receiving uses `power listen on` (the radio + # sleeps and wakes on an AirDrop advert), not a continuous hold. + fake = DuplexBridgeDevice(caps=link.CAP_BLE | link.CAP_IP | link.CAP_SCAN) + dl = os.path.join(self.tmp.name, "listen") + self.session = s = Session(transport_factory=lambda: fake, receive=True, + on_event=self.events, on_ask=lambda ask: True, + download_dir=dl, name="MeinPC") + s.start() + self.events.wait(device_state("connected")) + for _ in range(100): + if fake.listening == 8771: + break + time.sleep(0.05) + self.assertEqual(fake.listening, 8771) + self.assertIn("power listen on", fake.commands) + self.assertNotIn("power hold 90", fake.commands) + # A transfer still arrives (the firmware would have woken AWDL). + payload = b"listen path \xff" * 500 + self.assertTrue(self.incoming(fake, "l.jpg", payload)) + self.events.wait(lambda k, i: k == "receive" and i["phase"] == "received") + self.assertIn("l.jpg", os.listdir(dl)) + s.set_receiving(False) + for _ in range(40): + if "power listen off" in fake.commands: + break + time.sleep(0.05) + self.assertIn("power listen off", fake.commands) + def test_declined_and_unanswered(self): fake, dl = self.start(lambda ask: False) self.assertFalse(self.incoming(fake, "nein.txt", b"no")) From f743ec9171e5cc5fdc7bf857bd8bd71c077e9e4d Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 26 Oct 2026 12:19:19 +0000 Subject: [PATCH 81/99] feat(gui): autostart in the background, send from the file manager MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Autostart (menu EspDrop): Windows HKCU Run key, Linux XDG autostart. It starts `espdrop gui --background`: tray only, receiving until switched off; an incoming transfer brings up only its accept dialog (its own top-level window), and a notification says when files arrived (clicking it opens them). The window stays hidden. - Sending from the file manager (menu EspDrop): Windows "Senden an -> EspDrop (AirDrop)" (a shortcut written through WScript.Shell) and "Mit EspDrop senden" in the classic right-click menu (HKCU\Software\Classes\ *\shell); Linux "Öffnen mit" via a desktop entry. Both run `espdrop gui --send FILE...`, which opens "An wen senden?": the devices a search finds, one click sends, progress, closes itself when sent. - One copy per user: a later start hands its files (or "show") to the running app over a QLocalServer socket and exits, so the file manager and autostart never fight for the serial port; files from one Explorer call per file join the same dialog. Windows' own share sheet ("Teilen") lists only packaged (MSIX) apps; that waits for a packaged build. All per user, no admin rights. 57 host tests, incl. the registry and SendTo against a stand-in winreg. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01XzgaN4Bv1m8Ka9iqo7QVUN --- docs/SPIKES.md | 19 +- host/README.md | 13 ++ host/espdrop/cli.py | 10 +- host/espdrop/gui.py | 385 +++++++++++++++++++++++++++++++-- host/espdrop/integration.py | 236 ++++++++++++++++++++ host/tests/test_gui.py | 77 ++++++- host/tests/test_integration.py | 182 ++++++++++++++++ 7 files changed, 901 insertions(+), 21 deletions(-) create mode 100644 host/espdrop/integration.py create mode 100644 host/tests/test_integration.py diff --git a/docs/SPIKES.md b/docs/SPIKES.md index 82e3402..cf0b785 100644 --- a/docs/SPIKES.md +++ b/docs/SPIKES.md @@ -584,12 +584,29 @@ Test auf der Hardware (Firmware mit Schlafmodus geflasht): verbindet sich wieder. 5. Nach dem Ende des Empfangsfensters und ohne Suche: „Funk aus“, der ESP bleibt kühl. +6. Menü EspDrop → „Beim Anmelden im Hintergrund starten“, abmelden und wieder + anmelden: kein Fenster, nur das Tray-Symbol; die App ist dauerhaft + empfangsbereit (bei Firmware mit `power listen` schläft der Funk dabei). + Vom iPad etwas senden: nur der Annehmen-Dialog erscheint, danach eine + Benachrichtigung „Empfangen: …“ (Klick öffnet die Datei). +7. Menü EspDrop → „Im Explorer-Menü anbieten“. Im Explorer Dateien markieren, + Rechtsklick → „Senden an“ → „EspDrop (AirDrop)“ (Windows 11: erst + „Weitere Optionen anzeigen“) oder „Mit EspDrop senden“: „An wen senden?“ + öffnet sich, das iPad erscheint, anklicken, am iPad annehmen; der Dialog + schließt sich nach „Gesendet an …“. + +Windows' eigenes Teilen-Fenster („Teilen“) zeigt nur Apps mit Paket-Identität +(MSIX). Dorthin kommt EspDrop mit der späteren paketierten Version; bis dahin +sind „Senden an“ und das Rechtsklick-Menü der Weg aus dem Explorer. Tests ohne Hardware: `test_session.py` (Suche mit Namen, Senden mit Fortschritt und Abbrechen, Empfangen mit Annehmen/Ablehnen/Zeitüberschreitung, Empfangsfenster, `power listen` bei scan-fähiger Firmware, Neuverbinden) und `test_gui.py` (das Fenster offscreen: suchen und senden, Dialog annehmen und -ablehnen). Zusammen 49 Host-Tests. +ablehnen, Hintergrundbetrieb, „An wen senden?“ mit Dateien aus zwei Aufrufen, +nur eine laufende App) und `test_integration.py` (Autostart und +Dateimanager-Menü: Linux echt in Temp-Ordnern, Windows-Registry und +„Senden an“ nachgebildet). Zusammen 57 Host-Tests. ## Danach diff --git a/host/README.md b/host/README.md index f998445..ad4ee08 100644 --- a/host/README.md +++ b/host/README.md @@ -42,6 +42,19 @@ one window that owns the ESP for as long as it runs: with the sleep mode, docs/SPIKES.md); the label top right says which. - Closing the window keeps it in the tray; "Beenden" ends it. Unplugging the ESP is fine: it reconnects when it comes back. +- **Autostart:** menu EspDrop → "Beim Anmelden im Hintergrund starten" + (Windows: `HKCU\…\CurrentVersion\Run`; Linux: `~/.config/autostart`). It + then starts as `espdrop gui --background`: tray only, receiving until + switched off, and only an incoming transfer brings up its dialog; a + notification says when files arrived (click: open them). +- **Sending from the file manager:** menu EspDrop → "Im Explorer-Menü + anbieten" adds "Senden an → EspDrop (AirDrop)" and "Mit EspDrop senden" to + the right-click menu (on Windows 11 under "Weitere Optionen anzeigen"); on + Linux "Öffnen mit → AirDrop (EspDrop)". Either opens "An wen senden?": the + devices a search finds, one click sends. Windows' own share sheet ("Teilen") + only lists packaged apps (MSIX); that comes with a packaged build. +- **One copy per user:** a second start (autostart, the file manager, the + launcher) hands its files to the running app over a local socket and exits. It needs PySide6 in the venv: `tools/opendrop_install.py` installs it; for a venv made by `opendrop_install.sh`, run `python3 tools/opendrop_install.py` diff --git a/host/espdrop/cli.py b/host/espdrop/cli.py index e8af1f3..cb1872b 100644 --- a/host/espdrop/cli.py +++ b/host/espdrop/cli.py @@ -6,7 +6,7 @@ # espdrop status device, interface, counters # espdrop cmd TEXT... one device command (as in the bridge) # espdrop check is this PC ready (no device needed) -# espdrop gui the desktop app (gui.py, PySide6) +# espdrop gui [--background] [--send FILE...] the desktop app (gui.py, PySide6) # # The device is switched into IP mode (HELLO); it stays there until the next # session (tools/awdl_bridge.py switches it back to L2 itself). @@ -343,7 +343,7 @@ def cmd_gui(args, transport=None): except ImportError as e: raise ImportError(f"the GUI needs PySide6 ({e}): pip install PySide6 into the venv, " f"or run tools/opendrop_install.py again") from e - return gui.main(port=args.port) + return gui.main(port=args.port, background=args.background, send=args.send) def main(argv=None, transport=None): @@ -385,7 +385,11 @@ def main(argv=None, transport=None): c.add_argument("--wait", type=float, default=12.0, help="ping6: seconds to show replies") c.set_defaults(verbose=True) sub.add_parser("check", help="is this PC ready: Python, pyserial, OpenDrop, libarchive, TLS") - sub.add_parser("gui", help="the desktop app (PySide6)") + g = sub.add_parser("gui", help="the desktop app (PySide6)") + g.add_argument("--background", action="store_true", + help="tray only, receiving until switched off (what autostart runs)") + g.add_argument("--send", nargs="*", metavar="FILE", + help="ask whom to send these files to (Explorer's \"Senden an\")") args = ap.parse_args(argv) # Our steps (connect, accept, upload rate) at INFO; OpenDrop's own only from # WARNING on, unless -v asks for everything, with milliseconds since start. diff --git a/host/espdrop/gui.py b/host/espdrop/gui.py index fdb8f3b..d16af19 100644 --- a/host/espdrop/gui.py +++ b/host/espdrop/gui.py @@ -1,12 +1,19 @@ # EspDrop - the desktop app (PySide6) on top of the Session. # # espdrop gui [-p PORT] (Windows: .\tools\espdrop-gui.cmd) +# espdrop gui --background tray only, receiving; what autostart runs +# espdrop gui --send FILE... "An wen senden?" (Explorer "Senden an") # # One window: the ESP's state, a receive window that closes by itself (like # Apple's "Everyone for 10 minutes"), the receivers a search found with their # names, sending by button or by dropping files on a receiver, every transfer # with its progress, and a dialog that asks before anything is received. It -# keeps running in the tray when the window is closed. +# keeps running in the tray when the window is closed; in the background it +# only shows itself for an incoming transfer. +# +# One copy per user: a second start hands its files (or "show yourself") to +# the running one over a local socket and exits, so Explorer's "Senden an" and +# autostart never fight over the serial port. # # Session events arrive on worker threads; a Qt signal carries them to the GUI # thread. Session calls that talk to the device run on a short-lived thread so @@ -15,7 +22,10 @@ # SPDX-License-Identifier: GPL-3.0-or-later # Copyright (C) 2026 EspDrop contributors +import getpass +import json import os +import re import sys import threading import time @@ -23,6 +33,7 @@ from PySide6.QtCore import QObject, QPoint, QSettings, Qt, QTimer, QUrl, Signal from PySide6.QtGui import (QAction, QColor, QDesktopServices, QFont, QIcon, QPainter, QPixmap, QPolygon) +from PySide6.QtNetwork import QLocalServer, QLocalSocket from PySide6.QtWidgets import (QApplication, QComboBox, QDialog, QDialogButtonBox, QFileDialog, QGroupBox, QHBoxLayout, QHeaderView, QLabel, QLineEdit, QListWidget, QListWidgetItem, QMainWindow, QMenu, @@ -30,6 +41,7 @@ QSystemTrayIcon, QTreeWidget, QTreeWidgetItem, QVBoxLayout, QWidget) +from . import integration from .session import Session APP = "EspDrop" @@ -74,6 +86,78 @@ def human(n): n /= 1000 +def _safe(fn, default): + """fn(), or `default` when asking the system fails (a menu still opens).""" + try: + return fn() + except Exception: # noqa: BLE001 + return default + + +# ---- one copy per user ------------------------------------------------------- + +def instance_name(): + """The local socket (a named pipe on Windows) of this user's running copy.""" + user = re.sub(r"[^A-Za-z0-9_-]", "_", getpass.getuser() or "user") + return f"espdrop-{user}" + + +def send_to_running(message, name=None, timeout_ms=1000): + """Hand `message` ({"files": [...], "show": bool}) to the running copy. + False when there is none (then this process becomes it).""" + sock = QLocalSocket() + sock.connectToServer(name or instance_name()) + if not sock.waitForConnected(timeout_ms): + return False + sock.write(json.dumps(message).encode("utf-8") + b"\n") + sock.waitForBytesWritten(timeout_ms) + sock.disconnectFromServer() + if sock.state() != QLocalSocket.UnconnectedState: + sock.waitForDisconnected(timeout_ms) + return True + + +class InstanceServer(QObject): + """The running copy's end: each later start sends one JSON line.""" + message = Signal(object) + + def __init__(self, name=None): + super().__init__() + self.name = name or instance_name() + self.server = QLocalServer(self) + self.server.setSocketOptions(QLocalServer.UserAccessOption) + if not self.server.listen(self.name): + # A socket file left by a copy that crashed (Linux); main() only + # gets here when nothing answered on it. + QLocalServer.removeServer(self.name) + self.server.listen(self.name) + self.server.newConnection.connect(self._accept) + + def _accept(self): + while self.server.hasPendingConnections(): + sock = self.server.nextPendingConnection() + buf = bytearray() + + def read(sock=sock, buf=buf): + buf.extend(bytes(sock.readAll())) + if b"\n" not in buf: + return + line = bytes(buf).split(b"\n", 1)[0] + buf.clear() + try: + msg = json.loads(line.decode("utf-8")) + except ValueError: + return + if isinstance(msg, dict): + self.message.emit(msg) + sock.readyRead.connect(read) + sock.disconnected.connect(sock.deleteLater) + read() + + def close(self): + self.server.close() + + class Bridge(QObject): """Session callbacks (any thread) -> Qt signals (GUI thread).""" event = Signal(str, object) @@ -179,9 +263,160 @@ def dropEvent(self, e): e.acceptProposedAction() +class SendDialog(QDialog): + """'An wen senden?' as AirDrop shows it: the files, the devices a search + finds, one click sends. It shows the transfer and closes itself when the + files are through; Abbrechen (or closing it) cancels a running one.""" + + def __init__(self, window, files): + super().__init__(None) # top-level: the main window may be hidden + self.w = window + self.files = [] + self.transfer_id = None + self._browsing = False + self.setWindowTitle("Per AirDrop senden") + self.setWindowIcon(app_icon()) + self.setWindowFlag(Qt.WindowStaysOnTopHint) + self.setAttribute(Qt.WA_DeleteOnClose) + lay = QVBoxLayout(self) + self.files_label = QLabel() + self.files_label.setWordWrap(True) + lay.addWidget(self.files_label) + self.status = QLabel() + self.status.setWordWrap(True) + self.status.setStyleSheet("color: gray") + lay.addWidget(self.status) + self.list = QListWidget() + self.list.itemClicked.connect(self._pick) + lay.addWidget(self.list, 1) + self.bar = QProgressBar() + self.bar.setVisible(False) + lay.addWidget(self.bar) + row = QHBoxLayout() + self.more_btn = QPushButton("Dateien …") + self.more_btn.clicked.connect(self._choose_files) + cancel = QPushButton("Abbrechen") + cancel.clicked.connect(self.reject) + row.addWidget(self.more_btn) + row.addStretch(1) + row.addWidget(cancel) + lay.addLayout(row) + self.resize(420, 380) + self.add_files(files) + for r in window.session.receivers(): + if r.present: + self.on_receiver(r) + self.on_device(window.session.state == "connected") + + # ---- files ---------------------------------------------------------------- + + def add_files(self, files): + """More files for this send (Explorer passes them one by one).""" + if self.transfer_id is not None: + return False + for f in files: + f = os.path.abspath(f) + if os.path.isfile(f) and f not in self.files: + self.files.append(f) + names = [os.path.basename(f) for f in self.files] + if names: + shown = ", ".join(names[:4]) + (f" (+{len(names) - 4})" if len(names) > 4 else "") + size = sum(os.path.getsize(f) for f in self.files) + self.files_label.setText(f"{len(names)} {'Datei' if len(names) == 1 else 'Dateien'}" + f" ({human(size)}): {_esc(shown)}") + else: + self.files_label.setText("Noch keine Datei gewählt (Ordner gehen noch nicht).") + return True + + def _choose_files(self): + files, _ = QFileDialog.getOpenFileNames(self, "Dateien senden") + self.add_files(files) + + # ---- devices -------------------------------------------------------------- + + def on_device(self, connected): + if not connected: + self.status.setText("Verbinde mit dem ESP …") + self._browsing = False + return + if not self._browsing and self.transfer_id is None: + self._browsing = True + self.status.setText("Suche Geräte … Am iPhone/iPad: AirDrop „Für alle“, " + "entsperrt, Teilen-Menü offen.") + run_bg(self.w.session.browse, 60, on_error=self.w._log) + + def on_receiver(self, r): + item = None + for i in range(self.list.count()): + if self.list.item(i).data(Qt.UserRole) == r.id: + item = self.list.item(i) + if item is None: + if not r.present: + return + item = QListWidgetItem() + item.setData(Qt.UserRole, r.id) + self.list.addItem(item) + model = f" ({r.model})" if r.model else "" + item.setText(f"{r.label}{model}" + ("" if r.present else " – nicht mehr da")) + item.setForeground(QColor("black") if r.present else QColor("gray")) + if self.transfer_id is None and self._browsing: + self.status.setText("Auf ein Gerät klicken, um zu senden.") + + def _pick(self, item): + if self.transfer_id is not None: + return + if not self.files: + self._choose_files() + if not self.files: + return + try: + t = self.w.session.send(item.data(Qt.UserRole), self.files) + except Exception as e: # noqa: BLE001 + self.status.setText(f"Senden geht nicht: {e}") + return + self.transfer_id = t.id + self.list.setEnabled(False) + self.more_btn.setEnabled(False) + self.bar.setVisible(True) + self.bar.setRange(0, 0) + self.status.setText(f"Warte, bis {t.receiver.label} annimmt …") + + # ---- the transfer --------------------------------------------------------- + + def on_send(self, info): + t = info["transfer"] + if t.id != self.transfer_id: + return + state = info["state"] + if state == "uploading": + self.bar.setRange(0, 1000) + self.bar.setValue(int(1000 * info["progress"])) + self.status.setText(f"Sende an {t.receiver.label} …") + elif state == "done": + self.bar.setRange(0, 1000) + self.bar.setValue(1000) + self.status.setText(f"Gesendet an {t.receiver.label}.") + self.transfer_id = None + QTimer.singleShot(1500, self.accept) + elif state in ("declined", "failed", "cancelled"): + text = {"declined": f"{t.receiver.label} hat abgelehnt.", + "cancelled": "Abgebrochen."}.get(state, f"Fehlgeschlagen: {info.get('error')}") + self.status.setText(text + " Nochmal: auf ein Gerät klicken.") + self.transfer_id = None + self.bar.setVisible(False) + self.list.setEnabled(True) + self.more_btn.setEnabled(True) + + def done(self, result): + if result != QDialog.Accepted and self.transfer_id is not None: + run_bg(self.w.session.cancel, self.transfer_id) + self.w._send_dialog_closed(self) + super().done(result) + + class MainWindow(QMainWindow): def __init__(self, port=None, settings=None, session_factory=None, ask_timeout=60.0, - tray=True): + tray=True, background=False): super().__init__() self.settings = settings or QSettings(APP, APP) self.setWindowTitle(APP) @@ -198,6 +433,9 @@ def __init__(self, port=None, settings=None, session_factory=None, ask_timeout=6 self._recv_n = 0 self._recv_row = None # the row of the transfer being received self._quitting = False + self._send_dialogs = [] # open "An wen senden?" dialogs + self._ask_dialogs = [] # open accept dialogs (may have no parent) + self._notify_files = None # what clicking the last notification opens self._build() self.tray = self._make_tray() if tray and QSystemTrayIcon.isSystemTrayAvailable() else None self.session = self._make_session() @@ -205,6 +443,13 @@ def __init__(self, port=None, settings=None, session_factory=None, ask_timeout=6 self._update_dir() self.clock = QTimer(self, interval=1000, timeout=self._tick) self.clock.start() + if background: + # Autostart: receive until switched off; the window stays hidden + # and only an incoming transfer shows its dialog. (Not through the + # button: that would also store "dauerhaft" as the user's choice.) + self.window_combo.setCurrentText("dauerhaft") + self._receiving_ui(True) + run_bg(self.session.set_receiving, True, None, on_error=self._log) # ---- setup --------------------------------------------------------------- @@ -318,6 +563,20 @@ def _build(self): a.triggered.connect(self._pick_port) menu.addAction(a) menu.addSeparator() + caps = integration.supported() + self.autostart_action = self.send_menu_action = None + if caps["autostart"]: + a = self.autostart_action = QAction("Beim Anmelden im Hintergrund starten", + self, checkable=True) + a.setChecked(_safe(integration.autostart_enabled, False)) + a.toggled.connect(lambda on: self._integrate(integration.set_autostart, on, a)) + menu.addAction(a) + if caps["send"]: + a = self.send_menu_action = QAction(caps["send"], self, checkable=True) + a.setChecked(_safe(integration.send_menu_enabled, False)) + a.toggled.connect(lambda on: self._integrate(integration.set_send_menu, on, a)) + menu.addAction(a) + menu.addSeparator() a = QAction("Beenden", self) a.triggered.connect(self.quit) menu.addAction(a) @@ -329,6 +588,7 @@ def _make_tray(self): tray = QSystemTrayIcon(app_icon(), self) menu = QMenu() menu.addAction("Anzeigen", self._show) + menu.addAction("Dateien senden …", lambda: self.open_send_dialog([])) self.tray_receive = menu.addAction("Empfangsbereit machen", lambda: self.receive_btn.setChecked( not self.receive_btn.isChecked())) @@ -337,10 +597,69 @@ def _make_tray(self): tray.setContextMenu(menu) tray.activated.connect(lambda reason: self._show() if reason == QSystemTrayIcon.Trigger else None) + tray.messageClicked.connect(self._notification_clicked) tray.setToolTip(APP) tray.show() return tray + def _notify(self, text, files=None, msecs=5000): + """A tray notification; clicking it opens `files` (else the window).""" + self._notify_files = files + if self.tray: + self.tray.showMessage(APP, text, QSystemTrayIcon.Information, msecs) + + def _notification_clicked(self): + files = self._notify_files + if files: + target = files[0] if len(files) == 1 else os.path.dirname(files[0]) + QDesktopServices.openUrl(QUrl.fromLocalFile(target)) + else: + self._show() + + def _integrate(self, fn, on, action): + """Autostart / Explorer menu on or off; a failure is shown and undone.""" + try: + fn(on) + except Exception as e: # noqa: BLE001 + action.blockSignals(True) + action.setChecked(not on) + action.blockSignals(False) + QMessageBox.warning(self, APP, f"Das ging nicht: {e}") + + # ---- other starts and the share menu ------------------------------------- + + def handle_message(self, msg): + """From a later start (InstanceServer): files to send, or show.""" + files = msg.get("files") or [] + if files or msg.get("send"): + self.open_send_dialog(files) + elif msg.get("show"): + self._show() + + def open_send_dialog(self, files): + """'An wen senden?' for these files; Explorer passes files one call + at a time, so they join a dialog that has not started sending yet.""" + for d in self._send_dialogs: + if d.transfer_id is None and d.add_files(files): + d.show() + d.raise_() + d.activateWindow() + return d + d = SendDialog(self, files) + self._send_dialogs.append(d) + d.show() + d.raise_() + d.activateWindow() + if not files: + QTimer.singleShot(0, d._choose_files) + return d + + def _send_dialog_closed(self, d): + if d in self._send_dialogs: + self._send_dialogs.remove(d) + if not self._send_dialogs and self.session.browsing and not self.isVisible(): + run_bg(self.session.stop_browse) + # ---- window life ----------------------------------------------------------- def _show(self): @@ -365,6 +684,8 @@ def quit(self): def _shutdown(self): self.clock.stop() + for d in list(self._send_dialogs) + list(self._ask_dialogs): + d.close() self.session.stop() if self.tray: self.tray.hide() @@ -386,6 +707,9 @@ def _on_event(self, kind, info): self._set_device_ui(False) elif state == "stopped": self.device_label.setText("beendet") + if state in ("connected", "lost"): + for d in list(self._send_dialogs): + d.on_device(state == "connected") elif kind == "power": self.power_label.setText(power_text(info.get("text", ""))) elif kind == "browse": @@ -393,15 +717,31 @@ def _on_event(self, kind, info): self.search_btn.setText("Suche läuft …" if info["on"] else "Geräte suchen") elif kind == "receiver": self._receiver_item(info["receiver"]) + for d in list(self._send_dialogs): + d.on_receiver(info["receiver"]) elif kind == "receiving": self._receiving_ui(info["on"]) elif kind == "send": self._send_row(info) + for d in list(self._send_dialogs): + d.on_send(info) + self._send_notice(info) elif kind == "receive": self._receive_row(info) elif kind == "log": self._log(info.get("text", "")) + def _send_notice(self, info): + """In the background nobody sees the dialog close: say how it went.""" + if self.isVisible() or self._send_dialogs: + return + t, state = info["transfer"], info["state"] + if state == "done": + self._notify(f"Gesendet an {t.receiver.label}") + elif state in ("declined", "failed"): + self._notify(f"Nicht gesendet an {t.receiver.label}: " + f"{'abgelehnt' if state == 'declined' else info.get('error')}") + def _log(self, text): self.log.appendPlainText(f"{time.strftime('%H:%M:%S')} {text}") @@ -446,13 +786,19 @@ def _receiving_ui(self, on): self.tray_receive.setText("Empfangen beenden" if on else "Empfangsbereit machen") def _on_ask(self, ask, reply): - self._show() - if self.tray: - self.tray.showMessage(APP, f"{ask.sender} möchte etwas senden", - QSystemTrayIcon.Information, 5000) - dlg = AskDialog(self, ask, reply, self.bridge.ask_timeout) + # Hidden (autostart, tray): only the question comes up, on its own; + # the window stays where it is. + hidden = not self.isVisible() + self._notify(f"{ask.sender} möchte etwas senden") + dlg = AskDialog(None if hidden else self, ask, reply, self.bridge.ask_timeout) + if hidden: + dlg.setWindowIcon(app_icon()) + self._ask_dialogs.append(dlg) + dlg.finished.connect(lambda _r, d=dlg: self._ask_dialogs.remove(d) + if d in self._ask_dialogs else None) dlg.show() dlg.raise_() + dlg.activateWindow() self._last_ask_dialog = dlg # for tests def _receive_row(self, info): @@ -490,9 +836,7 @@ def _receive_row(self, info): item.setText(3, f"empfangen ({human(info.get('size', 0))})") item.setData(0, Qt.UserRole, files) item.setText(2, ", ".join(os.path.basename(f) for f in files)) - if self.tray: - self.tray.showMessage(APP, f"Empfangen: {item.text(2)}", - QSystemTrayIcon.Information, 5000) + self._notify(f"Empfangen: {item.text(2)} – klicken zum Öffnen", files) else: item.setText(3, "unvollständig") item.setData(0, Qt.UserRole, files + ([info["raw"]] if info.get("raw") else [])) @@ -658,10 +1002,23 @@ def default_download_dir(): return os.path.join(downloads if os.path.isdir(downloads) else home, "EspDrop") -def main(port=None): +def main(port=None, background=False, send=None): + """send: None (normal start) or the files of --send (may be empty).""" app = QApplication.instance() or QApplication(sys.argv) app.setApplicationName(APP) app.setQuitOnLastWindowClosed(False) - w = MainWindow(port=port) - w.show() - return app.exec() + files = [os.path.abspath(f) for f in (send or [])] + # Already running (autostart, an earlier start)? Hand over and leave. + if send_to_running({"files": files, "send": send is not None, + "show": send is None and not background}): + return 0 + server = InstanceServer() + w = MainWindow(port=port, background=background) + server.message.connect(w.handle_message) + if send is not None: + w.open_send_dialog(files) + elif not background or not w.tray: # no tray to live in: show the window + w.show() + rc = app.exec() + server.close() + return rc diff --git a/host/espdrop/integration.py b/host/espdrop/integration.py new file mode 100644 index 0000000..4580dba --- /dev/null +++ b/host/espdrop/integration.py @@ -0,0 +1,236 @@ +# EspDrop - hooks into the desktop: start at login, send from the file manager. +# +# Windows (per user, no admin rights): +# - autostart: a value in HKCU\Software\Microsoft\Windows\CurrentVersion\Run +# - "Senden an": a shortcut in %APPDATA%\Microsoft\Windows\SendTo (all +# selected files in one call), and "Mit EspDrop senden" in the classic +# right-click menu (HKCU\Software\Classes\*\shell; one call per file, the +# running app collects them). On Windows 11 both sit under "Weitere +# Optionen anzeigen". The modern share sheet ("Teilen") only lists apps +# with package identity (MSIX); that comes with a packaged build. +# Linux (XDG): +# - autostart: ~/.config/autostart/espdrop.desktop +# - "Öffnen mit": ~/.local/share/applications/espdrop-send.desktop +# +# Each entry starts the app as `espdrop gui ...` with the interpreter and +# checkout it runs from now; the app forwards to an already running copy +# (gui.py, single instance), so a second launch never fights for the port. +# +# SPDX-License-Identifier: GPL-3.0-or-later +# Copyright (C) 2026 EspDrop contributors + +import os +import subprocess +import sys + +IS_WINDOWS = os.name == "nt" +try: + import winreg # Windows only +except ImportError: + winreg = None + +APP = "EspDrop" +RUN_KEY = r"Software\Microsoft\Windows\CurrentVersion\Run" +MENU_KEY = r"Software\Classes\*\shell\EspDrop" +SENDTO_NAME = "EspDrop (AirDrop).lnk" +MENU_TEXT = "Mit EspDrop senden" + + +def launch_command(*args): + """argv that starts the app with these arguments, as it runs now: the + venv's pythonw on Windows (no console window), tools/espdrop.py of this + checkout; a frozen build starts itself.""" + if getattr(sys, "frozen", False): + return [sys.executable, "gui", *args] + exe = sys.executable + if IS_WINDOWS: + w = os.path.join(os.path.dirname(exe), "pythonw.exe") + if os.path.isfile(w): + exe = w + root = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + return [exe, os.path.join(root, "tools", "espdrop.py"), "gui", *args] + + +def supported(): + """What this system offers: {"autostart": bool, "send": label or None}.""" + if IS_WINDOWS: + return {"autostart": winreg is not None, "send": "Im Explorer-Menü anbieten " + "(„Senden an“)"} + if sys.platform.startswith("linux"): + return {"autostart": True, "send": "Im Dateimanager anbieten („Öffnen mit“)"} + return {"autostart": False, "send": None} + + +# ---- autostart --------------------------------------------------------------- + +def autostart_enabled(): + if IS_WINDOWS: + return _reg_get(RUN_KEY, APP) is not None + return os.path.isfile(_xdg_autostart()) + + +def set_autostart(on): + """Start the app hidden (tray, receiving) at login, or not.""" + cmd = launch_command("--background") + if IS_WINDOWS: + if on: + _reg_set(RUN_KEY, APP, subprocess.list2cmdline(cmd)) + else: + _reg_delete_value(RUN_KEY, APP) + return + path = _xdg_autostart() + if on: + _write_desktop(path, { + "Type": "Application", "Name": APP, + "Comment": "AirDrop über den ESP32 – empfangsbereit im Hintergrund", + "Exec": _exec_line(cmd), "Terminal": "false", + "X-GNOME-Autostart-enabled": "true"}) + elif os.path.exists(path): + os.remove(path) + + +# ---- sending from the file manager ------------------------------------------- + +def send_menu_enabled(): + if IS_WINDOWS: + return os.path.isfile(_sendto_path()) or _reg_get(MENU_KEY, "") is not None + return os.path.isfile(_xdg_send()) + + +def set_send_menu(on): + if IS_WINDOWS: + _set_windows_send(on) + return + path = _xdg_send() + if on: + _write_desktop(path, { + "Type": "Application", "Name": "AirDrop (EspDrop)", "Icon": "document-send", + "Comment": "Dateien per AirDrop an ein iPhone/iPad senden", + "Exec": _exec_line(launch_command("--send")) + " %F", + "Terminal": "false", "NoDisplay": "true", + # Common types; "Open with other application" lists the app for any. + "MimeType": ";".join(LINUX_MIME) + ";"}) + elif os.path.exists(path): + os.remove(path) + _refresh_desktop_db(os.path.dirname(path)) + + +LINUX_MIME = ["application/octet-stream", "application/pdf", "application/zip", + "text/plain", "image/jpeg", "image/png", "image/gif", "image/heic", + "image/webp", "video/mp4", "video/quicktime", "audio/mpeg"] + + +def _set_windows_send(on): + cmd = launch_command("--send") + lnk = _sendto_path() + if on: + # Explorer appends the selected files to the shortcut's arguments. + # WScript.Shell writes the .lnk; the values go in through the + # environment, so no quoting of paths inside PowerShell code. + env = dict(os.environ, ESPDROP_LNK=lnk, ESPDROP_EXE=cmd[0], + ESPDROP_ARGS=subprocess.list2cmdline(cmd[1:]), + ESPDROP_DIR=os.path.dirname(cmd[1]) if len(cmd) > 1 else "") + script = ("$s = (New-Object -ComObject WScript.Shell).CreateShortcut($env:ESPDROP_LNK);" + "$s.TargetPath = $env:ESPDROP_EXE; $s.Arguments = $env:ESPDROP_ARGS;" + "$s.WorkingDirectory = $env:ESPDROP_DIR;" + "$s.Description = 'Per AirDrop senden (EspDrop)'; $s.Save()") + os.makedirs(os.path.dirname(lnk), exist_ok=True) + subprocess.run(["powershell", "-NoProfile", "-NonInteractive", "-Command", script], + env=env, check=True, capture_output=True, + creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0)) + _reg_set(MENU_KEY, "", MENU_TEXT) + _reg_set(MENU_KEY, "Icon", cmd[0]) + _reg_set(MENU_KEY + r"\command", "", subprocess.list2cmdline(cmd) + ' "%1"') + else: + if os.path.exists(lnk): + os.remove(lnk) + _reg_delete_tree(MENU_KEY) + + +def _sendto_path(): + appdata = os.environ.get("APPDATA") or os.path.expanduser(r"~\AppData\Roaming") + return os.path.join(appdata, "Microsoft", "Windows", "SendTo", SENDTO_NAME) + + +# ---- registry (HKCU only) ------------------------------------------------------ + +def _reg_get(key, name): + try: + with winreg.OpenKey(winreg.HKEY_CURRENT_USER, key) as k: + return winreg.QueryValueEx(k, name)[0] + except OSError: + return None + + +def _reg_set(key, name, value): + with winreg.CreateKey(winreg.HKEY_CURRENT_USER, key) as k: + winreg.SetValueEx(k, name, 0, winreg.REG_SZ, value) + + +def _reg_delete_value(key, name): + try: + with winreg.OpenKey(winreg.HKEY_CURRENT_USER, key, 0, winreg.KEY_SET_VALUE) as k: + winreg.DeleteValue(k, name) + except OSError: + pass + + +def _reg_delete_tree(key): + """Delete key and its subkeys (DeleteKey only removes empty keys).""" + try: + with winreg.OpenKey(winreg.HKEY_CURRENT_USER, key) as k: + subs = [] + i = 0 + while True: + try: + subs.append(winreg.EnumKey(k, i)) + except OSError: + break + i += 1 + for sub in subs: + _reg_delete_tree(key + "\\" + sub) + winreg.DeleteKey(winreg.HKEY_CURRENT_USER, key) + except OSError: + pass + + +# ---- XDG ------------------------------------------------------------------------ + +def _xdg_autostart(): + base = os.environ.get("XDG_CONFIG_HOME") or os.path.expanduser("~/.config") + return os.path.join(base, "autostart", "espdrop.desktop") + + +def _xdg_send(): + base = os.environ.get("XDG_DATA_HOME") or os.path.expanduser("~/.local/share") + return os.path.join(base, "applications", "espdrop-send.desktop") + + +def _exec_quote(arg): + """One argument for a desktop entry's Exec line (freedesktop spec).""" + if arg and not any(c in arg for c in ' \t\n"\'\\><~|&;$*?#()`'): + return arg + return '"' + "".join("\\" + c if c in '"`$\\' else c for c in arg) + '"' + + +def _exec_line(argv): + return " ".join(_exec_quote(a) for a in argv).replace("%", "%%") + + +def _write_desktop(path, fields): + os.makedirs(os.path.dirname(path), exist_ok=True) + tmp = path + ".tmp" + with open(tmp, "w", encoding="utf-8") as f: + f.write("[Desktop Entry]\n") + for k, v in fields.items(): + f.write(f"{k}={v}\n") + os.replace(tmp, path) + + +def _refresh_desktop_db(directory): + """File managers find "Open with" apps through mimeinfo.cache.""" + try: + subprocess.run(["update-desktop-database", directory], capture_output=True, + timeout=10) + except (OSError, subprocess.SubprocessError): + pass diff --git a/host/tests/test_gui.py b/host/tests/test_gui.py index 663de55..bc39cb4 100644 --- a/host/tests/test_gui.py +++ b/host/tests/test_gui.py @@ -83,7 +83,7 @@ def pump(self, cond, secs=15.0): time.sleep(0.02) return cond() - def window(self, fake): + def window(self, fake, background=False): from espdrop.session import Session def factory(w): @@ -91,8 +91,9 @@ def factory(w): download_dir=self.dl, on_event=w.bridge.on_event, on_ask=w.bridge.on_ask, ask_timeout=w.bridge.ask_timeout) self.w = self.gui.MainWindow(settings=self.settings, session_factory=factory, - ask_timeout=20.0, tray=False) - self.w.show() + ask_timeout=20.0, tray=False, background=background) + if not background: + self.w.show() self.assertTrue(self.pump(lambda: self.w.device_label.text().startswith("ESP verbunden")), self.w.device_label.text()) return self.w @@ -161,6 +162,76 @@ def test_declined_in_the_dialog(self): self.assertEqual(os.listdir(self.dl), []) self.assertEqual(w.transfers.topLevelItemCount(), 0) + def test_background_shows_only_the_question(self): + # Autostart: no window, receiving until switched off; an incoming + # transfer brings up its dialog on its own, the window stays hidden. + fake = DuplexBridgeDevice() + w = self.window(fake, background=True) + self.assertFalse(w.isVisible()) + self.assertTrue(self.pump(lambda: fake.listening == 8771)) + self.assertTrue(w.session.receiving) + self.assertIsNone(w.session.receive_until) # dauerhaft + src = self.file("hintergrund.jpg", b"arrives while hidden " * 200) + result = {} + th = threading.Thread(target=lambda: result.setdefault("ok", incoming(fake, src))) + th.start() + self.assertTrue(self.pump(lambda: getattr(w, "_last_ask_dialog", None) is not None)) + dlg = w._last_ask_dialog + self.assertTrue(dlg.isVisible()) + self.assertIsNone(dlg.parent()) # its own window + self.assertFalse(w.isVisible()) + dlg.yes.click() + self.assertTrue(self.pump(lambda: not th.is_alive(), 20)) + self.assertTrue(result.get("ok")) + self.assertTrue(self.pump(lambda: w._notify_files is not None)) + self.assertEqual([os.path.basename(f) for f in w._notify_files], ["hintergrund.jpg"]) + self.assertFalse(w.isVisible()) + + def test_send_dialog_from_the_share_menu(self): + from loopback_receiver import LoopbackReceiver + pad = os.path.join(self.tmp.name, "pad") + os.makedirs(pad) + rx = LoopbackReceiver(name="TestPad") + port = rx.start() + try: + fake = DuplexBridgeDevice(target=("127.0.0.1", port), mdns_port=port, + answers={1: ["small"]}) + w = self.window(fake, background=True) + a = self.file("a.txt", b"first " * 100) + b = self.file("b.txt", b"second " * 100) + # Explorer's context menu starts one copy per file: both land in + # the same dialog. + w.handle_message({"files": [a], "send": True}) + w.handle_message({"files": [b], "send": True}) + (d,) = w._send_dialogs + self.assertEqual([os.path.basename(f) for f in d.files], ["a.txt", "b.txt"]) + self.assertTrue(self.pump(lambda: d.list.count() == 1 + and "TestPad" in d.list.item(0).text())) + os.chdir(pad) # the loopback receiver writes into cwd + d._pick(d.list.item(0)) + self.assertIsNotNone(d.transfer_id) + self.assertTrue(self.pump(lambda: d not in w._send_dialogs, 20), + d.status.text()) + self.assertFalse(w.isVisible()) + finally: + rx.stop() + self.assertEqual(sorted(f for f in os.listdir(pad) if f.endswith(".txt")), + ["a.txt", "b.txt"]) + + def test_single_instance(self): + name = f"espdrop-test-{os.getpid()}-{time.monotonic_ns()}" + self.assertFalse(self.gui.send_to_running({"show": True}, name=name, timeout_ms=200)) + server = self.gui.InstanceServer(name) + got = [] + server.message.connect(got.append) + try: + msg = {"files": ["/tmp/x y.jpg"], "send": True, "show": False} + self.assertTrue(self.gui.send_to_running(msg, name=name)) + self.assertTrue(self.pump(lambda: got, 5)) + self.assertEqual(got, [msg]) + finally: + server.close() + def test_power_text(self): t = self.gui.power_text self.assertEqual(t("power auto asleep (none) hold=0s wake=0s"), "Funk aus (spart Strom)") diff --git a/host/tests/test_integration.py b/host/tests/test_integration.py new file mode 100644 index 0000000..e887922 --- /dev/null +++ b/host/tests/test_integration.py @@ -0,0 +1,182 @@ +# EspDrop tests - autostart and "send from the file manager" (integration.py): +# the XDG files for real in temporary folders, the Windows registry and the +# "Senden an" shortcut against a stand-in winreg and a recorded PowerShell call. +# +# ~/opendrop-venv/bin/python -m unittest discover -s host/tests +# +# SPDX-License-Identifier: GPL-3.0-or-later +# Copyright (C) 2026 EspDrop contributors + +import os +import subprocess +import sys +import tempfile +import unittest +from unittest import mock + +HERE = os.path.dirname(os.path.abspath(__file__)) +sys.path.insert(0, os.path.join(HERE, "..")) + +from espdrop import integration # noqa: E402 + + +class FakeWinreg: + """The bits of winreg integration.py uses, on a dict: key -> {name: value}.""" + HKEY_CURRENT_USER = "HKCU" + REG_SZ = 1 + KEY_SET_VALUE = 2 + + def __init__(self): + self.keys = {} + + class _Handle: + def __init__(self, path): + self.path = path + + def __enter__(self): + return self + + def __exit__(self, *exc): + return False + + def OpenKey(self, root, key, reserved=0, access=0): + if key not in self.keys: + raise FileNotFoundError(key) + return self._Handle(key) + + def CreateKey(self, root, key): + parts = key.split("\\") + for i in range(1, len(parts) + 1): + self.keys.setdefault("\\".join(parts[:i]), {}) + return self._Handle(key) + + def QueryValueEx(self, h, name): + if name not in self.keys[h.path]: + raise FileNotFoundError(name) + return self.keys[h.path][name], self.REG_SZ + + def SetValueEx(self, h, name, reserved, kind, value): + self.keys[h.path][name] = value + + def DeleteValue(self, h, name): + if name not in self.keys[h.path]: + raise FileNotFoundError(name) + del self.keys[h.path][name] + + def _children(self, key): + return [k.rsplit("\\", 1)[1] for k in self.keys + if k.startswith(key + "\\") and "\\" not in k[len(key) + 1:]] + + def EnumKey(self, h, i): + kids = self._children(h.path) + if i >= len(kids): + raise OSError("no more") + return kids[i] + + def DeleteKey(self, root, key): + if key not in self.keys: + raise FileNotFoundError(key) + if self._children(key): + raise PermissionError("has subkeys") + del self.keys[key] + + +class LinuxTest(unittest.TestCase): + def setUp(self): + self.tmp = tempfile.TemporaryDirectory() + env = {"XDG_CONFIG_HOME": os.path.join(self.tmp.name, "config"), + "XDG_DATA_HOME": os.path.join(self.tmp.name, "data")} + self.patches = [mock.patch.dict(os.environ, env), + mock.patch.object(integration, "IS_WINDOWS", False)] + for p in self.patches: + p.start() + + def tearDown(self): + for p in self.patches: + p.stop() + self.tmp.cleanup() + + def read(self, path): + with open(path, encoding="utf-8") as f: + return dict(line.rstrip("\n").split("=", 1) for line in f if "=" in line) + + def test_autostart_starts_in_the_background(self): + self.assertFalse(integration.autostart_enabled()) + integration.set_autostart(True) + self.assertTrue(integration.autostart_enabled()) + path = os.path.join(self.tmp.name, "config", "autostart", "espdrop.desktop") + entry = self.read(path) + self.assertTrue(entry["Exec"].endswith(" gui --background"), entry["Exec"]) + self.assertIn(os.path.join("tools", "espdrop.py"), entry["Exec"]) + integration.set_autostart(False) + self.assertFalse(integration.autostart_enabled()) + self.assertFalse(os.path.exists(path)) + + def test_open_with_entry(self): + integration.set_send_menu(True) + self.assertTrue(integration.send_menu_enabled()) + entry = self.read(os.path.join(self.tmp.name, "data", "applications", + "espdrop-send.desktop")) + self.assertTrue(entry["Exec"].endswith(" gui --send %F"), entry["Exec"]) + self.assertIn("image/jpeg;", entry["MimeType"]) + integration.set_send_menu(False) + self.assertFalse(integration.send_menu_enabled()) + + def test_exec_quoting(self): + line = integration._exec_line(["/opt/my venv/bin/python", "/x/50%.py", "gui"]) + self.assertEqual(line, '"/opt/my venv/bin/python" /x/50%%.py gui') + + +class WindowsTest(unittest.TestCase): + def setUp(self): + self.tmp = tempfile.TemporaryDirectory() + self.reg = FakeWinreg() + self.runs = [] + + def run(argv, **kw): + self.runs.append((argv, kw)) + return subprocess.CompletedProcess(argv, 0, b"", b"") + self.patches = [mock.patch.object(integration, "IS_WINDOWS", True), + mock.patch.object(integration, "winreg", self.reg), + mock.patch.dict(os.environ, {"APPDATA": self.tmp.name}), + mock.patch.object(integration.subprocess, "run", run)] + for p in self.patches: + p.start() + + def tearDown(self): + for p in self.patches: + p.stop() + self.tmp.cleanup() + + def test_autostart_run_key(self): + self.assertFalse(integration.autostart_enabled()) + integration.set_autostart(True) + value = self.reg.keys[integration.RUN_KEY]["EspDrop"] + self.assertTrue(value.endswith("gui --background"), value) + self.assertTrue(integration.autostart_enabled()) + integration.set_autostart(False) + self.assertNotIn("EspDrop", self.reg.keys[integration.RUN_KEY]) + self.assertFalse(integration.autostart_enabled()) + + def test_send_to_and_context_menu(self): + integration.set_send_menu(True) + (argv, kw), = self.runs # one PowerShell call for the .lnk + self.assertEqual(argv[0], "powershell") + env = kw["env"] + self.assertEqual(env["ESPDROP_LNK"], os.path.join( + self.tmp.name, "Microsoft", "Windows", "SendTo", "EspDrop (AirDrop).lnk")) + self.assertTrue(env["ESPDROP_ARGS"].endswith("gui --send"), env["ESPDROP_ARGS"]) + self.assertNotIn(env["ESPDROP_LNK"], argv[-1]) # values via the environment only + menu = self.reg.keys[integration.MENU_KEY] + self.assertEqual(menu[""], "Mit EspDrop senden") + cmd = self.reg.keys[integration.MENU_KEY + r"\command"][""] + self.assertTrue(cmd.endswith('gui --send "%1"'), cmd) + self.assertTrue(integration.send_menu_enabled()) + integration.set_send_menu(False) + self.assertNotIn(integration.MENU_KEY, self.reg.keys) + self.assertNotIn(integration.MENU_KEY + r"\command", self.reg.keys) + self.assertFalse(integration.send_menu_enabled()) + + +if __name__ == "__main__": + unittest.main() From 67948fc55a6453d2355eeb37fc9ce5ca3beb5bc3 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 29 Oct 2026 19:34:13 +0000 Subject: [PATCH 82/99] feat(gui): redesign in Apple's look MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The desktop app now looks like Apple's own apps instead of stock Qt: - ui.py, a small design system drawn in code (no image or font files): light and dark colours that follow the system, the system UI face (Segoe UI Variable on Windows 11, Inter or the desktop font on Linux), a style sheet for buttons, fields, menus and scroll bars, and the controls Qt has no Apple-shaped version of: switch, segmented control, round device avatars (outline of iPhone/iPad/Mac or initials) with a ring for waiting and progress, a radar while searching, a thin progress line, rounded cards with inset separators, line icons, our own mark. - The window is one column of cards: AirDrop ("Empfangen von: Aus / 10 Minuten / Für alle" as on the iPhone, name, folder), devices nearby as avatars to click or drop files on, transfers with progress, cancel and show in folder, and Allgemein with switches for autostart and the right-click menu; port, log and quit sit behind the "…" button. - Incoming transfers ask in a floating card with the picture ("„Bens iPhone“ möchte ein Foto teilen."), a countdown line and Ablehnen / Annehmen, with no default button so Enter accepts nothing by accident. - Sending from the file manager opens an AirDrop sheet: the files on top, the devices as avatars, one click sends. Same behaviour and session underneath; 59 host tests (the GUI ones drive the new controls offscreen). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01XzgaN4Bv1m8Ka9iqo7QVUN --- docs/SPIKES.md | 50 +- host/README.md | 49 +- host/espdrop/gui.py | 982 ++++++++++++++------------ host/espdrop/integration.py | 13 +- host/espdrop/ui.py | 1321 +++++++++++++++++++++++++++++++++++ host/tests/test_gui.py | 83 ++- 6 files changed, 1987 insertions(+), 511 deletions(-) create mode 100644 host/espdrop/ui.py diff --git a/docs/SPIKES.md b/docs/SPIKES.md index cf0b785..eece9b0 100644 --- a/docs/SPIKES.md +++ b/docs/SPIKES.md @@ -559,8 +559,8 @@ Oberfläche. Neu dabei: `/Discover` („Bens iPad“), nicht nur mit der Kennung. Im Fenster wählen oder Dateien auf ein Gerät ziehen; `espdrop send -r NAME` (Name, Teil davon oder Kennung), bei mehreren ohne `-r` eine Liste zum Auswählen. -- **Empfangsfenster** statt dauernd empfangsbereit (10 Minuten, 1 Stunde oder - dauerhaft), passend zum Schlafmodus der Firmware. Kann der ESP sich selbst +- **Empfangsfenster** statt dauernd empfangsbereit („Empfangen von: Aus / + 10 Minuten / Für alle“, wie am iPhone), passend zum Schlafmodus der Firmware. Kann der ESP sich selbst wecken (`power listen`, siehe oben), schläft das WLAN auch während des Fensters und wacht erst auf, wenn ein Gerät in der Nähe teilen will; rechts oben steht dann „Funk schläft (empfangsbereit)“. @@ -572,41 +572,51 @@ ergänzt `python3 tools/opendrop_install.py` (nur was fehlt) oder `~/opendrop-venv/bin/pip install PySide6`. Test auf der Hardware (Firmware mit Schlafmodus geflasht): -1. App starten: oben „ESP verbunden“, rechts „Funk aus (spart Strom)“. -2. „Geräte suchen“, am iPad Teilen-Menü öffnen: das iPad erscheint mit Namen. - Eine Datei darauf ziehen, am iPad annehmen: „gesendet“. -3. „Empfangsbereit machen“: rechts oben „Funk schläft (empfangsbereit)“ (der +1. App starten: unter dem Titel „ESP verbunden · Funk aus (spart Strom)“. +2. „Suchen“, am iPad Teilen-Menü öffnen: das iPad erscheint als rundes Bild mit + Namen. + Eine Datei darauf ziehen, am iPad annehmen: der Ring um das Bild zeigt das + Warten und den Fortschritt, danach „Gesendet“. +3. „Empfangen von: 10 Minuten“: unter dem Titel „Funk schläft (empfangsbereit)“ (der ESP lauscht per Bluetooth). Am iPad etwas teilen: der ESP wacht von selbst - auf, „MeinPC“ erscheint am iPad, wählen; der Dialog fragt, „Annehmen“, die + auf, „MeinPC“ erscheint am iPad, wählen; die Annehmen-Karte fragt, „Annehmen“, die Datei liegt in `Downloads/EspDrop`. Einmal „Ablehnen“: das iPad meldet die Ablehnung. 4. Fenster schließen (läuft im Tray weiter), ESP ab- und anstecken: die App verbindet sich wieder. 5. Nach dem Ende des Empfangsfensters und ohne Suche: „Funk aus“, der ESP bleibt kühl. -6. Menü EspDrop → „Beim Anmelden im Hintergrund starten“, abmelden und wieder +6. Allgemein → „Beim Anmelden im Hintergrund starten“, abmelden und wieder anmelden: kein Fenster, nur das Tray-Symbol; die App ist dauerhaft empfangsbereit (bei Firmware mit `power listen` schläft der Funk dabei). - Vom iPad etwas senden: nur der Annehmen-Dialog erscheint, danach eine + Vom iPad etwas senden: nur die Annehmen-Karte erscheint, danach eine Benachrichtigung „Empfangen: …“ (Klick öffnet die Datei). -7. Menü EspDrop → „Im Explorer-Menü anbieten“. Im Explorer Dateien markieren, - Rechtsklick → „Senden an“ → „EspDrop (AirDrop)“ (Windows 11: erst - „Weitere Optionen anzeigen“) oder „Mit EspDrop senden“: „An wen senden?“ - öffnet sich, das iPad erscheint, anklicken, am iPad annehmen; der Dialog - schließt sich nach „Gesendet an …“. - -Windows' eigenes Teilen-Fenster („Teilen“) zeigt nur Apps mit Paket-Identität -(MSIX). Dorthin kommt EspDrop mit der späteren paketierten Version; bis dahin -sind „Senden an“ und das Rechtsklick-Menü der Weg aus dem Explorer. +7. Allgemein → „Im Rechtsklickmenü des Explorers anbieten“. Im Explorer + Dateien markieren, Rechtsklick → „Senden an“ → „EspDrop (AirDrop)“ + (Windows 11: erst „Weitere Optionen anzeigen“) oder „Mit EspDrop senden“: + das AirDrop-Blatt öffnet sich, das iPad erscheint, anklicken, am iPad + annehmen; das Blatt schließt sich nach „Gesendet an …“. + +Aus dem Explorer wird über das Rechtsklick-Menü gesendet („Senden an“ und „Mit +EspDrop senden“); so bleibt es auch, Windows' eigenes Teilen-Fenster ist nicht +nötig. + +Aussehen: Die App ist im Stil von Apples eigenen Apps gezeichnet +(`host/espdrop/ui.py`): helle bzw. dunkle Oberfläche nach Systemeinstellung, +abgerundete Karten wie in den macOS-Systemeinstellungen, Schalter und +Segment-Steuerung, Geräte als runde Bilder mit Fortschrittsring, „Radar“ +während der Suche, eine schwebende Karte zum Annehmen und ein AirDrop-Blatt +zum Senden. Ohne Bild- oder Schriftdateien; die Schrift ist die des Systems +(Windows 11: Segoe UI Variable). Tests ohne Hardware: `test_session.py` (Suche mit Namen, Senden mit Fortschritt und Abbrechen, Empfangen mit Annehmen/Ablehnen/Zeitüberschreitung, Empfangsfenster, `power listen` bei scan-fähiger Firmware, Neuverbinden) und `test_gui.py` (das Fenster offscreen: suchen und senden, Dialog annehmen und -ablehnen, Hintergrundbetrieb, „An wen senden?“ mit Dateien aus zwei Aufrufen, +ablehnen, Hintergrundbetrieb, AirDrop-Blatt mit Dateien aus zwei Aufrufen, nur eine laufende App) und `test_integration.py` (Autostart und Dateimanager-Menü: Linux echt in Temp-Ordnern, Windows-Registry und -„Senden an“ nachgebildet). Zusammen 57 Host-Tests. +„Senden an“ nachgebildet). Zusammen 59 Host-Tests. ## Danach diff --git a/host/README.md b/host/README.md index ad4ee08..d1f7628 100644 --- a/host/README.md +++ b/host/README.md @@ -29,30 +29,33 @@ Only one program can own the serial port: stop `tools/awdl_bridge.py` first. ## Desktop app `espdrop gui` (Windows: `.\tools\espdrop-gui.cmd`, no console window) opens -one window that owns the ESP for as long as it runs: - -- **Empfangen:** "Empfangsbereit machen" for 10 minutes, an hour or until - switched off; others see the name set at the top. Every incoming transfer - asks first (sender, files, thumbnail; no answer in 60 s = declined). Files go - to the folder shown (default `Downloads/EspDrop`). -- **Senden:** "Geräte suchen" (60 s, with the ESP's Bluetooth wake) lists the - receivers by name; drop files on one, or pick one and "Dateien senden …". -- **Übertragungen:** progress, cancel and open via the context menu. +one window that owns the ESP for as long as it runs. It is drawn in the look of +Apple's own apps (`espdrop/ui.py`: rounded cards, switches, segmented +controls, round device avatars; light and dark follow the system), with no +image or font files: text uses the system face (Segoe UI Variable on Windows +11, Inter or the desktop font on Linux). + +- **AirDrop:** "Empfangen von: Aus / 10 Minuten / Für alle", as on the iPhone; + the name others see; where received files go (default `Downloads/EspDrop`). + Every incoming transfer asks first, in a floating card (picture, sender, + files; Ablehnen / Annehmen; no answer in 60 s = declined). +- **Geräte in der Nähe:** "Suchen" (60 s, with the ESP's Bluetooth wake) shows + the devices as avatars with their names; drop files on one or click it. A + ring around the avatar shows the wait for Accept and the progress. +- **Übertragungen:** progress, cancel, show in folder. - The ESP's radio is only on while receiving, searching or sending (firmware - with the sleep mode, docs/SPIKES.md); the label top right says which. -- Closing the window keeps it in the tray; "Beenden" ends it. Unplugging the - ESP is fine: it reconnects when it comes back. -- **Autostart:** menu EspDrop → "Beim Anmelden im Hintergrund starten" - (Windows: `HKCU\…\CurrentVersion\Run`; Linux: `~/.config/autostart`). It - then starts as `espdrop gui --background`: tray only, receiving until - switched off, and only an incoming transfer brings up its dialog; a - notification says when files arrived (click: open them). -- **Sending from the file manager:** menu EspDrop → "Im Explorer-Menü - anbieten" adds "Senden an → EspDrop (AirDrop)" and "Mit EspDrop senden" to - the right-click menu (on Windows 11 under "Weitere Optionen anzeigen"); on - Linux "Öffnen mit → AirDrop (EspDrop)". Either opens "An wen senden?": the - devices a search finds, one click sends. Windows' own share sheet ("Teilen") - only lists packaged apps (MSIX); that comes with a packaged build. + with the sleep mode, docs/SPIKES.md); the line under the title says which. +- Closing the window keeps it in the tray; "Beenden" (the "…" button or the + tray) ends it. Unplugging the ESP is fine: it reconnects when it comes back. +- **Allgemein → "Beim Anmelden im Hintergrund starten"** (Windows: + `HKCU\…\CurrentVersion\Run`; Linux: `~/.config/autostart`). It then starts + as `espdrop gui --background`: tray only, visible to everyone until switched + off, and only an incoming transfer brings up its card; a notification says + when files arrived (click: open them). +- **Allgemein → "Im Rechtsklickmenü des Explorers anbieten"** adds "Senden an → + EspDrop (AirDrop)" and "Mit EspDrop senden" (on Windows 11 under "Weitere + Optionen anzeigen"); on Linux "Öffnen mit → AirDrop (EspDrop)". Either opens + the AirDrop sheet: the devices a search finds, one click sends. - **One copy per user:** a second start (autostart, the file manager, the launcher) hands its files to the running app over a local socket and exits. diff --git a/host/espdrop/gui.py b/host/espdrop/gui.py index d16af19..c7b45e7 100644 --- a/host/espdrop/gui.py +++ b/host/espdrop/gui.py @@ -2,17 +2,18 @@ # # espdrop gui [-p PORT] (Windows: .\tools\espdrop-gui.cmd) # espdrop gui --background tray only, receiving; what autostart runs -# espdrop gui --send FILE... "An wen senden?" (Explorer "Senden an") +# espdrop gui --send FILE... the AirDrop sheet (the file manager's right-click menu) # -# One window: the ESP's state, a receive window that closes by itself (like -# Apple's "Everyone for 10 minutes"), the receivers a search found with their -# names, sending by button or by dropping files on a receiver, every transfer -# with its progress, and a dialog that asks before anything is received. It +# In the look of Apple's own apps (ui.py): one column of rounded cards - +# visibility ("Aus / 10 Minuten / Für alle", as on the iPhone), name and +# folder; the devices nearby as round avatars to click or drop files on; the +# transfers; the general settings - a floating card that asks before anything +# is received, and an AirDrop sheet for sending from the file manager. It # keeps running in the tray when the window is closed; in the background it -# only shows itself for an incoming transfer. +# only shows itself for an incoming transfer. Light and dark follow the system. # # One copy per user: a second start hands its files (or "show yourself") to -# the running one over a local socket and exits, so Explorer's "Senden an" and +# the running one over a local socket and exits, so the file manager and # autostart never fight over the serial port. # # Session events arrive on worker threads; a Qt signal carries them to the GUI @@ -30,42 +31,32 @@ import threading import time -from PySide6.QtCore import QObject, QPoint, QSettings, Qt, QTimer, QUrl, Signal -from PySide6.QtGui import (QAction, QColor, QDesktopServices, QFont, QIcon, QPainter, - QPixmap, QPolygon) +from PySide6.QtCore import QObject, QSettings, Qt, QTimer, QUrl, Signal +from PySide6.QtGui import QColor, QDesktopServices, QFont, QGuiApplication, QPixmap from PySide6.QtNetwork import QLocalServer, QLocalSocket -from PySide6.QtWidgets import (QApplication, QComboBox, QDialog, QDialogButtonBox, - QFileDialog, QGroupBox, QHBoxLayout, QHeaderView, QLabel, - QLineEdit, QListWidget, QListWidgetItem, QMainWindow, QMenu, - QMessageBox, QPlainTextEdit, QProgressBar, QPushButton, - QSystemTrayIcon, QTreeWidget, QTreeWidgetItem, QVBoxLayout, - QWidget) - -from . import integration +from PySide6.QtWidgets import (QApplication, QDialog, QFileDialog, QFrame, + QGraphicsDropShadowEffect, QHBoxLayout, QInputDialog, QLabel, + QLineEdit, QMainWindow, QMenu, QMessageBox, QPlainTextEdit, + QScrollArea, QSystemTrayIcon, QToolButton, QVBoxLayout, QWidget) + +from . import integration, ui from .session import Session APP = "EspDrop" -WINDOWS = {"10 Minuten": 600, "1 Stunde": 3600, "dauerhaft": None} - -SEND_STATES = {"new": "wartet", "asking": "wartet auf Annehmen", "uploading": "sendet", - "done": "gesendet", "declined": "abgelehnt", "failed": "fehlgeschlagen", - "cancelled": "abgebrochen"} +# Who may send to us, as AirDrop on the iPhone puts it: label, seconds (0: off, +# None: until switched off). +VISIBILITY = [("Aus", 0), ("10 Minuten", 600), ("Für alle", None)] +SEND_STATES = {"new": "Warten …", "asking": "Warten auf Annahme …", "uploading": "Senden …", + "done": "Gesendet", "declined": "Abgelehnt", "failed": "Fehlgeschlagen", + "cancelled": "Abgebrochen"} +TILE_PHASE = {"new": "waiting", "asking": "waiting", "uploading": "sending", "done": "sent", + "declined": "declined", "failed": "failed", "cancelled": "cancelled"} +PHOTO_EXT = {".jpg", ".jpeg", ".png", ".heic", ".heif", ".gif", ".webp", ".tif", ".tiff", + ".bmp", ".dng"} +VIDEO_EXT = {".mov", ".mp4", ".m4v", ".avi", ".hevc"} -def app_icon(): - """A drawn icon (no image files to ship): a blue tile with a white drop.""" - pm = QPixmap(64, 64) - pm.fill(Qt.transparent) - p = QPainter(pm) - p.setRenderHint(QPainter.Antialiasing) - p.setBrush(QColor("#1f6feb")) - p.setPen(Qt.NoPen) - p.drawRoundedRect(2, 2, 60, 60, 14, 14) - p.setBrush(QColor("white")) - p.drawEllipse(18, 26, 28, 28) - p.drawPolygon(QPolygon([QPoint(32, 8), QPoint(20, 34), QPoint(44, 34)])) - p.end() - return QIcon(pm) +app_icon = ui.app_icon def run_bg(fn, *args, on_error=None): @@ -82,18 +73,37 @@ def run(): def human(n): for unit in ("B", "KB", "MB", "GB"): if n < 1000 or unit == "GB": - return f"{n:.0f} {unit}" if unit == "B" else f"{n:.1f} {unit}" + text = f"{n:.0f} {unit}" if unit == "B" else f"{n:.1f} {unit}" + return text.replace(".", ",") n /= 1000 def _safe(fn, default): - """fn(), or `default` when asking the system fails (a menu still opens).""" + """fn(), or `default` when asking the system fails (the window still opens).""" try: return fn() except Exception: # noqa: BLE001 return default +def _kind(path): + ext = os.path.splitext(path)[1].lower() + return "photo" if ext in PHOTO_EXT else "video" if ext in VIDEO_EXT else "file" + + +def what_text(files, urls=()): + """'ein Foto', '3 Dateien', 'einen Link' - what a transfer carries.""" + if urls and not files: + return "einen Link" if len(urls) == 1 else f"{len(urls)} Links" + n = len(files) + if not n: + return "etwas" + kinds = {_kind(f) for f in files} + one, many = {"photo": ("ein Foto", "Fotos"), "video": ("ein Video", "Videos")}.get( + kinds.pop() if len(kinds) == 1 else "file", ("eine Datei", "Dateien")) + return one if n == 1 else f"{n} {many}" + + # ---- one copy per user ------------------------------------------------------- def instance_name(): @@ -112,7 +122,7 @@ def send_to_running(message, name=None, timeout_ms=1000): sock.write(json.dumps(message).encode("utf-8") + b"\n") sock.waitForBytesWritten(timeout_ms) sock.disconnectFromServer() - if sock.state() != QLocalSocket.UnconnectedState: + if sock.state() != QLocalSocket.LocalSocketState.UnconnectedState: sock.waitForDisconnected(timeout_ms) return True @@ -125,7 +135,7 @@ def __init__(self, name=None): super().__init__() self.name = name or instance_name() self.server = QLocalServer(self) - self.server.setSocketOptions(QLocalServer.UserAccessOption) + self.server.setSocketOptions(QLocalServer.SocketOption.UserAccessOption) if not self.server.listen(self.name): # A socket file left by a copy that crashed (Linux); main() only # gets here when nothing answered on it. @@ -177,192 +187,256 @@ def on_ask(self, ask): return reply["ok"] +# ---- asking before a transfer ------------------------------------------------- + class AskDialog(QDialog): - """'Bens iPhone möchte 2 Dateien senden' - Annehmen / Ablehnen, with a countdown.""" + """A floating card, as AirDrop asks: the picture, '„Bens iPhone“ möchte ein + Foto teilen.', Ablehnen / Annehmen, and a line running out until it is + declined by itself. Enter accepts nothing by accident: no default button.""" def __init__(self, parent, ask, reply, timeout): super().__init__(parent) self.reply = reply + self.timeout = timeout + self._grab = None self.setWindowTitle("AirDrop") - self.setWindowFlag(Qt.WindowStaysOnTopHint) - lay = QVBoxLayout(self) - top = QHBoxLayout() - if ask.icon: - pm = QPixmap() - if pm.loadFromData(ask.icon): - thumb = QLabel() - thumb.setPixmap(pm.scaled(96, 96, Qt.KeepAspectRatio, Qt.SmoothTransformation)) - top.addWidget(thumb) + self.setWindowFlags(Qt.WindowType.Dialog | Qt.WindowType.FramelessWindowHint + | Qt.WindowType.WindowStaysOnTopHint) + self.setAttribute(Qt.WidgetAttribute.WA_TranslucentBackground) + outer = QVBoxLayout(self) + outer.setContentsMargins(28, 20, 28, 36) # room for the shadow + card = QFrame() + card.setObjectName("alert") + shadow = QGraphicsDropShadowEffect(card) + shadow.setBlurRadius(44) + shadow.setOffset(0, 12) + shadow.setColor(QColor(0, 0, 0, ui.T["shadow_alpha"] + 25)) + card.setGraphicsEffect(shadow) + v = QVBoxLayout(card) + v.setContentsMargins(22, 22, 22, 18) + v.setSpacing(8) + pic = QLabel() + pic.setAlignment(Qt.AlignmentFlag.AlignHCenter) + pic.setPixmap(self._preview(ask)) + v.addWidget(pic) + v.addSpacing(4) + v.addWidget(ui.label(f"„{ask.sender}“ möchte {what_text(ask.files, ask.urls)} teilen.", + "headline", wrap=True, center=True)) what = ask.files or ask.urls - noun = ("Link" if len(what) == 1 else "Links") if ask.urls and not ask.files else \ - ("Datei" if len(what) == 1 else "Dateien") - text = QLabel(f"{_esc(ask.sender)} möchte {len(what) or ''} {noun} senden:" - f"
{'
'.join(_esc(w) for w in what[:8])}" - f"{'
…' if len(what) > 8 else ''}") - text.setTextFormat(Qt.RichText) - text.setWordWrap(True) - top.addWidget(text, 1) - lay.addLayout(top) - self.left = QLabel() - lay.addWidget(self.left) - buttons = QDialogButtonBox() - self.yes = buttons.addButton("Annehmen", QDialogButtonBox.AcceptRole) - self.no = buttons.addButton("Ablehnen", QDialogButtonBox.RejectRole) - buttons.accepted.connect(self.accept) - buttons.rejected.connect(self.reject) - lay.addWidget(buttons) + if what: + names = ", ".join(what[:3]) + (f" und {len(what) - 3} weitere" if len(what) > 3 else "") + v.addWidget(ui.label(names, "secondary", wrap=True, center=True)) + v.addSpacing(6) + self.countdown = ui.ThinProgress() + self.countdown.set_value(1.0) + v.addWidget(self.countdown) + self.left = ui.label("", "caption", center=True) + v.addWidget(self.left) + v.addSpacing(2) + row = QHBoxLayout() + row.setSpacing(10) + self.no = ui.button("Ablehnen") + self.yes = ui.button("Annehmen", "primary") + for b in (self.no, self.yes): + b.setAutoDefault(False) + b.setDefault(False) + b.setMinimumHeight(32) + row.addWidget(b, 1) + self.no.clicked.connect(self.reject) + self.yes.clicked.connect(self.accept) + v.addLayout(row) + outer.addWidget(card) + self.setFixedWidth(400) self.end = time.monotonic() + timeout - self.timer = QTimer(self, interval=500, timeout=self._tick) + self.timer = QTimer(self) + self.timer.setInterval(250) + self.timer.timeout.connect(self._tick) self.timer.start() self._tick() self.finished.connect(self._answer) + @staticmethod + def _preview(ask): + pm = QPixmap() + if ask.icon and pm.loadFromData(ask.icon): + return ui.rounded_fit(pm, 260, 170, 12) + if ask.files: + return ui.file_badge(os.path.splitext(ask.files[0])[1], 72) + return ui.app_pixmap(64) + + def showEvent(self, e): + super().showEvent(e) + parent = self.parentWidget() + screen = (parent.screen() if parent and parent.isVisible() + else QGuiApplication.primaryScreen()) + if screen: + g = screen.availableGeometry() + self.adjustSize() + self.move(g.center().x() - self.width() // 2, g.top() + int(g.height() * 0.2)) + + def mousePressEvent(self, e): # no title bar: drag it anywhere + if e.button() == Qt.MouseButton.LeftButton: + self._grab = e.globalPosition().toPoint() - self.frameGeometry().topLeft() + + def mouseMoveEvent(self, e): + if self._grab is not None and e.buttons() & Qt.MouseButton.LeftButton: + self.move(e.globalPosition().toPoint() - self._grab) + + def mouseReleaseEvent(self, e): + self._grab = None + def _tick(self): left = self.end - time.monotonic() if left <= 0: self.reject() return + self.countdown.set_value(left / self.timeout) self.left.setText(f"Ohne Antwort abgelehnt in {left:.0f} s") def _answer(self, result): self.timer.stop() - self.reply["ok"] = result == QDialog.Accepted + self.reply["ok"] = result == QDialog.DialogCode.Accepted self.reply["done"].set() -def _esc(text): - return (str(text).replace("&", "&").replace("<", "<").replace(">", ">")) - - -class ReceiverList(QListWidget): - """The receivers; files dropped on one are sent to it.""" - dropped = Signal(str, list) # receiver id, paths - - def __init__(self): - super().__init__() - self.setAcceptDrops(True) - self.setDropIndicatorShown(True) - - def dragEnterEvent(self, e): - if e.mimeData().hasUrls(): - e.acceptProposedAction() - - def dragMoveEvent(self, e): - item = self.itemAt(e.position().toPoint()) - if e.mimeData().hasUrls() and item is not None and item.data(Qt.UserRole): - self.setCurrentItem(item) - e.acceptProposedAction() - else: - e.ignore() - - def dropEvent(self, e): - item = self.itemAt(e.position().toPoint()) - paths = [u.toLocalFile() for u in e.mimeData().urls() if u.isLocalFile()] - if item is not None and paths: - self.dropped.emit(item.data(Qt.UserRole), paths) - e.acceptProposedAction() - +# ---- the AirDrop sheet ---------------------------------------------------------- class SendDialog(QDialog): - """'An wen senden?' as AirDrop shows it: the files, the devices a search - finds, one click sends. It shows the transfer and closes itself when the - files are through; Abbrechen (or closing it) cancels a running one.""" + """The AirDrop sheet: the files on top, the devices a search finds as + avatars, one click sends. The avatar shows the wait for Accept and the + progress; the sheet closes itself when the files are through. Abbrechen + (or closing it) cancels a running transfer.""" def __init__(self, window, files): super().__init__(None) # top-level: the main window may be hidden self.w = window self.files = [] self.transfer_id = None + self._target = None self._browsing = False - self.setWindowTitle("Per AirDrop senden") - self.setWindowIcon(app_icon()) - self.setWindowFlag(Qt.WindowStaysOnTopHint) - self.setAttribute(Qt.WA_DeleteOnClose) + self.setWindowTitle("AirDrop") + self.setWindowIcon(ui.app_icon()) + self.setWindowFlag(Qt.WindowType.WindowStaysOnTopHint) + self.setAttribute(Qt.WidgetAttribute.WA_DeleteOnClose) + self.resize(480, 470) + self.setMinimumWidth(400) lay = QVBoxLayout(self) - self.files_label = QLabel() - self.files_label.setWordWrap(True) - lay.addWidget(self.files_label) - self.status = QLabel() - self.status.setWordWrap(True) - self.status.setStyleSheet("color: gray") - lay.addWidget(self.status) - self.list = QListWidget() - self.list.itemClicked.connect(self._pick) - lay.addWidget(self.list, 1) - self.bar = QProgressBar() - self.bar.setVisible(False) - lay.addWidget(self.bar) - row = QHBoxLayout() - self.more_btn = QPushButton("Dateien …") + lay.setContentsMargins(20, 18, 20, 18) + lay.setSpacing(10) + head = QHBoxLayout() + head.setSpacing(14) + self.preview = QLabel() + self.preview.setFixedSize(64, 72) + head.addWidget(self.preview, 0, Qt.AlignmentFlag.AlignVCenter) + titles = QVBoxLayout() + titles.setSpacing(2) + titles.addStretch(1) + self.title = ui.ElidedLabel() + self.title.setFont(ui.font(15, QFont.Weight.DemiBold)) + self.subtitle = ui.label("", "secondary") + titles.addWidget(self.title) + titles.addWidget(self.subtitle) + titles.addStretch(1) + head.addLayout(titles, 1) + self.more_btn = ui.button("Hinzufügen …", "link") self.more_btn.clicked.connect(self._choose_files) - cancel = QPushButton("Abbrechen") - cancel.clicked.connect(self.reject) - row.addWidget(self.more_btn) - row.addStretch(1) - row.addWidget(cancel) - lay.addLayout(row) - self.resize(420, 380) + head.addWidget(self.more_btn, 0, Qt.AlignmentFlag.AlignVCenter) + lay.addLayout(head) + lay.addWidget(ui.section("AirDrop")) + card = ui.Card() + body = QWidget() + bv = QVBoxLayout(body) + bv.setContentsMargins(0, 0, 0, 0) + bv.setSpacing(8) + self.radar = ui.Radar() + self.grid = ui.DeviceGrid() + self.grid.clicked.connect(self._pick) + self.grid.dropped.connect(self._dropped) + self.status = ui.label("", "secondary", wrap=True, center=True) + bv.addWidget(self.radar) + bv.addWidget(self.grid) + bv.addWidget(self.status) + card.add(body, margins=(12, 14, 12, 16)) + lay.addWidget(card, 1) + bottom = QHBoxLayout() + bottom.addStretch(1) + self.close_btn = ui.button("Abbrechen") + self.close_btn.clicked.connect(self.reject) + bottom.addWidget(self.close_btn) + lay.addLayout(bottom) self.add_files(files) for r in window.session.receivers(): if r.present: self.on_receiver(r) self.on_device(window.session.state == "connected") + self._update_view() # ---- files ---------------------------------------------------------------- def add_files(self, files): - """More files for this send (Explorer passes them one by one).""" + """More files for this send (Explorer passes them one call at a time).""" if self.transfer_id is not None: return False for f in files: f = os.path.abspath(f) if os.path.isfile(f) and f not in self.files: self.files.append(f) - names = [os.path.basename(f) for f in self.files] - if names: - shown = ", ".join(names[:4]) + (f" (+{len(names) - 4})" if len(names) > 4 else "") + self.preview.setPixmap(ui.files_preview(self.files, 64)) + if self.files: size = sum(os.path.getsize(f) for f in self.files) - self.files_label.setText(f"{len(names)} {'Datei' if len(names) == 1 else 'Dateien'}" - f" ({human(size)}): {_esc(shown)}") + if len(self.files) == 1: + self.title.setText(os.path.basename(self.files[0])) + ext = os.path.splitext(self.files[0])[1].lstrip(".").upper() + self.subtitle.setText(f"{ext} · {human(size)}" if ext else human(size)) + else: + text = what_text(self.files) + self.title.setText(text[0].upper() + text[1:]) + self.subtitle.setText(human(size)) else: - self.files_label.setText("Noch keine Datei gewählt (Ordner gehen noch nicht).") + self.title.setText("Keine Datei gewählt") + self.subtitle.setText("„Hinzufügen …“ wählt Dateien (Ordner gehen noch nicht).") return True def _choose_files(self): - files, _ = QFileDialog.getOpenFileNames(self, "Dateien senden") + files, _ = QFileDialog.getOpenFileNames(self, "Per AirDrop senden") self.add_files(files) # ---- devices -------------------------------------------------------------- + def _update_view(self): + has = self.grid.count() > 0 + self.radar.setVisible(not has) + self.radar.set_running(self._browsing) + self.grid.setVisible(has) + def on_device(self, connected): if not connected: self.status.setText("Verbinde mit dem ESP …") self._browsing = False + self._update_view() return if not self._browsing and self.transfer_id is None: self._browsing = True - self.status.setText("Suche Geräte … Am iPhone/iPad: AirDrop „Für alle“, " - "entsperrt, Teilen-Menü offen.") + self.status.setText("Suche nach Geräten in der Nähe …\nAm iPhone oder iPad: " + "AirDrop „Für alle“, entsperrt, Teilen-Menü offen.") run_bg(self.w.session.browse, 60, on_error=self.w._log) + self._update_view() def on_receiver(self, r): - item = None - for i in range(self.list.count()): - if self.list.item(i).data(Qt.UserRole) == r.id: - item = self.list.item(i) - if item is None: - if not r.present: - return - item = QListWidgetItem() - item.setData(Qt.UserRole, r.id) - self.list.addItem(item) - model = f" ({r.model})" if r.model else "" - item.setText(f"{r.label}{model}" + ("" if r.present else " – nicht mehr da")) - item.setForeground(QColor("black") if r.present else QColor("gray")) - if self.transfer_id is None and self._browsing: - self.status.setText("Auf ein Gerät klicken, um zu senden.") - - def _pick(self, item): + if r.id not in self.grid.tiles and not r.present: + return + tile = self.grid.upsert(r.id, r.label, r.model, r.present, r.discovered) + if self.transfer_id is not None: + tile.setEnabled(r.id == self._target) + elif self._browsing: + self.status.setText("Zum Senden auf ein Gerät klicken.") + self._update_view() + + def _dropped(self, rid, files): + self.add_files(files) + self._pick(rid) + + def _pick(self, rid): if self.transfer_id is not None: return if not self.files: @@ -370,16 +444,17 @@ def _pick(self, item): if not self.files: return try: - t = self.w.session.send(item.data(Qt.UserRole), self.files) + t = self.w.session.send(rid, self.files) except Exception as e: # noqa: BLE001 self.status.setText(f"Senden geht nicht: {e}") return - self.transfer_id = t.id - self.list.setEnabled(False) + self.transfer_id, self._target = t.id, rid + self.grid.set_tiles_enabled(False, except_rid=rid) + tile = self.grid.tiles.get(rid) + if tile: + tile.set_phase("waiting") self.more_btn.setEnabled(False) - self.bar.setVisible(True) - self.bar.setRange(0, 0) - self.status.setText(f"Warte, bis {t.receiver.label} annimmt …") + self.status.setText(f"Warten, bis {t.receiver.label} annimmt …") # ---- the transfer --------------------------------------------------------- @@ -388,40 +463,43 @@ def on_send(self, info): if t.id != self.transfer_id: return state = info["state"] + tile = self.grid.tiles.get(t.receiver.id) + if tile: + tile.set_phase(TILE_PHASE.get(state, "idle"), info.get("progress") or 0.0) if state == "uploading": - self.bar.setRange(0, 1000) - self.bar.setValue(int(1000 * info["progress"])) - self.status.setText(f"Sende an {t.receiver.label} …") + self.status.setText(f"Senden an {t.receiver.label} …") elif state == "done": - self.bar.setRange(0, 1000) - self.bar.setValue(1000) self.status.setText(f"Gesendet an {t.receiver.label}.") self.transfer_id = None + self.close_btn.setText("Fertig") QTimer.singleShot(1500, self.accept) elif state in ("declined", "failed", "cancelled"): text = {"declined": f"{t.receiver.label} hat abgelehnt.", "cancelled": "Abgebrochen."}.get(state, f"Fehlgeschlagen: {info.get('error')}") - self.status.setText(text + " Nochmal: auf ein Gerät klicken.") + self.status.setText(text + " Zum erneuten Versuch auf ein Gerät klicken.") self.transfer_id = None - self.bar.setVisible(False) - self.list.setEnabled(True) + self.grid.set_tiles_enabled(True) self.more_btn.setEnabled(True) def done(self, result): - if result != QDialog.Accepted and self.transfer_id is not None: + if result != QDialog.DialogCode.Accepted and self.transfer_id is not None: run_bg(self.w.session.cancel, self.transfer_id) self.w._send_dialog_closed(self) super().done(result) +# ---- the window ------------------------------------------------------------------- + class MainWindow(QMainWindow): def __init__(self, port=None, settings=None, session_factory=None, ask_timeout=60.0, tray=True, background=False): super().__init__() + ui.ensure(QApplication.instance()) self.settings = settings or QSettings(APP, APP) self.setWindowTitle(APP) - self.setWindowIcon(app_icon()) - self.resize(720, 640) + self.setWindowIcon(ui.app_icon()) + self.resize(560, 800) + self.setMinimumWidth(480) self.port = port or self.settings.value("port", "") or None self.bridge = Bridge(ask_timeout) self.bridge.event.connect(self._on_event) @@ -429,27 +507,30 @@ def __init__(self, port=None, settings=None, session_factory=None, ask_timeout=6 # session_factory(window) -> Session, for tests; it must use window.bridge self._make_session = ((lambda: session_factory(self)) if session_factory else self._default_session) - self._rows = {} # ("send", id) / ("recv", n) -> QTreeWidgetItem + self._rows = {} # ("send", id) / ("recv", n) -> TransferRow self._recv_n = 0 self._recv_row = None # the row of the transfer being received self._quitting = False - self._send_dialogs = [] # open "An wen senden?" dialogs - self._ask_dialogs = [] # open accept dialogs (may have no parent) + self._browsing = False + self._connected = False + self._send_dialogs = [] # open AirDrop sheets + self._ask_dialogs = [] # open accept cards (may have no parent) self._notify_files = None # what clicking the last notification opens self._build() self.tray = self._make_tray() if tray and QSystemTrayIcon.isSystemTrayAvailable() else None self.session = self._make_session() self.session.start() self._update_dir() - self.clock = QTimer(self, interval=1000, timeout=self._tick) + self.clock = QTimer(self) + self.clock.setInterval(1000) + self.clock.timeout.connect(self._tick) self.clock.start() if background: - # Autostart: receive until switched off; the window stays hidden - # and only an incoming transfer shows its dialog. (Not through the - # button: that would also store "dauerhaft" as the user's choice.) - self.window_combo.setCurrentText("dauerhaft") - self._receiving_ui(True) + # Autostart: visible to everyone until switched off; the window + # stays hidden and only an incoming transfer shows its card. + self._receiving_ui(True, None) run_bg(self.session.set_receiving, True, None, on_error=self._log) + self._tick() # ---- setup --------------------------------------------------------------- @@ -460,143 +541,182 @@ def _default_session(self): on_ask=self.bridge.on_ask, ask_timeout=self.bridge.ask_timeout) def _build(self): - root = QWidget() - lay = QVBoxLayout(root) + page = QWidget() + page.setObjectName("page") + col = QVBoxLayout(page) + col.setContentsMargins(24, 22, 24, 24) + col.setSpacing(4) - # device and name + # the app, and whether the ESP is there head = QHBoxLayout() - self.device_label = QLabel("Suche ESP …") - self.power_label = QLabel("") - self.power_label.setStyleSheet("color: gray") - head.addWidget(self.device_label, 1) - head.addWidget(self.power_label) - lay.addLayout(head) - name_row = QHBoxLayout() - name_row.addWidget(QLabel("Name für andere Geräte:")) + head.setSpacing(14) + mark = QLabel() + mark.setPixmap(ui.app_pixmap(56)) + head.addWidget(mark) + titles = QVBoxLayout() + titles.setSpacing(3) + titles.addStretch(1) + titles.addWidget(ui.label(APP, "title")) + status = QHBoxLayout() + status.setSpacing(6) + self.dot = ui.StatusDot() + status.addWidget(self.dot, 0, Qt.AlignmentFlag.AlignVCenter) + self.device_label = ui.label("Suche ESP …", "secondary") + self.power_label = ui.label("", "secondary") + status.addWidget(self.device_label) + status.addWidget(self.power_label) + status.addStretch(1) + titles.addLayout(status) + titles.addStretch(1) + head.addLayout(titles, 1) + self.more_btn = ui.tool_button("more", "Mehr") + self.more_btn.setPopupMode(QToolButton.ToolButtonPopupMode.InstantPopup) + menu = QMenu(self.more_btn) + self.log_action = menu.addAction("Protokoll anzeigen") + self.log_action.setCheckable(True) + menu.addAction("Serieller Port …", self._pick_port) + menu.addSeparator() + menu.addAction("Beenden", self.quit) + self.more_btn.setMenu(menu) + head.addWidget(self.more_btn, 0, Qt.AlignmentFlag.AlignTop) + col.addLayout(head) + + # AirDrop: who may send to us, our name, where files go + col.addWidget(ui.section("AirDrop")) + card = ui.Card() + self.visibility = ui.Segmented([lbl for lbl, _ in VISIBILITY]) + self.visibility.changed.connect(self._visibility_picked) + vis = QWidget() # the segments, and below them how long + vv = QVBoxLayout(vis) + vv.setContentsMargins(0, 0, 0, 0) + vv.setSpacing(4) + vh = QHBoxLayout() + vh.addWidget(QLabel("Empfangen von")) + vh.addStretch(1) + vh.addWidget(self.visibility) + vv.addLayout(vh) + self.receive_left = ui.label("", "caption") + vv.addWidget(self.receive_left) + card.add(vis, margins=(16, 10, 14, 9)) self.name_edit = QLineEdit(self.settings.value("name", "") or APP) + self.name_edit.setFixedWidth(210) self.name_edit.editingFinished.connect(self._name_changed) - name_row.addWidget(self.name_edit, 1) - lay.addLayout(name_row) - - # receiving - rbox = QGroupBox("Empfangen") - rl = QVBoxLayout(rbox) - r1 = QHBoxLayout() - self.receive_btn = QPushButton("Empfangsbereit machen") - self.receive_btn.setCheckable(True) - self.receive_btn.toggled.connect(self._receive_toggled) - self.window_combo = QComboBox() - self.window_combo.addItems(list(WINDOWS)) - self.window_combo.setCurrentText(self.settings.value("window", "10 Minuten")) - self.receive_left = QLabel("aus") - r1.addWidget(self.receive_btn) - r1.addWidget(self.window_combo) - r1.addWidget(self.receive_left, 1) - rl.addLayout(r1) - r2 = QHBoxLayout() - self.dir_label = QLabel() - self.dir_label.setTextInteractionFlags(Qt.TextSelectableByMouse) - pick = QPushButton("Ordner …") - pick.clicked.connect(self._pick_dir) - show = QPushButton("Öffnen") + card.add_row("Name", self.name_edit, "So sehen dich iPhone, iPad und Mac.") + folder = QWidget() + fh = QHBoxLayout(folder) + fh.setContentsMargins(0, 0, 0, 0) + fh.setSpacing(8) + fv = QVBoxLayout() + fv.setSpacing(2) + fv.addWidget(QLabel("Empfangene Dateien")) + self.dir_label = ui.ElidedLabel() + self.dir_label.setProperty("role", "caption") + fv.addWidget(self.dir_label) + fh.addLayout(fv, 1) + change = ui.button("Ändern …", "link") + change.clicked.connect(self._pick_dir) + show = ui.button("Öffnen", "link") show.clicked.connect(lambda: QDesktopServices.openUrl( QUrl.fromLocalFile(self.session.download_dir or os.getcwd()))) - r2.addWidget(QLabel("Ziel:")) - r2.addWidget(self.dir_label, 1) - r2.addWidget(pick) - r2.addWidget(show) - rl.addLayout(r2) - lay.addWidget(rbox) - - # sending - sbox = QGroupBox("Senden") - sl = QVBoxLayout(sbox) - s1 = QHBoxLayout() - self.search_btn = QPushButton("Geräte suchen") + fh.addWidget(change) + fh.addWidget(show) + card.add(folder, margins=(16, 9, 12, 9)) + col.addWidget(card) + + # devices nearby + self.search_btn = ui.button("Suchen") self.search_btn.clicked.connect(self._search) - self.search_label = QLabel("Am iPhone/iPad: AirDrop „Für alle“, entsperrt, " - "Teilen-Menü offen.") - self.search_label.setWordWrap(True) - s1.addWidget(self.search_btn) - s1.addWidget(self.search_label, 1) - sl.addLayout(s1) - self.receivers_list = ReceiverList() - self.receivers_list.dropped.connect(self._send_to) - self.receivers_list.itemDoubleClicked.connect(lambda item: self._send_dialog()) - sl.addWidget(self.receivers_list, 1) - s2 = QHBoxLayout() - self.send_btn = QPushButton("Dateien senden …") - self.send_btn.clicked.connect(self._send_dialog) - s2.addWidget(QLabel("Dateien auf ein Gerät ziehen oder:")) - s2.addStretch(1) - s2.addWidget(self.send_btn) - sl.addLayout(s2) - lay.addWidget(sbox, 2) + col.addWidget(ui.section("Geräte in der Nähe", self.search_btn)) + dcard = ui.Card() + body = QWidget() + bv = QVBoxLayout(body) + bv.setContentsMargins(0, 0, 0, 0) + bv.setSpacing(6) + self.radar = ui.Radar() + self.radar_text = ui.label("Suche nach Geräten in der Nähe …", "secondary", center=True) + self.devices_note = ui.label("„Suchen“ findet AirDrop-Geräte in der Nähe. Am iPhone " + "oder iPad: AirDrop „Für alle“, entsperrt, Teilen-Menü " + "offen.", "secondary", wrap=True, center=True) + self.devices_note.setContentsMargins(12, 10, 12, 10) + self.devices = ui.DeviceGrid() + self.devices.clicked.connect(self._tile_clicked) + self.devices.dropped.connect(self._send_to) + self.devices_hint = ui.label("Dateien auf ein Gerät ziehen oder es anklicken.", + "caption", center=True) + for w in (self.radar, self.radar_text, self.devices_note, self.devices, + self.devices_hint): + bv.addWidget(w) + dcard.add(body, margins=(12, 12, 12, 12)) + col.addWidget(dcard) # transfers - tbox = QGroupBox("Übertragungen") - tl = QVBoxLayout(tbox) - self.transfers = QTreeWidget() - self.transfers.setHeaderLabels(["", "Gerät", "Dateien", "Status"]) - self.transfers.setRootIsDecorated(False) - self.transfers.header().setSectionResizeMode(2, QHeaderView.Stretch) - self.transfers.setColumnWidth(0, 28) - self.transfers.setColumnWidth(1, 160) - self.transfers.itemDoubleClicked.connect(self._open_transfer) - self.transfers.setContextMenuPolicy(Qt.CustomContextMenu) - self.transfers.customContextMenuRequested.connect(self._transfer_menu) - tl.addWidget(self.transfers) - lay.addWidget(tbox, 2) + col.addWidget(ui.section("Übertragungen")) + tcard = ui.Card() + self.transfers = ui.TransferList() + tcard.add(self.transfers, margins=(0, 0, 0, 0)) + col.addWidget(tcard) + # general + caps = integration.supported() + self.autostart_switch = self.send_menu_switch = None + if caps["autostart"] or caps["send"]: + col.addWidget(ui.section("Allgemein")) + gcard = ui.Card() + if caps["autostart"]: + s = self.autostart_switch = ui.Switch() + s.set_quiet(_safe(integration.autostart_enabled, False)) + s.toggled.connect(lambda on, s=s: self._integrate(integration.set_autostart, on, s)) + gcard.add_row("Beim Anmelden im Hintergrund starten", s, + "Unsichtbar im Infobereich und für alle sichtbar; nur eingehende " + "Dateien melden sich.") + if caps["send"]: + s = self.send_menu_switch = ui.Switch() + s.set_quiet(_safe(integration.send_menu_enabled, False)) + s.toggled.connect(lambda on, s=s: self._integrate(integration.set_send_menu, on, s)) + gcard.add_row(caps["send"], s, caps.get("send_hint")) + col.addWidget(gcard) + + # the log, off unless asked for self.log = QPlainTextEdit() self.log.setReadOnly(True) self.log.setMaximumBlockCount(500) - self.log.setFont(QFont("monospace", 8)) - self.log.setVisible(False) - lay.addWidget(self.log, 1) - - menu = self.menuBar().addMenu("&EspDrop") - a = QAction("Protokoll anzeigen", self, checkable=True) - a.toggled.connect(self.log.setVisible) - menu.addAction(a) - a = QAction("Port …", self) - a.triggered.connect(self._pick_port) - menu.addAction(a) - menu.addSeparator() - caps = integration.supported() - self.autostart_action = self.send_menu_action = None - if caps["autostart"]: - a = self.autostart_action = QAction("Beim Anmelden im Hintergrund starten", - self, checkable=True) - a.setChecked(_safe(integration.autostart_enabled, False)) - a.toggled.connect(lambda on: self._integrate(integration.set_autostart, on, a)) - menu.addAction(a) - if caps["send"]: - a = self.send_menu_action = QAction(caps["send"], self, checkable=True) - a.setChecked(_safe(integration.send_menu_enabled, False)) - a.toggled.connect(lambda on: self._integrate(integration.set_send_menu, on, a)) - menu.addAction(a) - menu.addSeparator() - a = QAction("Beenden", self) - a.triggered.connect(self.quit) - menu.addAction(a) - self.setCentralWidget(root) + mono = QFont("Consolas") + mono.setStyleHint(QFont.StyleHint.Monospace) + mono.setPixelSize(11) + self.log.setFont(mono) + self.log.setMinimumHeight(140) + self.log_box = QWidget() + lb = QVBoxLayout(self.log_box) + lb.setContentsMargins(0, 0, 0, 0) + lb.addWidget(ui.section("Protokoll")) + lb.addWidget(self.log) + self.log_box.hide() + self.log_action.toggled.connect(self.log_box.setVisible) + col.addWidget(self.log_box) + col.addStretch(1) + + scroll = QScrollArea() + scroll.setWidgetResizable(True) + scroll.setFrameShape(QFrame.Shape.NoFrame) + scroll.setWidget(page) + self.setCentralWidget(scroll) self._update_dir() self._set_device_ui(False) + self._update_devices_ui() def _make_tray(self): - tray = QSystemTrayIcon(app_icon(), self) + tray = QSystemTrayIcon(ui.app_icon(), self) menu = QMenu() - menu.addAction("Anzeigen", self._show) + menu.addAction("EspDrop öffnen", self._show) menu.addAction("Dateien senden …", lambda: self.open_send_dialog([])) - self.tray_receive = menu.addAction("Empfangsbereit machen", - lambda: self.receive_btn.setChecked( - not self.receive_btn.isChecked())) + self.tray_receive = menu.addAction("Für alle sichtbar (10 Minuten)", + lambda: self.visibility.select( + 0 if self.session.receiving else 1)) menu.addSeparator() menu.addAction("Beenden", self.quit) tray.setContextMenu(menu) tray.activated.connect(lambda reason: self._show() - if reason == QSystemTrayIcon.Trigger else None) + if reason == QSystemTrayIcon.ActivationReason.Trigger else None) tray.messageClicked.connect(self._notification_clicked) tray.setToolTip(APP) tray.show() @@ -606,24 +726,25 @@ def _notify(self, text, files=None, msecs=5000): """A tray notification; clicking it opens `files` (else the window).""" self._notify_files = files if self.tray: - self.tray.showMessage(APP, text, QSystemTrayIcon.Information, msecs) + self.tray.showMessage(APP, text, QSystemTrayIcon.MessageIcon.Information, msecs) def _notification_clicked(self): - files = self._notify_files + if self._notify_files: + self._open_files(self._notify_files) + else: + self._show() + + def _open_files(self, files): if files: target = files[0] if len(files) == 1 else os.path.dirname(files[0]) QDesktopServices.openUrl(QUrl.fromLocalFile(target)) - else: - self._show() - def _integrate(self, fn, on, action): - """Autostart / Explorer menu on or off; a failure is shown and undone.""" + def _integrate(self, fn, on, switch): + """Autostart / file manager menu on or off; a failure is shown and undone.""" try: fn(on) except Exception as e: # noqa: BLE001 - action.blockSignals(True) - action.setChecked(not on) - action.blockSignals(False) + switch.set_quiet(not on) QMessageBox.warning(self, APP, f"Das ging nicht: {e}") # ---- other starts and the share menu ------------------------------------- @@ -637,8 +758,8 @@ def handle_message(self, msg): self._show() def open_send_dialog(self, files): - """'An wen senden?' for these files; Explorer passes files one call - at a time, so they join a dialog that has not started sending yet.""" + """The AirDrop sheet for these files; Explorer passes files one call at + a time, so they join a sheet that has not started sending yet.""" for d in self._send_dialogs: if d.transfer_id is None and d.add_files(files): d.show() @@ -671,8 +792,7 @@ def closeEvent(self, e): if self.tray and not self._quitting: e.ignore() self.hide() - self.tray.showMessage(APP, "Läuft im Hintergrund weiter (Symbol in der " - "Taskleiste).", QSystemTrayIcon.Information, 3000) + self._notify("Läuft im Hintergrund weiter (Symbol im Infobereich).", None, 3000) return self._shutdown() e.accept() @@ -696,31 +816,39 @@ def _on_event(self, kind, info): if kind == "device": state = info.get("state") if state == "connected": + self._connected = True + self.dot.set_state("green") + self.device_label.setText("ESP verbunden") mac = (info.get("info") or {}).get("mac", "") - self.device_label.setText(f"ESP verbunden ({mac})") + self.device_label.setToolTip(f"ESP {mac}" if mac else "") self._set_device_ui(True) # the session restarts receiving itself - elif state == "connecting": - pass elif state == "lost": - err = info.get("error") or "" - self.device_label.setText(f"Kein ESP: {err}" if err else "Kein ESP") + self._connected = False + self.dot.set_state("red") + self.device_label.setText("Kein ESP") + self.device_label.setToolTip(info.get("error") or "") self._set_device_ui(False) elif state == "stopped": - self.device_label.setText("beendet") + self.device_label.setText("Beendet") if state in ("connected", "lost"): for d in list(self._send_dialogs): d.on_device(state == "connected") elif kind == "power": - self.power_label.setText(power_text(info.get("text", ""))) + text = power_text(info.get("text", "")) + self.power_label.setText(f"· {text}" if text else "") elif kind == "browse": - self.search_btn.setEnabled(not info["on"] and self.session.state == "connected") - self.search_btn.setText("Suche läuft …" if info["on"] else "Geräte suchen") + self._browsing = info["on"] + self.search_btn.setText("Suche läuft …" if info["on"] else "Suchen") + self.search_btn.setEnabled(not info["on"] and self._connected) + self._update_devices_ui() elif kind == "receiver": - self._receiver_item(info["receiver"]) + r = info["receiver"] + self.devices.upsert(r.id, r.label, r.model, r.present, r.discovered) + self._update_devices_ui() for d in list(self._send_dialogs): - d.on_receiver(info["receiver"]) + d.on_receiver(r) elif kind == "receiving": - self._receiving_ui(info["on"]) + self._receiving_ui(info["on"], info.get("until")) elif kind == "send": self._send_row(info) for d in list(self._send_dialogs): @@ -732,7 +860,7 @@ def _on_event(self, kind, info): self._log(info.get("text", "")) def _send_notice(self, info): - """In the background nobody sees the dialog close: say how it went.""" + """In the background nobody sees the sheet close: say how it went.""" if self.isVisible() or self._send_dialogs: return t, state = info["transfer"], info["state"] @@ -746,53 +874,64 @@ def _log(self, text): self.log.appendPlainText(f"{time.strftime('%H:%M:%S')} {text}") def _set_device_ui(self, connected): - self.search_btn.setEnabled(connected and not self.session_browsing()) - self.send_btn.setEnabled(connected) + self.search_btn.setEnabled(connected and not self._browsing) + self.devices.set_tiles_enabled(connected) if not connected: self.power_label.setText("") def session_browsing(self): - s = getattr(self, "session", None) - return bool(s and s.browsing) + return self._browsing + + def _update_devices_ui(self): + has = self.devices.count() > 0 + self.radar.setVisible(not has and self._browsing) + self.radar_text.setVisible(not has and self._browsing) + self.radar.set_running(self._browsing) + self.devices_note.setVisible(not has and not self._browsing) + self.devices.setVisible(has) + self.devices_hint.setVisible(has) def _tick(self): - until = self.session.receive_until - if self.session.receiving: + s = getattr(self, "session", None) + if s is not None and s.receiving: + until = s.receive_until if until: left = max(0, until - time.monotonic()) - self.receive_left.setText(f"empfangsbereit, noch {int(left // 60)}:" - f"{int(left % 60):02d}") + text = f"Für alle sichtbar – noch {int(left // 60)}:{int(left % 60):02d}" else: - self.receive_left.setText("empfangsbereit (dauerhaft)") + text = "Für alle sichtbar, bis du es ausschaltest." + else: + text = "Niemand kann dir gerade etwas senden." + if self.receive_left.text() != text: + self.receive_left.setText(text) # ---- receiving ------------------------------------------------------------- - def _receive_toggled(self, on): - secs = WINDOWS.get(self.window_combo.currentText()) - self.settings.setValue("window", self.window_combo.currentText()) - self._receiving_ui(on) - run_bg(self.session.set_receiving, on, secs, on_error=self._log) - - def _receiving_ui(self, on): - if self.receive_btn.isChecked() != on: - self.receive_btn.blockSignals(True) - self.receive_btn.setChecked(on) - self.receive_btn.blockSignals(False) - self.receive_btn.setText("Empfangen beenden" if on else "Empfangsbereit machen") - self.window_combo.setEnabled(not on) - if not on: - self.receive_left.setText("aus") + def _visibility_picked(self, index): + _label, secs = VISIBILITY[index] + self._receiving_ui(bool(index), secs) + if index: + run_bg(self.session.set_receiving, True, secs, on_error=self._log) + else: + run_bg(self.session.set_receiving, False, on_error=self._log) + + def _receiving_ui(self, on, until): + index = 0 if not on else (1 if until else 2) + if self.visibility.index() != index: + self.visibility.set_index(index) if self.tray: - self.tray_receive.setText("Empfangen beenden" if on else "Empfangsbereit machen") + self.tray_receive.setText("Nicht mehr sichtbar" if on + else "Für alle sichtbar (10 Minuten)") + self._tick() def _on_ask(self, ask, reply): # Hidden (autostart, tray): only the question comes up, on its own; # the window stays where it is. hidden = not self.isVisible() - self._notify(f"{ask.sender} möchte etwas senden") + self._notify(f"{ask.sender} möchte {what_text(ask.files, ask.urls)} teilen") dlg = AskDialog(None if hidden else self, ask, reply, self.bridge.ask_timeout) if hidden: - dlg.setWindowIcon(app_icon()) + dlg.setWindowIcon(ui.app_icon()) self._ask_dialogs.append(dlg) dlg.finished.connect(lambda _r, d=dlg: self._ask_dialogs.remove(d) if d in self._ask_dialogs else None) @@ -809,37 +948,34 @@ def _receive_row(self, info): self._log(f"abgelehnt: {ask.summary() if ask else '?'}") return self._recv_n += 1 - item = QTreeWidgetItem(["↓", ask.sender if ask else "?", - ", ".join(ask.files or ask.urls) if ask else "", ""]) - self.transfers.insertTopLevelItem(0, item) - bar = QProgressBar() - bar.setRange(0, 0) # busy until sizes are known - self.transfers.setItemWidget(item, 3, bar) - self._rows[("recv", self._recv_n)] = item - self._recv_row = item + row = ui.TransferRow("receive", ", ".join(ask.files or ask.urls) if ask else "?", + ask.sender if ask else "?") + row.set_state("Wird empfangen …", running=True) + row.open.connect(lambda r=row: self._open_files(r.files)) + self.transfers.add(row) + self._rows[("recv", self._recv_n)] = row + self._recv_row = row elif phase == "receiving" and self._recv_row is not None: - bar = self.transfers.itemWidget(self._recv_row, 3) - total = info.get("total") - if isinstance(bar, QProgressBar): - if total: - bar.setRange(0, 1000) - bar.setValue(int(1000 * min(1.0, info["received"] / total))) - bar.setFormat(human(info["received"])) + got, total = info["received"], info.get("total") + text = f"{human(got)} von {human(total)}" if total else human(got) + self._recv_row.set_state(text, running=True, + progress=min(1.0, got / total) if total else None) elif phase == "received": - item = self._recv_row + row = self._recv_row self._recv_row = None - if item is None: + if row is None: return - self.transfers.removeItemWidget(item, 3) files = info.get("files") or [] if info.get("ok"): - item.setText(3, f"empfangen ({human(info.get('size', 0))})") - item.setData(0, Qt.UserRole, files) - item.setText(2, ", ".join(os.path.basename(f) for f in files)) - self._notify(f"Empfangen: {item.text(2)} – klicken zum Öffnen", files) + names = ", ".join(os.path.basename(f) for f in files) + row.set_title(names) + row.set_state(f"Empfangen · {human(info.get('size', 0))}", files=files, + tone="done") + self._notify(f"Empfangen: {names} – klicken zum Öffnen", files) else: - item.setText(3, "unvollständig") - item.setData(0, Qt.UserRole, files + ([info["raw"]] if info.get("raw") else [])) + row.set_state("Unvollständig", + files=files + ([info["raw"]] if info.get("raw") else []), + tone="bad") def _pick_dir(self): d = QFileDialog.getExistingDirectory(self, "Ordner für empfangene Dateien", @@ -868,31 +1004,10 @@ def _search(self): self.search_btn.setEnabled(False) run_bg(self.session.browse, 60, on_error=self._log) - def _receiver_item(self, r): - item = None - for i in range(self.receivers_list.count()): - if self.receivers_list.item(i).data(Qt.UserRole) == r.id: - item = self.receivers_list.item(i) - if item is None: - item = QListWidgetItem() - item.setData(Qt.UserRole, r.id) - self.receivers_list.addItem(item) - if self.receivers_list.currentItem() is None: - self.receivers_list.setCurrentItem(item) - model = f" ({r.model})" if r.model else "" - item.setText(f"{r.label}{model}" if r.present else f"{r.label}{model} – nicht mehr da") - item.setForeground(QColor("black") if r.present else QColor("gray")) - - def _selected_receiver(self): - item = self.receivers_list.currentItem() - return item.data(Qt.UserRole) if item else None - - def _send_dialog(self): - rid = self._selected_receiver() - if not rid: - QMessageBox.information(self, APP, "Zuerst „Geräte suchen“ und ein Gerät wählen.") - return - files, _ = QFileDialog.getOpenFileNames(self, "Dateien senden") + def _tile_clicked(self, rid): + tile = self.devices.tiles.get(rid) + files, _ = QFileDialog.getOpenFileNames( + self, f"An {tile.name if tile else 'das Gerät'} senden") if files: self._send_to(rid, files) @@ -911,54 +1026,30 @@ def _send_to(self, rid, files): def _send_row(self, info): t = info["transfer"] key = ("send", t.id) - item = self._rows.get(key) - if item is None: - item = QTreeWidgetItem(["↑", t.receiver.label, - ", ".join(os.path.basename(f) for f in t.files), ""]) - item.setData(0, Qt.UserRole + 1, t.id) - self.transfers.insertTopLevelItem(0, item) - self._rows[key] = item + row = self._rows.get(key) + if row is None: + row = ui.TransferRow("send", ", ".join(os.path.basename(f) for f in t.files), + t.receiver.label) + row.cancel.connect(lambda tid=t.id: run_bg(self.session.cancel, tid)) + row.open.connect(lambda r=row: self._open_files(r.files)) + self.transfers.add(row) + self._rows[key] = row state = info["state"] - if state == "uploading": - bar = self.transfers.itemWidget(item, 3) - if not isinstance(bar, QProgressBar): - bar = QProgressBar() - bar.setRange(0, 1000) - self.transfers.setItemWidget(item, 3, bar) - bar.setValue(int(1000 * info["progress"])) - else: - if self.transfers.itemWidget(item, 3) is not None: - self.transfers.removeItemWidget(item, 3) - text = SEND_STATES.get(state, state) - if state == "failed" and info.get("error"): - text += f": {info['error']}" - item.setText(3, text) - - def _transfer_menu(self, pos): - item = self.transfers.itemAt(pos) - if item is None: - return - tid = item.data(0, Qt.UserRole + 1) - menu = QMenu(self) - if tid is not None: - menu.addAction("Abbrechen", lambda: run_bg(self.session.cancel, tid)) - if item.data(0, Qt.UserRole): - menu.addAction("Öffnen", lambda: self._open_transfer(item)) - menu.addAction("Aus der Liste entfernen", - lambda: self.transfers.takeTopLevelItem( - self.transfers.indexOfTopLevelItem(item))) - menu.exec(self.transfers.viewport().mapToGlobal(pos)) - - def _open_transfer(self, item, column=0): - files = item.data(0, Qt.UserRole) or [] - target = files[0] if len(files) == 1 else (os.path.dirname(files[0]) if files else None) - if target: - QDesktopServices.openUrl(QUrl.fromLocalFile(target)) + text = SEND_STATES.get(state, state) + if state == "failed" and info.get("error"): + text += f": {info['error']}" + running = state in ("new", "asking", "uploading") + tone = "done" if state == "done" else "bad" if state in ("declined", "failed") else None + row.set_state(text, running=running, + progress=info.get("progress") if state == "uploading" else None, + files=None if running else t.files, tone=tone) + tile = self.devices.tiles.get(t.receiver.id) + if tile: + tile.set_phase(TILE_PHASE.get(state, "idle"), info.get("progress") or 0.0) # ---- settings -------------------------------------------------------------- def _pick_port(self): - from PySide6.QtWidgets import QInputDialog from .transport import candidates ports = ["automatisch"] + candidates() current = self.port or "automatisch" @@ -968,8 +1059,8 @@ def _pick_port(self): return self.port = None if port == "automatisch" else port self.settings.setValue("port", self.port or "") - receiving = self.session.receiving - secs = WINDOWS.get(self.window_combo.currentText()) + receiving, until = self.session.receiving, self.session.receive_until + secs = None if not until else max(1, int(until - time.monotonic())) old, new = self.session, self._make_session() self.session = new @@ -1007,6 +1098,7 @@ def main(port=None, background=False, send=None): app = QApplication.instance() or QApplication(sys.argv) app.setApplicationName(APP) app.setQuitOnLastWindowClosed(False) + ui.apply(app) files = [os.path.abspath(f) for f in (send or [])] # Already running (autostart, an earlier start)? Hand over and leave. if send_to_running({"files": files, "send": send is not None, diff --git a/host/espdrop/integration.py b/host/espdrop/integration.py index 4580dba..ae1d879 100644 --- a/host/espdrop/integration.py +++ b/host/espdrop/integration.py @@ -52,13 +52,16 @@ def launch_command(*args): def supported(): - """What this system offers: {"autostart": bool, "send": label or None}.""" + """What this system offers: {"autostart": bool, "send": label or None, + "send_hint": what the label switches on}.""" if IS_WINDOWS: - return {"autostart": winreg is not None, "send": "Im Explorer-Menü anbieten " - "(„Senden an“)"} + return {"autostart": winreg is not None, + "send": "Im Rechtsklickmenü des Explorers anbieten", + "send_hint": "„Senden an → EspDrop (AirDrop)“ und „Mit EspDrop senden“"} if sys.platform.startswith("linux"): - return {"autostart": True, "send": "Im Dateimanager anbieten („Öffnen mit“)"} - return {"autostart": False, "send": None} + return {"autostart": True, "send": "Im Dateimanager anbieten", + "send_hint": "„Öffnen mit → AirDrop (EspDrop)“"} + return {"autostart": False, "send": None, "send_hint": None} # ---- autostart --------------------------------------------------------------- diff --git a/host/espdrop/ui.py b/host/espdrop/ui.py new file mode 100644 index 0000000..52a9366 --- /dev/null +++ b/host/espdrop/ui.py @@ -0,0 +1,1321 @@ +# EspDrop - the look of the desktop app: Apple-like colour, type and controls. +# +# Fusion with a palette and a style sheet made from two sets of colours, light +# and dark, following the system (ESPDROP_THEME=light|dark forces one), plus +# the controls Qt has no Apple-shaped version of: the switch, the segmented +# control, round device avatars with a progress ring, the radar shown while +# searching, a thin progress line, rounded "cards" for grouped settings and +# line icons. All of it is drawn in code: no image or font files to ship. Text +# uses the system's UI face (SF on a Mac, Segoe UI Variable on Windows 11, +# Inter or the desktop's font on Linux). +# +# SPDX-License-Identifier: GPL-3.0-or-later +# Copyright (C) 2026 EspDrop contributors + +import os +import re + +from PySide6.QtCore import (QEasingCurve, QPoint, QPointF, QRect, QRectF, QSize, Qt, + QTimer, QVariantAnimation, Signal) +from PySide6.QtGui import (QColor, QFont, QFontMetrics, QGuiApplication, QIcon, QImageReader, + QLinearGradient, QPainter, QPainterPath, QPalette, QPen, QPixmap) +from PySide6.QtWidgets import (QAbstractButton, QFrame, QHBoxLayout, QLabel, QLayout, + QSizePolicy, QToolButton, QVBoxLayout, QWidget) + +# ---- colours --------------------------------------------------------------------- + +LIGHT = { + "window": "#F5F5F7", "card": "#FFFFFF", "card_border": "#E3E3E8", + "separator": "#E5E5EA", "text": "#1D1D1F", "secondary": "#6E6E73", + "tertiary": "#AEAEB2", "accent": "#007AFF", "accent_hover": "#1A86FF", + "accent_pressed": "#0066D6", "focus": "#80B9FF", + "fill": "#E8E8ED", "fill_hover": "#DEDEE3", "fill_pressed": "#D1D1D6", + "field": "#FFFFFF", "field_border": "#D2D2D7", + "segment": "#E8E8ED", "control": "#FFFFFF", "switch_off": "#E2E2E7", + "track": "#E5E5EA", "scroll": "#C7C7CC", "hover": "#EDEDF2", + "green": "#34C759", "orange": "#FF9500", "red": "#FF3B30", + "avatar_top": "#A9AEBA", "avatar_bottom": "#858A96", "shadow_alpha": 40, +} +DARK = { + "window": "#1C1C1E", "card": "#2C2C2E", "card_border": "#3A3A3C", + "separator": "#3A3A3C", "text": "#F5F5F7", "secondary": "#98989D", + "tertiary": "#636366", "accent": "#0A84FF", "accent_hover": "#3395FF", + "accent_pressed": "#0070E0", "focus": "#4A9EFF", + "fill": "#3A3A3C", "fill_hover": "#444446", "fill_pressed": "#505053", + "field": "#1C1C1E", "field_border": "#48484A", + "segment": "#1C1C1E", "control": "#636366", "switch_off": "#48484A", + "track": "#3A3A3C", "scroll": "#5A5A5E", "hover": "#38383A", + "green": "#30D158", "orange": "#FF9F0A", "red": "#FF453A", + "avatar_top": "#8E8E93", "avatar_bottom": "#636366", "shadow_alpha": 90, +} +T = dict(LIGHT) # the colours in use; widgets read them when painting + + +def color(key, alpha=None): + c = QColor(T[key]) + if alpha is not None: + c.setAlpha(alpha) + return c + + +def is_dark(): + forced = os.environ.get("ESPDROP_THEME", "").lower() + if forced in ("dark", "light"): + return forced == "dark" + try: + return QGuiApplication.styleHints().colorScheme() == Qt.ColorScheme.Dark + except AttributeError: # Qt before 6.5 + return QGuiApplication.palette().color(QPalette.ColorRole.Window).lightness() < 128 + + +# ---- type ------------------------------------------------------------------------ + +TEXT_FACES = ["SF Pro Text", ".AppleSystemUIFont", "Segoe UI Variable Text", "Segoe UI", + "Inter", "Helvetica Neue", "Cantarell", "Noto Sans", "DejaVu Sans"] +DISPLAY_FACES = ["SF Pro Display", ".AppleSystemUIFont", "Segoe UI Variable Display", + "Segoe UI", "Inter Display", "Inter", "Helvetica Neue", "Cantarell", + "Noto Sans", "DejaVu Sans"] + + +def font(px=13, weight=QFont.Weight.Normal): + f = QFont() + f.setFamilies(DISPLAY_FACES if px >= 20 else TEXT_FACES) + f.setPixelSize(px) + f.setWeight(weight) + return f + + +# ---- applying it --------------------------------------------------------------- + +def apply(app): + """Style the whole app once; it follows the system's light/dark switch.""" + if app.property("espdrop_theme"): + return + app.setProperty("espdrop_theme", True) + app.setStyle("Fusion") + refresh(app) + try: + app.styleHints().colorSchemeChanged.connect(lambda _scheme: refresh(app)) + except AttributeError: + pass + + +def refresh(app): + T.clear() + T.update(DARK if is_dark() else LIGHT) + app.setFont(font(13)) + app.setPalette(palette()) + app.setStyleSheet(qss()) + for w in app.topLevelWidgets(): + w.update() + + +def palette(): + p = QPalette() + roles = { + QPalette.ColorRole.Window: "window", QPalette.ColorRole.WindowText: "text", + QPalette.ColorRole.Base: "field", QPalette.ColorRole.AlternateBase: "window", + QPalette.ColorRole.Text: "text", QPalette.ColorRole.Button: "fill", + QPalette.ColorRole.ButtonText: "text", QPalette.ColorRole.Highlight: "accent", + QPalette.ColorRole.ToolTipBase: "card", QPalette.ColorRole.ToolTipText: "text", + QPalette.ColorRole.PlaceholderText: "tertiary", QPalette.ColorRole.Link: "accent", + QPalette.ColorRole.Mid: "separator", QPalette.ColorRole.Midlight: "card", + QPalette.ColorRole.Dark: "fill_pressed", QPalette.ColorRole.Light: "card", + } + for role, key in roles.items(): + p.setColor(role, QColor(T[key])) + p.setColor(QPalette.ColorRole.HighlightedText, QColor("white")) + for role in (QPalette.ColorRole.Text, QPalette.ColorRole.WindowText, + QPalette.ColorRole.ButtonText): + p.setColor(QPalette.ColorGroup.Disabled, role, QColor(T["tertiary"])) + return p + + +def qss(): + t = T + return f""" +QWidget {{ color: {t['text']}; }} +QMainWindow, QDialog {{ background: {t['window']}; }} +QWidget#page {{ background: {t['window']}; }} +QScrollArea {{ background: transparent; border: none; }} +QFrame#card {{ background: {t['card']}; border: 1px solid {t['card_border']}; + border-radius: 12px; }} +QFrame#alert {{ background: {t['card']}; border: 1px solid {t['card_border']}; + border-radius: 16px; }} +QLabel {{ background: transparent; }} +QLabel[role="title"] {{ font-size: 24px; font-weight: 700; }} +QLabel[role="headline"] {{ font-size: 15px; font-weight: 600; }} +QLabel[role="section"] {{ color: {t['secondary']}; font-size: 12px; font-weight: 600; }} +QLabel[role="secondary"] {{ color: {t['secondary']}; }} +QLabel[role="caption"] {{ color: {t['secondary']}; font-size: 11px; }} +QPushButton {{ background: {t['fill']}; color: {t['text']}; border: none; border-radius: 7px; + padding: 5px 14px; min-height: 20px; }} +QPushButton:hover {{ background: {t['fill_hover']}; }} +QPushButton:pressed {{ background: {t['fill_pressed']}; }} +QPushButton:disabled {{ color: {t['tertiary']}; }} +QPushButton:focus {{ outline: none; }} +QPushButton[kind="primary"] {{ background: {t['accent']}; color: white; font-weight: 600; }} +QPushButton[kind="primary"]:hover {{ background: {t['accent_hover']}; }} +QPushButton[kind="primary"]:pressed {{ background: {t['accent_pressed']}; }} +QPushButton[kind="link"] {{ background: transparent; color: {t['accent']}; padding: 2px 4px; + min-height: 0; }} +QPushButton[kind="link"]:hover {{ color: {t['accent_hover']}; }} +QPushButton[kind="link"]:disabled {{ color: {t['tertiary']}; }} +QToolButton {{ background: transparent; border: none; border-radius: 7px; padding: 4px; }} +QToolButton:hover {{ background: {t['fill']}; }} +QToolButton:pressed {{ background: {t['fill_pressed']}; }} +QToolButton::menu-indicator {{ image: none; width: 0; }} +QLineEdit {{ background: {t['field']}; border: 1px solid {t['field_border']}; + border-radius: 7px; padding: 4px 8px; selection-background-color: {t['accent']}; + selection-color: white; }} +QLineEdit:focus {{ border: 2px solid {t['focus']}; padding: 3px 7px; }} +QPlainTextEdit {{ background: {t['card']}; border: 1px solid {t['card_border']}; + border-radius: 10px; padding: 6px; }} +QMenu {{ background: {t['card']}; border: 1px solid {t['separator']}; border-radius: 9px; + padding: 5px; }} +QMenu::item {{ padding: 5px 22px 5px 14px; border-radius: 5px; }} +QMenu::item:selected {{ background: {t['accent']}; color: white; }} +QMenu::item:disabled {{ color: {t['tertiary']}; }} +QMenu::separator {{ height: 1px; background: {t['separator']}; margin: 4px 8px; }} +QMenu::indicator {{ width: 0; }} +QScrollBar:vertical {{ background: transparent; width: 10px; margin: 2px; }} +QScrollBar::handle:vertical {{ background: {t['scroll']}; border-radius: 3px; min-height: 30px; + margin: 0 2px; }} +QScrollBar::add-line:vertical, QScrollBar::sub-line:vertical {{ height: 0; }} +QScrollBar::add-page:vertical, QScrollBar::sub-page:vertical {{ background: transparent; }} +QToolTip {{ background: {t['card']}; color: {t['text']}; border: 1px solid {t['separator']}; + border-radius: 6px; padding: 4px 6px; }} +QMessageBox {{ background: {t['window']}; }} +""" + + +def ensure(app): + """apply() unless it ran already (windows made without main(), as in tests).""" + apply(app) + + +# ---- drawn icons ------------------------------------------------------------------- + +def _pen(c, width): + return QPen(QColor(c), width, Qt.PenStyle.SolidLine, Qt.PenCapStyle.RoundCap, + Qt.PenJoinStyle.RoundJoin) + + +def paint_glyph(p, name, r, c, width=1.8): + """A line icon in the rect r (QRectF): up, down, x, check, folder, search, + more, phone, tablet, mac, person, doc, alert.""" + p.save() + p.setPen(_pen(c, width)) + p.setBrush(Qt.BrushStyle.NoBrush) + x, y, w, h = r.x(), r.y(), r.width(), r.height() + cx, cy = r.center().x(), r.center().y() + if name in ("up", "down"): + s = 1 if name == "up" else -1 + tip, tail = cy - s * h * 0.30, cy + s * h * 0.30 + p.drawLine(QPointF(cx, tail), QPointF(cx, tip)) + p.drawLine(QPointF(cx, tip), QPointF(cx - w * 0.24, tip + s * h * 0.24)) + p.drawLine(QPointF(cx, tip), QPointF(cx + w * 0.24, tip + s * h * 0.24)) + elif name == "x": + d = w * 0.24 + p.drawLine(QPointF(cx - d, cy - d), QPointF(cx + d, cy + d)) + p.drawLine(QPointF(cx + d, cy - d), QPointF(cx - d, cy + d)) + elif name == "check": + path = QPainterPath(QPointF(x + w * 0.24, y + h * 0.52)) + path.lineTo(x + w * 0.43, y + h * 0.70) + path.lineTo(x + w * 0.77, y + h * 0.32) + p.drawPath(path) + elif name == "folder": + path = QPainterPath(QPointF(x + w * 0.12, y + h * 0.28)) + path.lineTo(x + w * 0.40, y + h * 0.28) + path.lineTo(x + w * 0.48, y + h * 0.36) + path.lineTo(x + w * 0.88, y + h * 0.36) + path.lineTo(x + w * 0.88, y + h * 0.76) + path.lineTo(x + w * 0.12, y + h * 0.76) + path.closeSubpath() + p.drawPath(path) + elif name == "search": + rr = w * 0.24 + p.drawEllipse(QPointF(cx - w * 0.06, cy - h * 0.06), rr, rr) + p.drawLine(QPointF(cx + w * 0.12, cy + h * 0.12), QPointF(cx + w * 0.30, cy + h * 0.30)) + elif name == "more": + p.setPen(Qt.PenStyle.NoPen) + p.setBrush(QColor(c)) + for k in (-1, 0, 1): + p.drawEllipse(QPointF(cx + k * w * 0.26, cy), width * 0.9, width * 0.9) + elif name == "phone": + body = QRectF(cx - w * 0.21, cy - h * 0.37, w * 0.42, h * 0.74) + p.drawRoundedRect(body, w * 0.09, w * 0.09) + p.drawLine(QPointF(cx - w * 0.06, body.top() + h * 0.08), + QPointF(cx + w * 0.06, body.top() + h * 0.08)) + elif name == "tablet": + body = QRectF(cx - w * 0.30, cy - h * 0.38, w * 0.60, h * 0.76) + p.drawRoundedRect(body, w * 0.08, w * 0.08) + p.drawLine(QPointF(cx - w * 0.05, body.bottom() - h * 0.08), + QPointF(cx + w * 0.05, body.bottom() - h * 0.08)) + elif name == "mac": + screen = QRectF(cx - w * 0.32, cy - h * 0.28, w * 0.64, h * 0.42) + p.drawRoundedRect(screen, w * 0.04, w * 0.04) + p.drawLine(QPointF(cx - w * 0.42, screen.bottom() + h * 0.10), + QPointF(cx + w * 0.42, screen.bottom() + h * 0.10)) + elif name == "person": + p.drawEllipse(QPointF(cx, cy - h * 0.12), w * 0.15, w * 0.15) + arc = QRectF(cx - w * 0.30, cy + h * 0.10, w * 0.60, h * 0.50) + p.drawArc(arc, 0, 180 * 16) + elif name == "doc": + path = QPainterPath(QPointF(x + w * 0.24, y + h * 0.12)) + path.lineTo(x + w * 0.60, y + h * 0.12) + path.lineTo(x + w * 0.78, y + h * 0.30) + path.lineTo(x + w * 0.78, y + h * 0.88) + path.lineTo(x + w * 0.24, y + h * 0.88) + path.closeSubpath() + p.drawPath(path) + p.drawLine(QPointF(x + w * 0.60, y + h * 0.12), QPointF(x + w * 0.60, y + h * 0.30)) + p.drawLine(QPointF(x + w * 0.60, y + h * 0.30), QPointF(x + w * 0.78, y + h * 0.30)) + elif name == "alert": + p.drawLine(QPointF(cx, cy - h * 0.24), QPointF(cx, cy + h * 0.06)) + p.drawPoint(QPointF(cx, cy + h * 0.24)) + p.restore() + + +def _canvas(w, h=None): + """A transparent pixmap for crisp drawing on high-DPI screens.""" + h = w if h is None else h + dpr = max(2.0, QGuiApplication.primaryScreen().devicePixelRatio() + if QGuiApplication.primaryScreen() else 2.0) + pm = QPixmap(int(w * dpr), int(h * dpr)) + pm.setDevicePixelRatio(dpr) + pm.fill(Qt.GlobalColor.transparent) + return pm + + +def glyph_icon(name, c=None, px=18, width=1.7): + pm = _canvas(px) + p = QPainter(pm) + p.setRenderHint(QPainter.RenderHint.Antialiasing) + paint_glyph(p, name, QRectF(0, 0, px, px), c or T["text"], width) + p.end() + return QIcon(pm) + + +def paint_app_icon(p, r): + """Our own mark: a blue rounded square with a white drop in it.""" + s = r.width() + grad = QLinearGradient(r.topLeft(), r.bottomLeft()) + grad.setColorAt(0, QColor("#53A8FF")) + grad.setColorAt(1, QColor("#0A64F0")) + p.setPen(Qt.PenStyle.NoPen) + p.setBrush(grad) + p.drawRoundedRect(r, s * 0.225, s * 0.225) + cx, rad = r.center().x(), s * 0.21 + cy = r.top() + s * 0.60 + drop = QPainterPath(QPointF(cx, r.top() + s * 0.17)) + drop.cubicTo(QPointF(cx + rad * 0.25, r.top() + s * 0.31), + QPointF(cx + rad, cy - rad * 0.75), QPointF(cx + rad, cy)) + drop.arcTo(QRectF(cx - rad, cy - rad, 2 * rad, 2 * rad), 0, -180) + drop.cubicTo(QPointF(cx - rad, cy - rad * 0.75), + QPointF(cx - rad * 0.25, r.top() + s * 0.31), QPointF(cx, r.top() + s * 0.17)) + white = QLinearGradient(QPointF(cx, r.top()), QPointF(cx, cy + rad)) + white.setColorAt(0, QColor(255, 255, 255, 255)) + white.setColorAt(1, QColor(255, 255, 255, 225)) + p.setBrush(white) + p.drawPath(drop) + + +def app_pixmap(px=64): + pm = _canvas(px) + p = QPainter(pm) + p.setRenderHint(QPainter.RenderHint.Antialiasing) + paint_app_icon(p, QRectF(px * 0.04, px * 0.04, px * 0.92, px * 0.92)) + p.end() + return pm + + +def app_icon(): + icon = QIcon() + for px in (16, 24, 32, 48, 64, 128, 256): + icon.addPixmap(app_pixmap(px)) + return icon + + +# ---- devices --------------------------------------------------------------------- + +def device_kind(model, name): + m, n = (model or "").lower(), (name or "").lower() + if m.startswith("iphone") or "iphone" in n: + return "phone" + if m.startswith("ipad") or "ipad" in n: + return "tablet" + if m.startswith(("mac", "imac")) or "macbook" in n or "imac" in n or n.startswith("mac"): + return "mac" + return None + + +def pretty_model(model): + """'iPad13,1' -> 'iPad' (AirDrop's /Discover gives the hardware id).""" + if not model: + return "" + m = re.match(r"[A-Za-z]+", model) + word = m.group(0) if m else model + return {"iphone": "iPhone", "ipad": "iPad", "macbookpro": "MacBook Pro", + "macbookair": "MacBook Air", "imac": "iMac", "macmini": "Mac mini", + "mac": "Mac"}.get(word.lower(), word) + + +def initials(name): + words = re.findall(r"[^\W\d_]+", name or "") + if not words: + return "" + return (words[0][0] + (words[-1][0] if len(words) > 1 else "")).upper() + + +def paint_avatar(p, r, name=None, model=None): + """A round gray avatar: the device's outline (iPhone, iPad, Mac) or the + name's initials, as the share sheet draws people and devices.""" + grad = QLinearGradient(r.topLeft(), r.bottomLeft()) + grad.setColorAt(0, color("avatar_top")) + grad.setColorAt(1, color("avatar_bottom")) + p.save() + p.setPen(Qt.PenStyle.NoPen) + p.setBrush(grad) + p.drawEllipse(r) + kind = device_kind(model, name) + text = initials(name) if not kind else "" + if kind or not text: + inner = r.adjusted(r.width() * 0.22, r.height() * 0.22, + -r.width() * 0.22, -r.height() * 0.22) + paint_glyph(p, kind or "person", inner, "#FFFFFF", max(1.6, r.width() / 26)) + else: + p.setPen(QColor("white")) + p.setFont(font(int(r.height() * 0.38), QFont.Weight.DemiBold)) + p.drawText(r, Qt.AlignmentFlag.AlignCenter, text) + p.restore() + + +IMAGE_EXT = {".jpg", ".jpeg", ".png", ".gif", ".bmp", ".webp", ".tif", ".tiff"} + + +def _rounded(pm, px, radius): + """pm cropped to a px square (or fitted inside it), with rounded corners.""" + out = _canvas(px) + p = QPainter(out) + p.setRenderHint(QPainter.RenderHint.Antialiasing) + p.setRenderHint(QPainter.RenderHint.SmoothPixmapTransform) + clip = QPainterPath() + clip.addRoundedRect(QRectF(0, 0, px, px), radius, radius) + p.setClipPath(clip) + scaled = pm.scaled(int(px * out.devicePixelRatio()), int(px * out.devicePixelRatio()), + Qt.AspectRatioMode.KeepAspectRatioByExpanding, + Qt.TransformationMode.SmoothTransformation) + scaled.setDevicePixelRatio(out.devicePixelRatio()) + sw = scaled.width() / scaled.devicePixelRatio() + sh = scaled.height() / scaled.devicePixelRatio() + p.drawPixmap(QPointF((px - sw) / 2, (px - sh) / 2), scaled) + p.end() + return out + + +def rounded_fit(pm, max_w, max_h, radius=12): + """pm scaled to fit max_w x max_h (keeping its shape), corners rounded.""" + if pm.isNull(): + return pm + f = min(max_w / pm.width(), max_h / pm.height(), 1.0 if pm.width() >= 64 else 4.0) + w, h = max(1, int(pm.width() * f)), max(1, int(pm.height() * f)) + out = _canvas(w, h) + p = QPainter(out) + p.setRenderHint(QPainter.RenderHint.Antialiasing) + p.setRenderHint(QPainter.RenderHint.SmoothPixmapTransform) + clip = QPainterPath() + clip.addRoundedRect(QRectF(0, 0, w, h), radius, radius) + p.setClipPath(clip) + p.drawPixmap(QRectF(0, 0, w, h), pm, QRectF(pm.rect())) + p.end() + return out + + +def image_thumb(path, px=64, radius=10): + reader = QImageReader(path) + reader.setAutoTransform(True) + size = reader.size() + if size.isValid() and size.width() > 0: + f = (px * 2.0) / min(size.width(), size.height()) + if f < 1: + reader.setScaledSize(QSize(int(size.width() * f), int(size.height() * f))) + img = reader.read() + if img.isNull(): + return None + return _rounded(QPixmap.fromImage(img), px, radius) + + +def file_badge(ext, px=64): + """A document icon with the file's extension, for anything not a picture.""" + pm = _canvas(px) + p = QPainter(pm) + p.setRenderHint(QPainter.RenderHint.Antialiasing) + page = QRectF(px * 0.18, px * 0.06, px * 0.64, px * 0.86) + path = QPainterPath(page.topLeft()) + fold = px * 0.18 + path.lineTo(page.right() - fold, page.top()) + path.lineTo(page.right(), page.top() + fold) + path.lineTo(page.bottomRight()) + path.lineTo(page.bottomLeft()) + path.closeSubpath() + p.setPen(_pen(T["separator"] if not is_dark() else T["tertiary"], 1.2)) + p.setBrush(QColor("#FFFFFF") if not is_dark() else QColor("#3A3A3C")) + p.drawPath(path) + p.setBrush(color("fill")) + corner = QPainterPath(QPointF(page.right() - fold, page.top())) + corner.lineTo(page.right() - fold, page.top() + fold) + corner.lineTo(page.right(), page.top() + fold) + corner.closeSubpath() + p.drawPath(corner) + label = (ext or "").lstrip(".").upper()[:4] + if label: + p.setPen(color("accent")) + p.setFont(font(max(8, int(px * 0.17)), QFont.Weight.Bold)) + p.drawText(QRectF(page.left(), page.top() + page.height() * 0.45, page.width(), + page.height() * 0.3), Qt.AlignmentFlag.AlignCenter, label) + p.end() + return pm + + +def files_preview(paths, px=64): + """The first file as a picture or a document icon; more files stack behind.""" + if not paths: + return file_badge("", px) + first = paths[0] + ext = os.path.splitext(first)[1].lower() + front = image_thumb(first, px - 8, 10) if ext in IMAGE_EXT else None + front = front or file_badge(ext, px - 8) + out = _canvas(px) + p = QPainter(out) + p.setRenderHint(QPainter.RenderHint.Antialiasing) + if len(paths) > 1: # more files: cards peeking out behind + p.setPen(Qt.PenStyle.NoPen) + for k, a in ((2, 70), (1, 130)): + p.setBrush(color("tertiary", a)) + p.drawRoundedRect(QRectF(4 + k * 2, 8 - k * 4, px - 8, px - 8), 10, 10) + p.drawPixmap(QPointF(0, 8), front) + p.end() + return out + + +# ---- controls ---------------------------------------------------------------------- + +class Switch(QAbstractButton): + """The Apple switch: a rounded track, a white knob that slides.""" + + def __init__(self, parent=None): + super().__init__(parent) + self.setCheckable(True) + self.setCursor(Qt.CursorShape.PointingHandCursor) + self.setFocusPolicy(Qt.FocusPolicy.StrongFocus) + self._pos = 0.0 + self._anim = QVariantAnimation(self) + self._anim.setDuration(150) + self._anim.setEasingCurve(QEasingCurve.Type.OutCubic) + self._anim.valueChanged.connect(self._set_pos) + self.toggled.connect(self._slide) + + def sizeHint(self): + return QSize(42, 26) + + def set_quiet(self, on): + """Set it without the toggled signal (initial state, undoing a change).""" + self.blockSignals(True) + self.setChecked(on) + self.blockSignals(False) + self._anim.stop() + self._set_pos(1.0 if on else 0.0) + + def _set_pos(self, v): + self._pos = float(v) + self.update() + + def _slide(self, on): + end = 1.0 if on else 0.0 + if not self.isVisible(): + self._set_pos(end) + return + self._anim.stop() + self._anim.setStartValue(self._pos) + self._anim.setEndValue(end) + self._anim.start() + + def hitButton(self, pos): + return self.rect().contains(pos) + + def paintEvent(self, e): + p = QPainter(self) + p.setRenderHint(QPainter.RenderHint.Antialiasing) + w, h = 38.0, 22.0 + r = QRectF((self.width() - w) / 2, (self.height() - h) / 2, w, h) + off, on = color("switch_off"), color("accent") + t = self._pos + track = QColor(int(off.red() + (on.red() - off.red()) * t), + int(off.green() + (on.green() - off.green()) * t), + int(off.blue() + (on.blue() - off.blue()) * t)) + if not self.isEnabled(): + p.setOpacity(0.5) + if self.hasFocus(): + p.setPen(QPen(color("focus"), 3)) + p.setBrush(Qt.BrushStyle.NoBrush) + p.drawRoundedRect(r.adjusted(-2, -2, 2, 2), h / 2 + 2, h / 2 + 2) + p.setPen(Qt.PenStyle.NoPen) + p.setBrush(track) + p.drawRoundedRect(r, h / 2, h / 2) + d = h - 4 + knob = QRectF(r.left() + 2 + t * (w - h), r.top() + 2, d, d) + p.setBrush(QColor(0, 0, 0, 35)) + p.drawEllipse(knob.translated(0, 0.8)) + p.setBrush(QColor("white")) + p.drawEllipse(knob) + + +class Segmented(QWidget): + """The segmented control: a recessed track with a raised, sliding choice.""" + changed = Signal(int) # picked by the user (not by set_index) + + def __init__(self, labels, parent=None): + super().__init__(parent) + self.labels = list(labels) + self._index = 0 + self._pos = 0.0 + self._anim = QVariantAnimation(self) + self._anim.setDuration(180) + self._anim.setEasingCurve(QEasingCurve.Type.OutCubic) + self._anim.valueChanged.connect(self._set_pos) + self.setCursor(Qt.CursorShape.PointingHandCursor) + self.setFocusPolicy(Qt.FocusPolicy.StrongFocus) + self.setSizePolicy(QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Fixed) + self.setAccessibleName(" / ".join(self.labels)) + + def sizeHint(self): + fm = QFontMetrics(font(13, QFont.Weight.DemiBold)) + seg = max(fm.horizontalAdvance(t) for t in self.labels) + 26 + return QSize(seg * len(self.labels) + 4, 30) + + def minimumSizeHint(self): + return self.sizeHint() + + def index(self): + return self._index + + def set_index(self, i, animate=True): + i = max(0, min(len(self.labels) - 1, i)) + self._index = i + if animate and self.isVisible(): + self._anim.stop() + self._anim.setStartValue(self._pos) + self._anim.setEndValue(float(i)) + self._anim.start() + else: + self._set_pos(float(i)) + + def select(self, i): + """As a click on segment i.""" + if i != self._index: + self.set_index(i) + self.changed.emit(self._index) + + def _set_pos(self, v): + self._pos = float(v) + self.update() + + def mousePressEvent(self, e): + seg = self.width() / len(self.labels) + self.select(int(e.position().x() // seg)) + + def keyPressEvent(self, e): + if e.key() in (Qt.Key.Key_Left, Qt.Key.Key_Up): + self.select(self._index - 1) + elif e.key() in (Qt.Key.Key_Right, Qt.Key.Key_Down): + self.select(self._index + 1) + else: + super().keyPressEvent(e) + + def paintEvent(self, e): + p = QPainter(self) + p.setRenderHint(QPainter.RenderHint.Antialiasing) + r = QRectF(self.rect()).adjusted(0.5, 0.5, -0.5, -0.5) + n = len(self.labels) + seg = r.width() / n + if not self.isEnabled(): + p.setOpacity(0.5) + p.setPen(Qt.PenStyle.NoPen) + p.setBrush(color("segment")) + p.drawRoundedRect(r, 8, 8) + sel = QRectF(r.left() + 2 + self._pos * seg, r.top() + 2, seg - 4, r.height() - 4) + p.setBrush(QColor(0, 0, 0, T["shadow_alpha"] // 2)) + p.drawRoundedRect(sel.translated(0, 0.8), 6, 6) + p.setBrush(color("control")) + p.drawRoundedRect(sel, 6, 6) + p.setPen(QPen(color("tertiary", 120), 1)) + for k in range(1, n): # dividers away from the choice + if abs(self._pos - k) > 0.55 and abs(self._pos - (k - 1)) > 0.55: + x = r.left() + k * seg + p.drawLine(QPointF(x, r.top() + 8), QPointF(x, r.bottom() - 8)) + for k, label in enumerate(self.labels): + chosen = k == self._index + p.setFont(font(13, QFont.Weight.DemiBold if chosen else QFont.Weight.Normal)) + p.setPen(color("text")) + p.drawText(QRectF(r.left() + k * seg, r.top(), seg, r.height()), + Qt.AlignmentFlag.AlignCenter, label) + if self.hasFocus(): + p.setPen(QPen(color("focus"), 2)) + p.setBrush(Qt.BrushStyle.NoBrush) + p.drawRoundedRect(r.adjusted(-1, -1, 1, 1), 9, 9) + + +class ThinProgress(QWidget): + """A 4 px progress line; value None runs a sliding segment (no total yet).""" + + def __init__(self, parent=None): + super().__init__(parent) + self._value = None + self._phase = 0.0 + self._timer = QTimer(self) + self._timer.setInterval(16) + self._timer.timeout.connect(self._step) + self.setFixedHeight(4) + self.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed) + + def value(self): + return self._value + + def set_value(self, v): + self._value = None if v is None else max(0.0, min(1.0, float(v))) + if self._value is None and self.isVisible(): + self._timer.start() + else: + self._timer.stop() + self.update() + + def showEvent(self, e): + if self._value is None: + self._timer.start() + + def hideEvent(self, e): + self._timer.stop() + + def _step(self): + self._phase = (self._phase + 0.012) % 1.0 + self.update() + + def paintEvent(self, e): + p = QPainter(self) + p.setRenderHint(QPainter.RenderHint.Antialiasing) + r = QRectF(self.rect()) + p.setPen(Qt.PenStyle.NoPen) + p.setBrush(color("track")) + p.drawRoundedRect(r, 2, 2) + p.setBrush(color("accent")) + if self._value is None: + seg = r.width() * 0.3 + x = -seg + self._phase * (r.width() + seg) + p.setClipRect(r) + p.drawRoundedRect(QRectF(x, r.top(), seg, r.height()), 2, 2) + elif self._value > 0: + p.drawRoundedRect(QRectF(r.left(), r.top(), r.width() * self._value, r.height()), 2, 2) + + +class Radar(QWidget): + """'Looking for devices': rings that grow out of our mark and fade.""" + + def __init__(self, parent=None): + super().__init__(parent) + self._phase = 0.0 + self._running = False + self._timer = QTimer(self) + self._timer.setInterval(33) + self._timer.timeout.connect(self._step) + self.setMinimumHeight(150) + self.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed) + + def set_running(self, on): + self._running = on + if on and self.isVisible(): + self._timer.start() + else: + self._timer.stop() + self.update() + + def showEvent(self, e): + if self._running: + self._timer.start() + + def hideEvent(self, e): + self._timer.stop() + + def _step(self): + self._phase = (self._phase + 0.008) % 1.0 + self.update() + + def paintEvent(self, e): + p = QPainter(self) + p.setRenderHint(QPainter.RenderHint.Antialiasing) + c = QPointF(self.width() / 2, self.height() / 2) + maxr = min(self.width(), self.height()) / 2 - 2 + if self._running: + for k in range(3): + t = (self._phase + k / 3) % 1.0 + rad = 22 + t * (maxr - 22) + p.setPen(QPen(color("accent", int(170 * (1 - t))), 1.6)) + p.setBrush(Qt.BrushStyle.NoBrush) + p.drawEllipse(c, rad, rad) + else: + p.setPen(QPen(color("separator"), 1.2)) + for rad in (maxr * 0.45, maxr * 0.75): + p.drawEllipse(c, rad, rad) + paint_app_icon(p, QRectF(c.x() - 20, c.y() - 20, 40, 40)) + + +class StatusDot(QWidget): + """Green connected, orange looking, red nothing.""" + + def __init__(self, parent=None): + super().__init__(parent) + self._key = "orange" + self.setFixedSize(10, 10) + + def set_state(self, key): + self._key = key + self.update() + + def paintEvent(self, e): + p = QPainter(self) + p.setRenderHint(QPainter.RenderHint.Antialiasing) + p.setPen(Qt.PenStyle.NoPen) + p.setBrush(color(self._key)) + p.drawEllipse(QRectF(1, 1, 8, 8)) + + +class ElidedLabel(QLabel): + """One line, shortened in the middle to fit (paths).""" + + def __init__(self, text="", parent=None): + super().__init__(parent) + self._full = text + self.setSizePolicy(QSizePolicy.Policy.Ignored, QSizePolicy.Policy.Preferred) + self.setText(text) + + def setText(self, text): + self._full = text + self.setToolTip(text) + self._elide() + + def full_text(self): + return self._full + + def resizeEvent(self, e): + super().resizeEvent(e) + self._elide() + + def _elide(self): + fm = QFontMetrics(self.font()) + super().setText(fm.elidedText(self._full, Qt.TextElideMode.ElideMiddle, + max(40, self.width()))) + + +# ---- cards ------------------------------------------------------------------------ + +class Separator(QWidget): + def __init__(self, inset=16, parent=None): + super().__init__(parent) + self.inset = inset + self.setFixedHeight(1) + + def paintEvent(self, e): + p = QPainter(self) + p.fillRect(QRectF(self.inset, 0, self.width() - self.inset, 1), color("separator")) + + +class Card(QFrame): + """A rounded group of rows, divided by inset lines (as in System Settings).""" + + def __init__(self, parent=None): + super().__init__(parent) + self.setObjectName("card") + self.box = QVBoxLayout(self) + self.box.setContentsMargins(0, 2, 0, 2) + self.box.setSpacing(0) + self._n = 0 + + def add(self, w, margins=(16, 10, 16, 10)): + if self._n: + self.box.addWidget(Separator(16)) + holder = QWidget() + lay = QVBoxLayout(holder) + lay.setContentsMargins(*margins) + lay.addWidget(w) + self.box.addWidget(holder) + self._n += 1 + return holder + + def add_row(self, title, control=None, subtitle=None): + """Title (and a gray line under it) on the left, a control on the right. + Returns the subtitle label (None without one).""" + row = QWidget() + h = QHBoxLayout(row) + h.setContentsMargins(0, 0, 0, 0) + h.setSpacing(12) + v = QVBoxLayout() + v.setSpacing(2) + v.addWidget(QLabel(title)) + sub = None + if subtitle is not None: + sub = QLabel(subtitle) + sub.setProperty("role", "caption") + sub.setWordWrap(True) + v.addWidget(sub) + h.addLayout(v, 1) + if control is not None: + h.addWidget(control, 0, Qt.AlignmentFlag.AlignVCenter) + self.add(row, margins=(16, 9, 14, 9)) + return sub + + +def section(title, right=None): + """The gray heading above a card, with an optional button on the right.""" + w = QWidget() + h = QHBoxLayout(w) + h.setContentsMargins(6, 14, 4, 4) + label = QLabel(title) + label.setProperty("role", "section") + h.addWidget(label) + h.addStretch(1) + if right is not None: + h.addWidget(right) + return w + + +def label(text="", role=None, wrap=False, center=False): + lbl = QLabel(text) + if role: + lbl.setProperty("role", role) + lbl.setWordWrap(wrap) + if center: + lbl.setAlignment(Qt.AlignmentFlag.AlignHCenter | Qt.AlignmentFlag.AlignTop) + return lbl + + +def button(text, kind=None): + from PySide6.QtWidgets import QPushButton + b = QPushButton(text) + if kind: + b.setProperty("kind", kind) + b.setCursor(Qt.CursorShape.PointingHandCursor) + return b + + +def tool_button(glyph, tip): + b = QToolButton() + b.setIcon(glyph_icon(glyph, T["secondary"], 18)) + b.setIconSize(QSize(18, 18)) + b.setToolTip(tip) + b.setCursor(Qt.CursorShape.PointingHandCursor) + return b + + +# ---- devices on screen ----------------------------------------------------------- + +class FlowLayout(QLayout): + """Lays its widgets out in rows that wrap, each row centred.""" + + def __init__(self, parent=None, spacing=4): + super().__init__(parent) + self._items = [] + self.setSpacing(spacing) + self.setContentsMargins(0, 0, 0, 0) + + def addItem(self, item): + self._items.append(item) + + def count(self): + return len(self._items) + + def itemAt(self, i): + return self._items[i] if 0 <= i < len(self._items) else None + + def takeAt(self, i): + return self._items.pop(i) if 0 <= i < len(self._items) else None + + def expandingDirections(self): + return Qt.Orientation(0) + + def hasHeightForWidth(self): + return True + + def heightForWidth(self, width): + return self._place(QRect(0, 0, width, 0), dry=True) + + def setGeometry(self, rect): + super().setGeometry(rect) + self._place(rect, dry=False) + + def sizeHint(self): + return self.minimumSize() + + def minimumSize(self): + size = QSize() + for it in self._items: + size = size.expandedTo(it.minimumSize()) + m = self.contentsMargins() + return size + QSize(m.left() + m.right(), m.top() + m.bottom()) + + def _place(self, rect, dry): + m = self.contentsMargins() + r = rect.adjusted(m.left(), m.top(), -m.right(), -m.bottom()) + sp = self.spacing() + rows, row, x, row_h = [], [], 0, 0 + for it in self._items: + if it.isEmpty(): + continue + s = it.sizeHint() + if row and x + s.width() > r.width(): + rows.append((row, x - sp, row_h)) + row, x, row_h = [], 0, 0 + row.append((it, s)) + x += s.width() + sp + row_h = max(row_h, s.height()) + if row: + rows.append((row, x - sp, row_h)) + y = r.y() + for items, row_w, row_h in rows: + x = r.x() + max(0, (r.width() - row_w) // 2) + for it, s in items: + if not dry: + it.setGeometry(QRect(QPoint(x, y), s)) + x += s.width() + sp + y += row_h + sp + used = (y - sp - r.y()) if rows else 0 + return used + m.top() + m.bottom() + + +def _two_lines(fm, text, width): + """text in at most two lines of `width`, the second elided.""" + words, first = text.split(), "" + while words and fm.horizontalAdvance((first + " " + words[0]).strip()) <= width: + first = (first + " " + words.pop(0)).strip() + if not first: # one long word + return [fm.elidedText(text, Qt.TextElideMode.ElideRight, width)] + rest = " ".join(words) + return [first] + ([fm.elidedText(rest, Qt.TextElideMode.ElideRight, width)] if rest else []) + + +class DeviceTile(QWidget): + """One device: avatar, name, and a line saying what is going on; a ring + around the avatar while waiting for Accept and while sending. Files + dropped on it are sent to it, a click picks it.""" + clicked = Signal(str) + dropped = Signal(str, list) + W, H, AV = 104, 124, 58 + NOTES = {"waiting": "Warten …", "sent": "Gesendet", "declined": "Abgelehnt", + "failed": "Fehlgeschlagen", "cancelled": "Abgebrochen"} + + def __init__(self, rid, parent=None): + super().__init__(parent) + self.rid = rid + self.name = rid + self.model = None + self.named = False + self.present = True + self.phase = "idle" + self.progress = 0.0 + self._hover = self._drag = False + self._spin = 0.0 + self._timer = QTimer(self) + self._timer.setInterval(16) + self._timer.timeout.connect(self._step) + self._reset = QTimer(self) + self._reset.setSingleShot(True) + self._reset.timeout.connect(lambda: self.set_phase("idle")) + self.setFixedSize(self.W, self.H) + self.setAcceptDrops(True) + self.setCursor(Qt.CursorShape.PointingHandCursor) + self.setFocusPolicy(Qt.FocusPolicy.StrongFocus) + + def set_info(self, name, model, present, named): + self.name, self.model, self.present, self.named = name, model, present, named + self.setToolTip(name if named else f"{name} (Name noch unbekannt)") + self.setAccessibleName(name) + self.update() + + def set_phase(self, phase, progress=0.0): + self.phase, self.progress = phase, progress + if phase in ("waiting", "sending"): + if not self._timer.isActive(): + self._timer.start() + else: + self._timer.stop() + if phase in ("sent", "declined", "failed", "cancelled"): + self._reset.start(6000) + else: + self._reset.stop() + self.update() + + def caption(self): + if self.phase == "sending": + return f"Senden … {int(self.progress * 100)} %" + if self.phase in self.NOTES: + return self.NOTES[self.phase] + if not self.present: + return "nicht in der Nähe" + return pretty_model(self.model) + + def _step(self): + self._spin = (self._spin + 7) % 360 + self.update() + + def paintEvent(self, e): + p = QPainter(self) + p.setRenderHint(QPainter.RenderHint.Antialiasing) + if not self.isEnabled() or (not self.present and self.phase == "idle"): + p.setOpacity(0.45) + av = QRectF((self.W - self.AV) / 2, 12, self.AV, self.AV) + if self._hover or self._drag or self.hasFocus(): + p.setPen(Qt.PenStyle.NoPen) + p.setBrush(color("hover")) + p.drawRoundedRect(QRectF(4, 2, self.W - 8, self.H - 4), 14, 14) + paint_avatar(p, av, self.name if self.named else None, self.model) + ring = av.adjusted(-5, -5, 5, 5) + if self._drag: + p.setPen(QPen(color("accent"), 3)) + p.setBrush(Qt.BrushStyle.NoBrush) + p.drawEllipse(ring) + elif self.phase in ("waiting", "sending"): + p.setBrush(Qt.BrushStyle.NoBrush) + p.setPen(QPen(color("track"), 3)) + p.drawEllipse(ring) + pen = QPen(color("accent"), 3) + pen.setCapStyle(Qt.PenCapStyle.RoundCap) + p.setPen(pen) + if self.phase == "waiting": + p.drawArc(ring, int((90 - self._spin) * 16), -75 * 16) + else: + p.drawArc(ring, 90 * 16, int(-max(0.02, self.progress) * 360 * 16)) + f = font(12, QFont.Weight.Medium) + p.setFont(f) + p.setPen(color("text")) + fm = QFontMetrics(f) + y = av.bottom() + 10 + for line in _two_lines(fm, self.name, self.W - 10): + p.drawText(QRectF(5, y, self.W - 10, fm.height()), Qt.AlignmentFlag.AlignHCenter, line) + y += fm.height() + note = self.caption() + if note: + f2 = font(11) + p.setFont(f2) + key = {"sent": "accent", "declined": "red", "failed": "red"}.get(self.phase, + "secondary") + p.setPen(color(key)) + fm2 = QFontMetrics(f2) + p.drawText(QRectF(5, y + 1, self.W - 10, fm2.height()), Qt.AlignmentFlag.AlignHCenter, + fm2.elidedText(note, Qt.TextElideMode.ElideRight, self.W - 10)) + + # mouse, keyboard and drops + + def enterEvent(self, e): + self._hover = True + self.update() + + def leaveEvent(self, e): + self._hover = False + self.update() + + def mouseReleaseEvent(self, e): + if e.button() == Qt.MouseButton.LeftButton and self.rect().contains(e.position().toPoint()): + self.clicked.emit(self.rid) + + def keyPressEvent(self, e): + if e.key() in (Qt.Key.Key_Space, Qt.Key.Key_Return, Qt.Key.Key_Enter): + self.clicked.emit(self.rid) + else: + super().keyPressEvent(e) + + def dragEnterEvent(self, e): + if e.mimeData().hasUrls() and self.isEnabled(): + self._drag = True + self.update() + e.acceptProposedAction() + + def dragLeaveEvent(self, e): + self._drag = False + self.update() + + def dropEvent(self, e): + self._drag = False + self.update() + paths = [u.toLocalFile() for u in e.mimeData().urls() if u.isLocalFile()] + if paths: + self.dropped.emit(self.rid, paths) + e.acceptProposedAction() + + +class DeviceGrid(QWidget): + """The devices in reach, as tiles in wrapping, centred rows.""" + clicked = Signal(str) + dropped = Signal(str, list) + + def __init__(self, parent=None): + super().__init__(parent) + self.tiles = {} + self.flow = FlowLayout(self, spacing=6) + self.setSizePolicy(QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Preferred) + + def upsert(self, rid, name, model, present, named): + tile = self.tiles.get(rid) + if tile is None: + tile = self.tiles[rid] = DeviceTile(rid, self) + tile.clicked.connect(self.clicked) + tile.dropped.connect(self.dropped) + self.flow.addWidget(tile) + tile.show() + tile.set_info(name, model, present, named) + self.updateGeometry() + return tile + + def count(self): + return len(self.tiles) + + def set_tiles_enabled(self, on, except_rid=None): + for rid, tile in self.tiles.items(): + tile.setEnabled(on or rid == except_rid) + + +class RoundGlyph(QWidget): + """A coloured circle with a white line icon (transfer rows).""" + + def __init__(self, glyph, key="accent", px=30, parent=None): + super().__init__(parent) + self.glyph, self.key = glyph, key + self.setFixedSize(px, px) + + def set(self, glyph, key): + self.glyph, self.key = glyph, key + self.update() + + def paintEvent(self, e): + p = QPainter(self) + p.setRenderHint(QPainter.RenderHint.Antialiasing) + r = QRectF(self.rect()).adjusted(0.5, 0.5, -0.5, -0.5) + p.setPen(Qt.PenStyle.NoPen) + p.setBrush(color(self.key)) + p.drawEllipse(r) + paint_glyph(p, self.glyph, r.adjusted(5, 5, -5, -5), "#FFFFFF", 2.0) + + +class TransferRow(QWidget): + """One transfer: icon, files, device and state, a progress line while it + runs; buttons to cancel it or to show the files.""" + cancel = Signal() + open = Signal() + + def __init__(self, direction, title, device, parent=None): + super().__init__(parent) + self.direction = direction # "send" or "receive" + self.files = [] + self._top_line = False + h = QHBoxLayout(self) + h.setContentsMargins(14, 10, 10, 10) + h.setSpacing(12) + self.icon = RoundGlyph("up" if direction == "send" else "down", + "accent" if direction == "send" else "green") + h.addWidget(self.icon, 0, Qt.AlignmentFlag.AlignVCenter) + v = QVBoxLayout() + v.setSpacing(3) + self.title = ElidedLabel(title) + self.title.setFont(font(13, QFont.Weight.Medium)) + self.sub = QLabel() + self.sub.setProperty("role", "caption") + self.bar = ThinProgress() + self.bar.hide() + v.addWidget(self.title) + v.addWidget(self.sub) + v.addWidget(self.bar) + h.addLayout(v, 1) + self.cancel_btn = tool_button("x", "Abbrechen") + self.cancel_btn.clicked.connect(self.cancel) + self.open_btn = tool_button("search", "Im Ordner zeigen") + self.open_btn.clicked.connect(self.open) + h.addWidget(self.cancel_btn) + h.addWidget(self.open_btn) + self.cancel_btn.hide() + self.open_btn.hide() + self.device = device + self._status = "" + self.set_state("") + + def set_title(self, title): + self.title.setText(title) + + def set_state(self, status, running=False, progress=None, files=None, tone=None): + """tone: None, "done" or "bad" (the icon turns into a check or a mark).""" + self._status = status + self.sub.setText(f"{self.device} · {status}" if status else self.device) + self.bar.setVisible(running) + if running: + self.bar.set_value(progress) + self.cancel_btn.setVisible(running and self.direction == "send") + if files is not None: + self.files = files + self.open_btn.setVisible(bool(self.files) and not running) + if tone == "done": + self.icon.set("check", "accent" if self.direction == "send" else "green") + elif tone == "bad": + self.icon.set("alert", "red") + + def status_text(self): + return self._status + + def title_text(self): + return self.title.full_text() + + def set_top_line(self, on): + self._top_line = on + self.update() + + def paintEvent(self, e): + if self._top_line: + p = QPainter(self) + p.fillRect(QRectF(56, 0, self.width() - 56, 1), color("separator")) + + +class TransferList(QWidget): + """The transfers, newest on top; a gray line when there are none.""" + + def __init__(self, keep=30, parent=None): + super().__init__(parent) + self.rows = [] + self.keep = keep + self.box = QVBoxLayout(self) + self.box.setContentsMargins(0, 0, 0, 0) + self.box.setSpacing(0) + self.placeholder = label("Noch keine Übertragungen.", "secondary", center=True) + self.placeholder.setContentsMargins(0, 16, 0, 16) + self.box.addWidget(self.placeholder) + + def add(self, row): + self.placeholder.hide() + self.rows.insert(0, row) + self.box.insertWidget(0, row) + while len(self.rows) > self.keep: + old = self.rows.pop() + old.setParent(None) + old.deleteLater() + self._lines() + return row + + def remove(self, row): + if row in self.rows: + self.rows.remove(row) + row.setParent(None) + row.deleteLater() + self.placeholder.setVisible(not self.rows) + self._lines() + + def count(self): + return len(self.rows) + + def _lines(self): + for k, row in enumerate(self.rows): + row.set_top_line(k > 0) diff --git a/host/tests/test_gui.py b/host/tests/test_gui.py index bc39cb4..38ddbe1 100644 --- a/host/tests/test_gui.py +++ b/host/tests/test_gui.py @@ -115,16 +115,21 @@ def test_search_and_send(self): answers={1: ["small"]}) w = self.window(fake) src = self.file("foto.jpg", b"\xff\xd8 gui send " * 500) + self.assertFalse(w.radar.isVisible()) # not searching yet w.search_btn.click() - self.assertTrue(self.pump(lambda: w.receivers_list.count() == 1 - and "TestPad" in w.receivers_list.item(0).text())) + self.assertTrue(self.pump(lambda: w.devices.count() == 1 and next( + iter(w.devices.tiles.values())).name == "TestPad")) + (rid, tile), = w.devices.tiles.items() + self.assertEqual(tile.caption(), "OpenDrop") # its /Discover model os.chdir(pad) # the loopback receiver writes into cwd - w._send_to(w._selected_receiver(), [src]) - row = lambda: w.transfers.topLevelItem(0) # noqa: E731 - self.assertTrue(self.pump(lambda: row() and row().text(3) in - ("gesendet", "fehlgeschlagen", "abgelehnt"))) - self.assertEqual(row().text(3), "gesendet") - self.assertEqual((row().text(1), row().text(2)), ("TestPad", "foto.jpg")) + w._send_to(rid, [src]) + row = lambda: w.transfers.rows[0] if w.transfers.rows else None # noqa: E731 + self.assertTrue(self.pump(lambda: row() and row().status_text() in + ("Gesendet", "Fehlgeschlagen", "Abgelehnt"))) + self.assertEqual(row().status_text(), "Gesendet") + self.assertEqual((row().device, row().title_text()), ("TestPad", "foto.jpg")) + self.assertEqual(tile.phase, "sent") # the avatar says so too + self.assertTrue(row().open_btn.isVisibleTo(w)) finally: rx.stop() self.assertIn("foto.jpg", os.listdir(pad)) @@ -132,18 +137,20 @@ def test_search_and_send(self): def answer_incoming(self, accept): fake = DuplexBridgeDevice() w = self.window(fake) - w.receive_btn.click() # empfangsbereit (10 Minuten) + w.visibility.select(1) # "10 Minuten", as a click self.assertTrue(self.pump(lambda: fake.listening == 8771)) self.assertTrue(w.session.receiving) self.assertIsNotNone(w.session.receive_until) + self.assertTrue(self.pump(lambda: w.receive_left.text().startswith( + "Für alle sichtbar – noch")), w.receive_left.text()) src = self.file("bild.jpg", b"incoming via the dialog " * 300) result = {} th = threading.Thread(target=lambda: result.setdefault("ok", incoming(fake, src))) th.start() self.assertTrue(self.pump(lambda: getattr(w, "_last_ask_dialog", None) is not None)) dlg = w._last_ask_dialog - self.assertIn("Sender", dlg.findChildren(type(w.device_label))[0].text() - + "".join(lbl.text() for lbl in dlg.findChildren(type(w.device_label)))) + texts = [lbl.text() for lbl in dlg.findChildren(type(w.device_label))] + self.assertIn("„Sender“ möchte ein Foto teilen.", texts) (dlg.yes if accept else dlg.no).click() self.assertTrue(self.pump(lambda: not th.is_alive(), 20)) return w, result.get("ok") @@ -151,16 +158,17 @@ def answer_incoming(self, accept): def test_receive_through_the_dialog(self): w, ok = self.answer_incoming(True) self.assertTrue(ok) - row = lambda: w.transfers.topLevelItem(0) # noqa: E731 - self.assertTrue(self.pump(lambda: row() and row().text(3).startswith("empfangen"))) - self.assertEqual(row().text(2), "bild.jpg") + row = lambda: w.transfers.rows[0] if w.transfers.rows else None # noqa: E731 + self.assertTrue(self.pump(lambda: row() and row().status_text().startswith("Empfangen"))) + self.assertEqual((row().title_text(), row().device), ("bild.jpg", "Sender")) self.assertIn("bild.jpg", os.listdir(self.dl)) def test_declined_in_the_dialog(self): w, ok = self.answer_incoming(False) self.assertFalse(ok) self.assertEqual(os.listdir(self.dl), []) - self.assertEqual(w.transfers.topLevelItemCount(), 0) + self.assertEqual(w.transfers.count(), 0) + self.assertTrue(w.transfers.placeholder.isVisibleTo(w)) def test_background_shows_only_the_question(self): # Autostart: no window, receiving until switched off; an incoming @@ -171,6 +179,7 @@ def test_background_shows_only_the_question(self): self.assertTrue(self.pump(lambda: fake.listening == 8771)) self.assertTrue(w.session.receiving) self.assertIsNone(w.session.receive_until) # dauerhaft + self.assertEqual(w.visibility.index(), 2) # "Für alle" src = self.file("hintergrund.jpg", b"arrives while hidden " * 200) result = {} th = threading.Thread(target=lambda: result.setdefault("ok", incoming(fake, src))) @@ -205,11 +214,15 @@ def test_send_dialog_from_the_share_menu(self): w.handle_message({"files": [b], "send": True}) (d,) = w._send_dialogs self.assertEqual([os.path.basename(f) for f in d.files], ["a.txt", "b.txt"]) - self.assertTrue(self.pump(lambda: d.list.count() == 1 - and "TestPad" in d.list.item(0).text())) + self.assertEqual(d.title.full_text(), "2 Dateien") + self.assertTrue(self.pump(lambda: d.grid.count() == 1 and next( + iter(d.grid.tiles.values())).name == "TestPad")) + self.assertFalse(d.radar.isVisible()) # devices replace the radar + (rid, tile), = d.grid.tiles.items() os.chdir(pad) # the loopback receiver writes into cwd - d._pick(d.list.item(0)) + d._pick(rid) self.assertIsNotNone(d.transfer_id) + self.assertIn(tile.phase, ("waiting", "sending", "sent")) self.assertTrue(self.pump(lambda: d not in w._send_dialogs, 20), d.status.text()) self.assertFalse(w.isVisible()) @@ -232,6 +245,40 @@ def test_single_instance(self): finally: server.close() + def test_what_text(self): + w = self.gui.what_text + self.assertEqual(w(["a.jpg"]), "ein Foto") + self.assertEqual(w(["a.JPG", "b.heic"]), "2 Fotos") + self.assertEqual(w(["a.mov"]), "ein Video") + self.assertEqual(w(["a.jpg", "b.pdf"]), "2 Dateien") + self.assertEqual(w([], ["https://example.org"]), "einen Link") + self.assertEqual(w([]), "etwas") + + def test_controls(self): + from espdrop import ui + seg = ui.Segmented(["Aus", "10 Minuten", "Für alle"]) + picked = [] + seg.changed.connect(picked.append) + seg.set_index(2) # programmatic: no signal + seg.select(1) # as a click + seg.select(1) + self.assertEqual((seg.index(), picked), (1, [1])) + sw = ui.Switch() + flips = [] + sw.toggled.connect(flips.append) + sw.set_quiet(True) + sw.click() + self.assertEqual((sw.isChecked(), flips), (False, [False])) + self.assertEqual(ui.pretty_model("iPad13,1"), "iPad") + self.assertEqual(ui.device_kind("iPhone15,2", None), "phone") + self.assertEqual(ui.device_kind(None, "MacBook von Anna"), "mac") + self.assertEqual(ui.initials("Bens iPad"), "BI") + for dark in (False, True): # both looks build + ui.T.clear() + ui.T.update(ui.DARK if dark else ui.LIGHT) + self.assertIn(ui.T["accent"], ui.qss()) + ui.refresh(self.app) + def test_power_text(self): t = self.gui.power_text self.assertEqual(t("power auto asleep (none) hold=0s wake=0s"), "Funk aus (spart Strom)") From 8100306ac2199673b464a5bd15104ad2b6cdf702 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 31 Oct 2026 13:21:10 +0000 Subject: [PATCH 83/99] Release list: defaults, Downloads, icons, theme switch, clipboard - The receiver's share sheet need not be open when sending (confirmed with the ESP's BLE wake): the hints are gone from the app, the CLI, the tools and the docs; the old spike notes say they were superseded. - Autostart and the right-click entries are on from the first start (once; switched off they stay off). Every start writes the icon file (%LOCALAPPDATA%\EspDrop\espdrop.ico, ~/.local/share/espdrop/espdrop.png) and points the entries at it and at the app as it runs now. The ICO holds classic bitmaps up to 128 px and PNG at 256 px. The right-click registry entry is written before the PowerShell "Send to" shortcut, so it works where PowerShell is blocked. - Windows taskbar: own AppUserModelID, so it shows our icon, not Python's. - Received files land in Downloads itself. OpenDrop unpacks into a hidden staging folder (Downloads/.espdrop-incoming), then each file moves out under a free name ('IMG_1 (1).JPG'): nothing gets overwritten; what a crash or a locked file leaves there moves out at the next start. - Allgemein -> Erscheinungsbild: Automatisch / Hell / Dunkel (also sets the title bar on Windows with Qt 6.8+). - AirDrop -> "In die Zwischenablage kopieren" (off by default): received files as files, a single photo also as an image, links as text. - Single instance: the running copy's server no longer deleteLater()s the sockets of later starts. A deferred delete still queued when the server went deleted the socket a second time (crash at quit, and in the tests); finished connections are now deleted explicitly. 68 host tests pass. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01XzgaN4Bv1m8Ka9iqo7QVUN --- docs/SPIKES.md | 85 +++++++++++---- docs/UEBERBLICK.md | 5 +- firmware/spikes/main/ble_wake.c | 8 +- firmware/spikes/main/ble_wake.h | 7 +- host/README.md | 44 +++++--- host/espdrop/cli.py | 6 +- host/espdrop/gui.py | 182 ++++++++++++++++++++++++++------ host/espdrop/integration.py | 83 +++++++++++++-- host/espdrop/receive.py | 55 ++++++++-- host/espdrop/session.py | 35 ++++-- host/espdrop/ui.py | 105 ++++++++++++++++++ host/tests/test_gui.py | 78 +++++++++++++- host/tests/test_integration.py | 42 ++++++++ host/tests/test_session.py | 64 ++++++++++- tools/airdrop_wake.py | 15 +-- tools/opendrop_receive.sh | 6 +- tools/opendrop_send.sh | 19 ++-- tools/zeroconf_probe.py | 6 +- 18 files changed, 712 insertions(+), 133 deletions(-) diff --git a/docs/SPIKES.md b/docs/SPIKES.md index eece9b0..19a501a 100644 --- a/docs/SPIKES.md +++ b/docs/SPIKES.md @@ -205,10 +205,11 @@ Radarerkennung). ## Senden an ein iPhone (M3, PC -> iPhone) Empfangen ist damit fertig. Für die Gegenrichtung muss das iPhone oder iPad als -Empfänger über AWDL antworten. Das tut es nur, wenn sein AWDL wach ist (offenes -Teilen-Menü) **und** ein Bluetooth-LE-Signal in der Nähe meldet, dass jemand -senden will. (Nachgewiesen im Referenzprojekt airdrop-mt7921, FINDINGS §27 -und §37: gleiche Verbindung, gleiches offenes Menü, gefunden nur mit BLE.) +Empfänger über AWDL antworten. Das tut es nur, solange ein Bluetooth-LE-Signal +in der Nähe meldet, dass jemand senden will. (Nachgewiesen im Referenzprojekt +airdrop-mt7921, FINDINGS §27 und §37: gleiche Verbindung, gefunden nur mit BLE; +dort lief es mit offenem Teilen-Menü. Mit dem Wecksignal des ESP zeigte sich +später, dass das Menü des Empfängers nicht offen sein muss.) Das Wecksignal kommt vom Bluetooth des ESP selbst (Bridge-Firmware, Befehl `wake`) oder vom Bluetooth des PCs (`tools/airdrop_wake.py`, braucht @@ -250,7 +251,8 @@ bereit (gehört dem Benutzer, der sie mit `sudo` gestartet hat). auf „Nur Kontakte“ zurück, also kurz vor dem Senden neu wählen), entsperrt, **Teilen-Menü öffnen und offen lassen** (es hält AWDL wach; bei geschlossenem Menü meldet sich das Gerät nie als Empfänger). Bei der Abfrage „Annehmen“ - tippen. + tippen. *Später überholt:* Mit dem BLE-Wecksignal des ESP (M4) muss das + Teilen-Menü des Empfängers nicht offen sein. Mehrere Apple-Geräte in Reichweite: mit `-r "Annas iPhone"` das Ziel am Namen wählen. „Nur Kontakte“ statt „Jeder“: Identifikator über @@ -301,7 +303,7 @@ derselben Adresse senden): 1. `mode ip`: Antwort `mode ip`. 2. `ip`: `up fe80::…` (unsere Link-Local, wie bisher auf `awdl0`), Zähler und die aus dem Verkehr gelernten Peers (`peers: fe80::…=mac(Alter)`). - iPhone/iPad dafür wach halten (Teilen-Menü offen, gegebenenfalls `wake 60`). + iPhone/iPad dafür wecken (`wake 60`). 3. `ping6 10`: zehn Echo-Requests vom ESP selbst; die Antworten erscheinen als `[device] ping6: reply …`, am Ende die Bilanz. 4. `ip` und `stats` noch einmal: Zähler, `heap=`. @@ -334,7 +336,7 @@ In der IP-Betriebsart gibt die Firmware die L2-Warteschlange frei (`stats`: `rxq=0`, `heap=` entsprechend höher); `awdl_bridge.py` legt sie beim Start wieder an. -Test (Bridge beenden, Teilen-Menü am iPhone/iPad offen): +Test (Bridge beenden, iPhone/iPad auf AirDrop „Für alle“, entsperrt): ```sh ~/opendrop-venv/bin/python tools/espdrop.py status ~/opendrop-venv/bin/python tools/espdrop.py find -t 30 @@ -372,7 +374,8 @@ Verbindungsschicht. ``` `send` startet das BLE-Wecksignal des ESP, sucht den Empfänger, baut die Verbindung auf und überträgt; am iPad „Annehmen“ tippen. Bedingungen wie -immer: AirDrop „Für alle“, entsperrt, Teilen-Menü offen. +immer: AirDrop „Für alle“, entsperrt. Das Teilen-Menü des Empfängers muss nicht +offen sein (mit dem BLE-Wecksignal des ESP auf der Hardware bestätigt). Ergebnis (2026-10-23, C5): Senden an ein iPad erfolgreich über `espdrop send`, ohne `awdl0`/root/Firewall. `ip` danach: `tcp: opened=1 to_host=1137 @@ -456,7 +459,7 @@ Checkout oder ZIP): .\tools\espdrop.cmd receive -n MeinPC ``` Bedingungen am iPad wie gewohnt (AirDrop „Für alle“, entsperrt, - Teilen-Menü offen). Empfangene Dateien landen im aktuellen Ordner. + ohne offenes Teilen-Menü). Empfangene Dateien landen im aktuellen Ordner. 3. Zusätzlich einmal eine Datei mit Umlaut im Namen senden und empfangen (z. B. `Prüfung.txt`): ob libarchive unter Windows Nicht-ASCII-Namen richtig umsetzt, ist noch offen. @@ -573,26 +576,26 @@ ergänzt `python3 tools/opendrop_install.py` (nur was fehlt) oder Test auf der Hardware (Firmware mit Schlafmodus geflasht): 1. App starten: unter dem Titel „ESP verbunden · Funk aus (spart Strom)“. -2. „Suchen“, am iPad Teilen-Menü öffnen: das iPad erscheint als rundes Bild mit - Namen. +2. „Suchen“ (iPad auf AirDrop „Für alle“, entsperrt): das iPad erscheint als + rundes Bild mit Namen. Eine Datei darauf ziehen, am iPad annehmen: der Ring um das Bild zeigt das Warten und den Fortschritt, danach „Gesendet“. 3. „Empfangen von: 10 Minuten“: unter dem Titel „Funk schläft (empfangsbereit)“ (der ESP lauscht per Bluetooth). Am iPad etwas teilen: der ESP wacht von selbst auf, „MeinPC“ erscheint am iPad, wählen; die Annehmen-Karte fragt, „Annehmen“, die - Datei liegt in `Downloads/EspDrop`. Einmal „Ablehnen“: das iPad meldet die + Datei liegt direkt in `Downloads`. Einmal „Ablehnen“: das iPad meldet die Ablehnung. 4. Fenster schließen (läuft im Tray weiter), ESP ab- und anstecken: die App verbindet sich wieder. 5. Nach dem Ende des Empfangsfensters und ohne Suche: „Funk aus“, der ESP bleibt kühl. -6. Allgemein → „Beim Anmelden im Hintergrund starten“, abmelden und wieder - anmelden: kein Fenster, nur das Tray-Symbol; die App ist dauerhaft +6. Allgemein → „Beim Anmelden im Hintergrund starten“ (ab dem ersten Start + an), abmelden und wieder anmelden: kein Fenster, nur das Tray-Symbol; die App ist dauerhaft empfangsbereit (bei Firmware mit `power listen` schläft der Funk dabei). Vom iPad etwas senden: nur die Annehmen-Karte erscheint, danach eine Benachrichtigung „Empfangen: …“ (Klick öffnet die Datei). -7. Allgemein → „Im Rechtsklickmenü des Explorers anbieten“. Im Explorer - Dateien markieren, Rechtsklick → „Senden an“ → „EspDrop (AirDrop)“ +7. Allgemein → „Im Rechtsklickmenü des Explorers anbieten“ (ab dem ersten + Start an). Im Explorer Dateien markieren, Rechtsklick → „Senden an“ → „EspDrop (AirDrop)“ (Windows 11: erst „Weitere Optionen anzeigen“) oder „Mit EspDrop senden“: das AirDrop-Blatt öffnet sich, das iPad erscheint, anklicken, am iPad annehmen; das Blatt schließt sich nach „Gesendet an …“. @@ -609,14 +612,52 @@ während der Suche, eine schwebende Karte zum Annehmen und ein AirDrop-Blatt zum Senden. Ohne Bild- oder Schriftdateien; die Schrift ist die des Systems (Windows 11: Segoe UI Variable). +### Vor dem ersten Release + +- Das Teilen-Menü des Empfängers muss beim Senden nicht offen sein (mit dem + Wecksignal des ESP bestätigt); alle Hinweise darauf sind entfernt. +- Autostart und Rechtsklickmenü sind ab dem ersten Start an (einmalig; wer + sie ausschaltet, behält sie aus). +- Empfangene Dateien landen direkt in `Downloads`. OpenDrop entpackt in einen + versteckten Ordner `Downloads/.espdrop-incoming` und würde dort gleichnamige + Dateien ersetzen; die App verschiebt jede Übertragung danach unter einem + freien Namen heraus (`IMG_1.JPG`, `IMG_1 (1).JPG`), es wird nichts + überschrieben. Was ein Absturz dort liegen lässt, kommt beim nächsten Start + heraus. +- Symbol: Die App schreibt ihr Symbol bei jedem Start nach + `%LOCALAPPDATA%\EspDrop\espdrop.ico` (Linux: + `~/.local/share/espdrop/espdrop.png`); Rechtsklickmenü, „Senden an“ und die + Linux-Einträge zeigen darauf. In der Taskleiste erscheint es über eine eigene + AppUserModelID (`EspDrop.EspDrop`), statt als `pythonw.exe` mit dem + Python-Symbol. +- Allgemein → „Erscheinungsbild: Automatisch / Hell / Dunkel“. +- AirDrop → „In die Zwischenablage kopieren“ (aus, wenn nicht eingeschaltet): + Empfangene Dateien liegen danach auch in der Zwischenablage (im Explorer + einfügen), ein einzelnes Foto zusätzlich als Bild (in einen Chat einfügen), + ein Link als Text. + +Test unter Windows: +1. Einmal starten: Allgemein zeigt beide Schalter an. Rechtsklick auf eine + Datei → „Weitere Optionen anzeigen“: „Mit EspDrop senden“ und „Senden an → + EspDrop (AirDrop)“ tragen den blauen Tropfen. Die Taskleiste zeigt ihn + ebenso (nicht das Python-Symbol). +2. Dasselbe Foto zweimal vom iPhone senden: in `Downloads` liegen `IMG_….JPG` + und `IMG_… (1).JPG`, keine Datei wurde ersetzt. +3. „In die Zwischenablage kopieren“ an, ein Foto empfangen: im Explorer + Strg+V fügt die Datei ein, in Paint oder einem Chat das Bild. +4. Erscheinungsbild „Dunkel“ und „Hell“ umschalten: das Fenster wechselt + sofort, auch die Titelleiste. + Tests ohne Hardware: `test_session.py` (Suche mit Namen, Senden mit Fortschritt und Abbrechen, Empfangen mit Annehmen/Ablehnen/Zeitüberschreitung, -Empfangsfenster, `power listen` bei scan-fähiger Firmware, Neuverbinden) und -`test_gui.py` (das Fenster offscreen: suchen und senden, Dialog annehmen und -ablehnen, Hintergrundbetrieb, AirDrop-Blatt mit Dateien aus zwei Aufrufen, -nur eine laufende App) und `test_integration.py` (Autostart und -Dateimanager-Menü: Linux echt in Temp-Ordnern, Windows-Registry und -„Senden an“ nachgebildet). Zusammen 59 Host-Tests. +Empfangsfenster, `power listen` bei scan-fähiger Firmware, Neuverbinden, +nichts überschreiben, Reste aus dem Zwischenordner) und `test_gui.py` (das +Fenster offscreen: suchen und senden, Dialog annehmen und ablehnen, +Hintergrundbetrieb, AirDrop-Blatt mit Dateien aus zwei Aufrufen, nur eine +laufende App, Erscheinungsbild, Zwischenablage, Symboldatei) und +`test_integration.py` (Autostart und Dateimanager-Menü: Linux echt in +Temp-Ordnern, Windows-Registry und „Senden an“ nachgebildet, Voreinstellungen, +Symbol). Zusammen 68 Host-Tests. ## Danach diff --git a/docs/UEBERBLICK.md b/docs/UEBERBLICK.md index cc5694a..244a29a 100644 --- a/docs/UEBERBLICK.md +++ b/docs/UEBERBLICK.md @@ -62,10 +62,13 @@ Bekannte Schwächen von Upstream-OWL, die im Fork von jedbillyb behoben sind: - Das iPhone muss auf **„Jeder für 10 Minuten“** stehen. „Nur Kontakte“ braucht ein Apple-signiertes Validation Record, das man nur von einem echten Mac extrahieren kann. -- **Empfangen** (iPhone → PC): Das Share-Sheet muss offen sein, dann ist AWDL wach. +- **Empfangen** (iPhone → PC): Das iPhone hält AWDL wach, solange sein Teilen-Menü + offen ist, also während man dort den PC als Ziel wählt. - **Senden** (PC → iPhone): Das iPhone annonciert `_airdrop._tcp` nur, solange ein BLE-Continuity-AirDrop-Advertisement läuft (§27, nachgewiesen). Bei „Jeder“ dürfen die Hash-Felder Nullen sein. + Das Teilen-Menü des iPhones muss dafür nicht offen sein (mit dem Wecksignal vom + ESP getestet: AirDrop „Für alle“ und entsperrt genügt). - Nötige OpenDrop-Änderungen: Chunked Requests ohne `Content-Length`, Container `application/x-dvzip` (Blöcke mit 4-Byte-BE-Header, Bit 31 = stored, sonst zlib, 128-KiB-Blöcke → ODC-cpio), `TransferID` + `TransferType` schon im `/Ask`. diff --git a/firmware/spikes/main/ble_wake.c b/firmware/spikes/main/ble_wake.c index b5a9c8f..bed3713 100644 --- a/firmware/spikes/main/ble_wake.c +++ b/firmware/spikes/main/ble_wake.c @@ -18,10 +18,10 @@ #include "nimble/nimble_port.h" #include "nimble/nimble_port_freertos.h" -/* 200-250 ms: often enough that a phone scanning with its share sheet open sees - * the advert within a second, rare enough that BLE costs AWDL little: it shares - * the radio with Wi-Fi and, on the C5 parked on 5 GHz, needs a band switch for - * every advertising event. */ +/* 200-250 ms: often enough that a phone nearby sees the advert within a + * second, rare enough that BLE costs AWDL little: it shares the radio with + * Wi-Fi and, on the C5 parked on 5 GHz, needs a band switch for every + * advertising event. */ #define WAKE_ITVL_MIN BLE_GAP_ADV_ITVL_MS(200) #define WAKE_ITVL_MAX BLE_GAP_ADV_ITVL_MS(250) #define WAKE_ADV_LEN 24 diff --git a/firmware/spikes/main/ble_wake.h b/firmware/spikes/main/ble_wake.h index 0038800..090a54e 100644 --- a/firmware/spikes/main/ble_wake.h +++ b/firmware/spikes/main/ble_wake.h @@ -2,9 +2,10 @@ * EspDrop - AirDrop wake over the chip's own Bluetooth LE. * * To be found as a SENDER's target, an iPhone/iPad must hear a Continuity - * "AirDrop" advertisement (Apple manufacturer data, type 0x05) while its share - * sheet is open; only then does it answer for _airdrop._tcp over AWDL - * (airdrop-mt7921 FINDINGS §27). This sends that advert from the ESP itself, + * "AirDrop" advertisement (Apple manufacturer data, type 0x05); only then does + * it answer for _airdrop._tcp over AWDL (airdrop-mt7921 FINDINGS §27). Its own + * share sheet need not be open (tested with this advert from the ESP: AirDrop + * "Everyone" and unlocked is enough). This sends that advert from the ESP itself, * the same bytes as tools/airdrop_wake.py sends from the PC's Bluetooth. * * The advert is always timed: it stops by itself after the requested time, so diff --git a/host/README.md b/host/README.md index d1f7628..e6fbabc 100644 --- a/host/README.md +++ b/host/README.md @@ -31,14 +31,20 @@ Only one program can own the serial port: stop `tools/awdl_bridge.py` first. `espdrop gui` (Windows: `.\tools\espdrop-gui.cmd`, no console window) opens one window that owns the ESP for as long as it runs. It is drawn in the look of Apple's own apps (`espdrop/ui.py`: rounded cards, switches, segmented -controls, round device avatars; light and dark follow the system), with no -image or font files: text uses the system face (Segoe UI Variable on Windows -11, Inter or the desktop font on Linux). +controls, round device avatars), with no image or font files: text uses the +system face (Segoe UI Variable on Windows 11, Inter or the desktop font on +Linux), the icon is drawn in code. - **AirDrop:** "Empfangen von: Aus / 10 Minuten / Für alle", as on the iPhone; - the name others see; where received files go (default `Downloads/EspDrop`). - Every incoming transfer asks first, in a floating card (picture, sender, - files; Ablehnen / Annehmen; no answer in 60 s = declined). + the name others see; where received files go (default: the Downloads folder + itself). Every incoming transfer asks first, in a floating card (picture, + sender, files; Ablehnen / Annehmen; no answer in 60 s = declined). Nothing + gets overwritten: OpenDrop unpacks into a hidden folder + (`Downloads/.espdrop-incoming`), and each file then moves next to the others + under a free name (`IMG_1.JPG`, `IMG_1 (1).JPG`). "In die Zwischenablage + kopieren" (off by default) also puts what arrived on the clipboard: files to + paste in Explorer or the file manager, a single photo also as a picture, a + link as text. - **Geräte in der Nähe:** "Suchen" (60 s, with the ESP's Bluetooth wake) shows the devices as avatars with their names; drop files on one or click it. A ring around the avatar shows the wait for Accept and the progress. @@ -47,15 +53,25 @@ image or font files: text uses the system face (Segoe UI Variable on Windows with the sleep mode, docs/SPIKES.md); the line under the title says which. - Closing the window keeps it in the tray; "Beenden" (the "…" button or the tray) ends it. Unplugging the ESP is fine: it reconnects when it comes back. +- **Allgemein → "Erscheinungsbild": Automatisch / Hell / Dunkel.** Automatisch + follows the system; the choice also sets the title bar on Windows (Qt 6.8+). - **Allgemein → "Beim Anmelden im Hintergrund starten"** (Windows: - `HKCU\…\CurrentVersion\Run`; Linux: `~/.config/autostart`). It then starts - as `espdrop gui --background`: tray only, visible to everyone until switched - off, and only an incoming transfer brings up its card; a notification says - when files arrived (click: open them). -- **Allgemein → "Im Rechtsklickmenü des Explorers anbieten"** adds "Senden an → - EspDrop (AirDrop)" and "Mit EspDrop senden" (on Windows 11 under "Weitere - Optionen anzeigen"); on Linux "Öffnen mit → AirDrop (EspDrop)". Either opens - the AirDrop sheet: the devices a search finds, one click sends. + `HKCU\…\CurrentVersion\Run`; Linux: `~/.config/autostart`), on by default. + It then starts as `espdrop gui --background`: tray only, visible to everyone + until switched off, and only an incoming transfer brings up its card; a + notification says when files arrived (click: open them). +- **Allgemein → "Im Rechtsklickmenü des Explorers anbieten"**, on by default, + adds "Mit EspDrop senden" and "Senden an → EspDrop (AirDrop)" (on Windows 11 + under "Weitere Optionen anzeigen"); on Linux "Öffnen mit → AirDrop + (EspDrop)". Either opens the AirDrop sheet: the devices a search finds, one + click sends. +- **Defaults and the icon:** the very first start switches both entries on + (once; switched off they stay off). Every start writes the icon to + `%LOCALAPPDATA%\EspDrop\espdrop.ico` (Linux: + `~/.local/share/espdrop/espdrop.png`) for the menu entries and points them + at this copy of the app again, should it have moved. On Windows the app + sets its own taskbar identity (AppUserModelID `EspDrop.EspDrop`), so the + taskbar shows our icon instead of Python's. - **One copy per user:** a second start (autostart, the file manager, the launcher) hands its files to the running app over a local socket and exits. diff --git a/host/espdrop/cli.py b/host/espdrop/cli.py index cb1872b..0957afb 100644 --- a/host/espdrop/cli.py +++ b/host/espdrop/cli.py @@ -103,7 +103,7 @@ def packet(src, mcast, what): b = Browser(dev, on_found=found, on_gone=gone, on_packet=packet if args.verbose else None) print(f"browsing {AIRDROP} on the ESP's AWDL link for {args.time:.0f}s " - f"(target: AirDrop Everyone, unlocked, share sheet OPEN) ...", flush=True) + f"(target: AirDrop Everyone, unlocked) ...", flush=True) b.start() try: end = time.monotonic() + args.time @@ -118,7 +118,7 @@ def packet(src, mcast, what): print(f"{len(rs)} receiver(s); {b.queries_sent} queries sent, " f"{b.packets} mDNS messages received, {b.invalid} unreadable", flush=True) if not rs: - print(" - nothing: is the target on AirDrop Everyone with its share sheet open?\n" + print(" - nothing: is the target unlocked and on AirDrop Everyone?\n" " `espdrop status` shows whether the ESP hears it at all (peers, rx).", flush=True) return 0 if rs else 1 @@ -181,7 +181,7 @@ def packet(src, mcast, what): t0 = time.monotonic() print(f"looking for {args.receiver or 'receivers'} " - f"(target: AirDrop Everyone, unlocked, share sheet OPEN) ...", flush=True) + f"(target: AirDrop Everyone, unlocked) ...", flush=True) hits = find_receivers(dev, timeout=args.time, want=args.receiver, name=args.name, on_packet=packet if args.verbose else None) if not hits: diff --git a/host/espdrop/gui.py b/host/espdrop/gui.py index c7b45e7..3a8f6c2 100644 --- a/host/espdrop/gui.py +++ b/host/espdrop/gui.py @@ -31,6 +31,7 @@ import threading import time +import shiboken6 from PySide6.QtCore import QObject, QSettings, Qt, QTimer, QUrl, Signal from PySide6.QtGui import QColor, QDesktopServices, QFont, QGuiApplication, QPixmap from PySide6.QtNetwork import QLocalServer, QLocalSocket @@ -47,6 +48,9 @@ # None: until switched off). VISIBILITY = [("Aus", 0), ("10 Minuten", 600), ("Für alle", None)] +THEMES = [("Automatisch", "auto"), ("Hell", "light"), ("Dunkel", "dark")] +THEME_KEYS = [key for _, key in THEMES] + SEND_STATES = {"new": "Warten …", "asking": "Warten auf Annahme …", "uploading": "Senden …", "done": "Gesendet", "declined": "Abgelehnt", "failed": "Fehlgeschlagen", "cancelled": "Abgebrochen"} @@ -128,12 +132,17 @@ def send_to_running(message, name=None, timeout_ms=1000): class InstanceServer(QObject): - """The running copy's end: each later start sends one JSON line.""" + """The running copy's end: each later start sends one JSON line. + + The sockets of finished starts are deleted here, explicitly, at the next + start and in close(), never with deleteLater: a deferred delete still + queued when the server goes (quit) deletes the socket a second time.""" message = Signal(object) def __init__(self, name=None): super().__init__() self.name = name or instance_name() + self._conns = [] # [socket, bytes so far] per later start self.server = QLocalServer(self) self.server.setSocketOptions(QLocalServer.SocketOption.UserAccessOption) if not self.server.listen(self.name): @@ -144,28 +153,44 @@ def __init__(self, name=None): self.server.newConnection.connect(self._accept) def _accept(self): + self._prune() while self.server.hasPendingConnections(): sock = self.server.nextPendingConnection() - buf = bytearray() - - def read(sock=sock, buf=buf): - buf.extend(bytes(sock.readAll())) - if b"\n" not in buf: - return - line = bytes(buf).split(b"\n", 1)[0] - buf.clear() + self._conns.append([sock, bytearray()]) + sock.readyRead.connect(self._read) # a method: no closure per socket + self._read() # what came in before the connect + + def _read(self): + for sock, buf in list(self._conns): # a handler may start a nested loop + if not shiboken6.isValid(sock): + continue + buf.extend(bytes(sock.readAll())) + while b"\n" in buf: + line, _, rest = bytes(buf).partition(b"\n") + buf[:] = rest try: msg = json.loads(line.decode("utf-8")) except ValueError: - return + continue if isinstance(msg, dict): self.message.emit(msg) - sock.readyRead.connect(read) - sock.disconnected.connect(sock.deleteLater) - read() + + def _prune(self, everything=False): + keep = [] + for conn in self._conns: + sock = conn[0] + if not shiboken6.isValid(sock): + continue + if everything or sock.state() == QLocalSocket.LocalSocketState.UnconnectedState: + shiboken6.delete(sock) + else: + keep.append(conn) + self._conns = keep def close(self): self.server.close() + self._read() + self._prune(everything=True) class Bridge(QObject): @@ -418,7 +443,7 @@ def on_device(self, connected): if not self._browsing and self.transfer_id is None: self._browsing = True self.status.setText("Suche nach Geräten in der Nähe …\nAm iPhone oder iPad: " - "AirDrop „Für alle“, entsperrt, Teilen-Menü offen.") + "AirDrop „Für alle“, entsperrt.") run_bg(self.w.session.browse, 60, on_error=self.w._log) self._update_view() @@ -496,6 +521,7 @@ def __init__(self, port=None, settings=None, session_factory=None, ask_timeout=6 super().__init__() ui.ensure(QApplication.instance()) self.settings = settings or QSettings(APP, APP) + ui.set_mode(QApplication.instance(), self._theme_key()) self.setWindowTitle(APP) self.setWindowIcon(ui.app_icon()) self.resize(560, 800) @@ -621,6 +647,13 @@ def _build(self): fh.addWidget(change) fh.addWidget(show) card.add(folder, margins=(16, 9, 12, 9)) + self.clipboard_switch = ui.Switch() + self.clipboard_switch.set_quiet(self.settings.value("clipboard", False, type=bool)) + self.clipboard_switch.toggled.connect( + lambda on: self.settings.setValue("clipboard", bool(on))) + card.add_row("In die Zwischenablage kopieren", self.clipboard_switch, + "Empfangenes zusätzlich zum Einfügen bereithalten: Bilder, Dateien " + "und Links.") col.addWidget(card) # devices nearby @@ -635,8 +668,8 @@ def _build(self): self.radar = ui.Radar() self.radar_text = ui.label("Suche nach Geräten in der Nähe …", "secondary", center=True) self.devices_note = ui.label("„Suchen“ findet AirDrop-Geräte in der Nähe. Am iPhone " - "oder iPad: AirDrop „Für alle“, entsperrt, Teilen-Menü " - "offen.", "secondary", wrap=True, center=True) + "oder iPad: AirDrop „Für alle“, entsperrt.", + "secondary", wrap=True, center=True) self.devices_note.setContentsMargins(12, 10, 12, 10) self.devices = ui.DeviceGrid() self.devices.clicked.connect(self._tile_clicked) @@ -659,22 +692,25 @@ def _build(self): # general caps = integration.supported() self.autostart_switch = self.send_menu_switch = None - if caps["autostart"] or caps["send"]: - col.addWidget(ui.section("Allgemein")) - gcard = ui.Card() - if caps["autostart"]: - s = self.autostart_switch = ui.Switch() - s.set_quiet(_safe(integration.autostart_enabled, False)) - s.toggled.connect(lambda on, s=s: self._integrate(integration.set_autostart, on, s)) - gcard.add_row("Beim Anmelden im Hintergrund starten", s, - "Unsichtbar im Infobereich und für alle sichtbar; nur eingehende " - "Dateien melden sich.") - if caps["send"]: - s = self.send_menu_switch = ui.Switch() - s.set_quiet(_safe(integration.send_menu_enabled, False)) - s.toggled.connect(lambda on, s=s: self._integrate(integration.set_send_menu, on, s)) - gcard.add_row(caps["send"], s, caps.get("send_hint")) - col.addWidget(gcard) + col.addWidget(ui.section("Allgemein")) + gcard = ui.Card() + self.theme = ui.Segmented([lbl for lbl, _ in THEMES]) + self.theme.set_index(THEME_KEYS.index(self._theme_key()), animate=False) + self.theme.changed.connect(self._theme_picked) + gcard.add_row("Erscheinungsbild", self.theme) + if caps["autostart"]: + s = self.autostart_switch = ui.Switch() + s.set_quiet(_safe(integration.autostart_enabled, False)) + s.toggled.connect(lambda on, s=s: self._integrate(integration.set_autostart, on, s)) + gcard.add_row("Beim Anmelden im Hintergrund starten", s, + "Unsichtbar im Infobereich und für alle sichtbar; nur eingehende " + "Dateien melden sich.") + if caps["send"]: + s = self.send_menu_switch = ui.Switch() + s.set_quiet(_safe(integration.send_menu_enabled, False)) + s.toggled.connect(lambda on, s=s: self._integrate(integration.set_send_menu, on, s)) + gcard.add_row(caps["send"], s, caps.get("send_hint")) + col.addWidget(gcard) # the log, off unless asked for self.log = QPlainTextEdit() @@ -905,6 +941,41 @@ def _tick(self): if self.receive_left.text() != text: self.receive_left.setText(text) + # ---- appearance and the clipboard ------------------------------------------ + + def _theme_key(self): + key = self.settings.value("theme", "auto") + return key if key in THEME_KEYS else "auto" + + def _theme_picked(self, index): + key = THEME_KEYS[index] + self.settings.setValue("theme", key) + ui.set_mode(QApplication.instance(), key) + + def _to_clipboard(self, files=(), urls=()): + """Put what arrived on the clipboard, if the user wants that: files as + files (paste in Explorer or a file manager), a single picture also as + an image (paste in a chat or a document), links as text.""" + if not self.clipboard_switch.isChecked() or not (files or urls): + return False + from PySide6.QtCore import QMimeData + from PySide6.QtGui import QImage + mime = QMimeData() + if files: + qurls = [QUrl.fromLocalFile(f) for f in files] + mime.setUrls(qurls) + # GNOME's file manager pastes files only from its own format. + mime.setData("x-special/gnome-copied-files", + ("copy\n" + "\n".join(u.toString() for u in qurls)).encode("utf-8")) + if len(files) == 1 and _kind(files[0]) == "photo": + img = QImage(files[0]) + if not img.isNull(): + mime.setImageData(img) + else: + mime.setText("\n".join(urls)) + QApplication.clipboard().setMimeData(mime) + return True + # ---- receiving ------------------------------------------------------------- def _visibility_picked(self, index): @@ -947,6 +1018,10 @@ def _receive_row(self, info): if not info.get("accepted"): self._log(f"abgelehnt: {ask.summary() if ask else '?'}") return + if ask and ask.urls and not ask.files: # a link: no upload follows + copied = self._to_clipboard(urls=ask.urls) + self._notify(f"Link von {ask.sender}" + (" – in der Zwischenablage" + if copied else "")) self._recv_n += 1 row = ui.TransferRow("receive", ", ".join(ask.files or ask.urls) if ask else "?", ask.sender if ask else "?") @@ -971,7 +1046,10 @@ def _receive_row(self, info): row.set_title(names) row.set_state(f"Empfangen · {human(info.get('size', 0))}", files=files, tone="done") - self._notify(f"Empfangen: {names} – klicken zum Öffnen", files) + copied = self._to_clipboard(files=files) + self._notify(f"Empfangen: {names}" + (" – in der Zwischenablage" + if copied else " – klicken zum Öffnen"), + files) else: row.set_state("Unvollständig", files=files + ([info["raw"]] if info.get("raw") else []), @@ -1088,16 +1166,48 @@ def power_text(reply): def default_download_dir(): + """The user's Downloads folder itself (Windows' known folder, XDG on Linux).""" + from PySide6.QtCore import QStandardPaths + d = QStandardPaths.writableLocation(QStandardPaths.StandardLocation.DownloadLocation) + if d: + return os.path.normpath(d) home = os.path.expanduser("~") downloads = os.path.join(home, "Downloads") - return os.path.join(downloads if os.path.isdir(downloads) else home, "EspDrop") + return downloads if os.path.isdir(downloads) else home + + +def _set_app_id(): + """Windows groups windows by app id; without our own the taskbar shows them + as pythonw.exe with Python's icon instead of ours.""" + if os.name == "nt": + try: + import ctypes + ctypes.windll.shell32.SetCurrentProcessExplicitAppUserModelID("EspDrop.EspDrop") + except Exception: # noqa: BLE001 - cosmetic only + pass + + +def _desktop_setup(settings): + """Icon file for the desktop, then autostart and the file manager entry: + on by default at the very first start, kept pointing at this app after.""" + try: + integration.ICON = ui.write_icon_file(integration.data_dir()) + except OSError: + integration.ICON = None + if not settings.value("defaults_applied", False, type=bool): + integration.apply_defaults() # synchronous: the switches show the result + settings.setValue("defaults_applied", True) + else: + run_bg(integration.refresh) def main(port=None, background=False, send=None): """send: None (normal start) or the files of --send (may be empty).""" + _set_app_id() app = QApplication.instance() or QApplication(sys.argv) app.setApplicationName(APP) app.setQuitOnLastWindowClosed(False) + app.setWindowIcon(ui.app_icon()) ui.apply(app) files = [os.path.abspath(f) for f in (send or [])] # Already running (autostart, an earlier start)? Hand over and leave. @@ -1105,7 +1215,9 @@ def main(port=None, background=False, send=None): "show": send is None and not background}): return 0 server = InstanceServer() - w = MainWindow(port=port, background=background) + settings = QSettings(APP, APP) + _desktop_setup(settings) + w = MainWindow(port=port, settings=settings, background=background) server.message.connect(w.handle_message) if send is not None: w.open_send_dialog(files) diff --git a/host/espdrop/integration.py b/host/espdrop/integration.py index ae1d879..e8bb824 100644 --- a/host/espdrop/integration.py +++ b/host/espdrop/integration.py @@ -30,6 +30,7 @@ winreg = None APP = "EspDrop" +ICON = None # our icon file, .ico on Windows (gui.py writes it at start) RUN_KEY = r"Software\Microsoft\Windows\CurrentVersion\Run" MENU_KEY = r"Software\Classes\*\shell\EspDrop" SENDTO_NAME = "EspDrop (AirDrop).lnk" @@ -51,6 +52,15 @@ def launch_command(*args): return [exe, os.path.join(root, "tools", "espdrop.py"), "gui", *args] +def data_dir(): + """Per-user folder for files the desktop needs from us (the icon).""" + if IS_WINDOWS: + base = os.environ.get("LOCALAPPDATA") or os.path.expanduser(r"~\AppData\Local") + return os.path.join(base, APP) + base = os.environ.get("XDG_DATA_HOME") or os.path.expanduser("~/.local/share") + return os.path.join(base, "espdrop") + + def supported(): """What this system offers: {"autostart": bool, "send": label or None, "send_hint": what the label switches on}.""" @@ -86,7 +96,7 @@ def set_autostart(on): _write_desktop(path, { "Type": "Application", "Name": APP, "Comment": "AirDrop über den ESP32 – empfangsbereit im Hintergrund", - "Exec": _exec_line(cmd), "Terminal": "false", + "Exec": _exec_line(cmd), "Icon": ICON or "document-send", "Terminal": "false", "X-GNOME-Autostart-enabled": "true"}) elif os.path.exists(path): os.remove(path) @@ -106,8 +116,8 @@ def set_send_menu(on): return path = _xdg_send() if on: - _write_desktop(path, { - "Type": "Application", "Name": "AirDrop (EspDrop)", "Icon": "document-send", + changed = _write_desktop(path, { + "Type": "Application", "Name": "AirDrop (EspDrop)", "Icon": ICON or "document-send", "Comment": "Dateien per AirDrop an ein iPhone/iPad senden", "Exec": _exec_line(launch_command("--send")) + " %F", "Terminal": "false", "NoDisplay": "true", @@ -115,7 +125,11 @@ def set_send_menu(on): "MimeType": ";".join(LINUX_MIME) + ";"}) elif os.path.exists(path): os.remove(path) - _refresh_desktop_db(os.path.dirname(path)) + changed = True + else: + changed = False + if changed: + _refresh_desktop_db(os.path.dirname(path)) LINUX_MIME = ["application/octet-stream", "application/pdf", "application/zip", @@ -127,23 +141,27 @@ def _set_windows_send(on): cmd = launch_command("--send") lnk = _sendto_path() if on: + # The right-click entry first: plain registry values, it works even + # where PowerShell below is blocked (the error still reaches the caller). + _reg_set(MENU_KEY, "", MENU_TEXT) + _reg_set(MENU_KEY, "Icon", ICON or cmd[0]) + _reg_set(MENU_KEY + r"\command", "", subprocess.list2cmdline(cmd) + ' "%1"') # Explorer appends the selected files to the shortcut's arguments. # WScript.Shell writes the .lnk; the values go in through the # environment, so no quoting of paths inside PowerShell code. env = dict(os.environ, ESPDROP_LNK=lnk, ESPDROP_EXE=cmd[0], ESPDROP_ARGS=subprocess.list2cmdline(cmd[1:]), - ESPDROP_DIR=os.path.dirname(cmd[1]) if len(cmd) > 1 else "") + ESPDROP_DIR=os.path.dirname(cmd[1]) if len(cmd) > 1 else "", + ESPDROP_ICON=ICON or "") script = ("$s = (New-Object -ComObject WScript.Shell).CreateShortcut($env:ESPDROP_LNK);" "$s.TargetPath = $env:ESPDROP_EXE; $s.Arguments = $env:ESPDROP_ARGS;" "$s.WorkingDirectory = $env:ESPDROP_DIR;" + "if ($env:ESPDROP_ICON) { $s.IconLocation = $env:ESPDROP_ICON + ',0' };" "$s.Description = 'Per AirDrop senden (EspDrop)'; $s.Save()") os.makedirs(os.path.dirname(lnk), exist_ok=True) subprocess.run(["powershell", "-NoProfile", "-NonInteractive", "-Command", script], env=env, check=True, capture_output=True, creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0)) - _reg_set(MENU_KEY, "", MENU_TEXT) - _reg_set(MENU_KEY, "Icon", cmd[0]) - _reg_set(MENU_KEY + r"\command", "", subprocess.list2cmdline(cmd) + ' "%1"') else: if os.path.exists(lnk): os.remove(lnk) @@ -155,6 +173,42 @@ def _sendto_path(): return os.path.join(appdata, "Microsoft", "Windows", "SendTo", SENDTO_NAME) +def refresh(): + """Keep enabled entries pointing at the app as it runs now: a checkout or + venv that moved, a new icon. Rewrites only what differs (on Windows the + "Senden an" shortcut costs a PowerShell start).""" + cmd = launch_command("--background") + if autostart_enabled(): + if IS_WINDOWS: + if _reg_get(RUN_KEY, APP) != subprocess.list2cmdline(cmd): + set_autostart(True) + else: + set_autostart(True) + if send_menu_enabled(): + if IS_WINDOWS: + want = subprocess.list2cmdline(launch_command("--send")) + ' "%1"' + if (_reg_get(MENU_KEY + r"\command", "") != want + or _reg_get(MENU_KEY, "Icon") != (ICON or launch_command()[0]) + or not os.path.isfile(_sendto_path())): + set_send_menu(True) + else: + set_send_menu(True) + + +def apply_defaults(): + """First start: autostart and the file manager entry are on by default + (the user can switch them off; this runs only once). Returns what failed.""" + errors = [] + caps = supported() + for ok, fn in ((caps["autostart"], set_autostart), (caps["send"], set_send_menu)): + if ok: + try: + fn(True) + except Exception as e: # noqa: BLE001 - reported, not fatal + errors.append(f"{fn.__name__}: {e}") + return errors + + # ---- registry (HKCU only) ------------------------------------------------------ def _reg_get(key, name): @@ -221,13 +275,20 @@ def _exec_line(argv): def _write_desktop(path, fields): + """Write a desktop entry; False when it already had this content.""" + text = "[Desktop Entry]\n" + "".join(f"{k}={v}\n" for k, v in fields.items()) + try: + with open(path, encoding="utf-8") as f: + if f.read() == text: + return False + except OSError: + pass os.makedirs(os.path.dirname(path), exist_ok=True) tmp = path + ".tmp" with open(tmp, "w", encoding="utf-8") as f: - f.write("[Desktop Entry]\n") - for k, v in fields.items(): - f.write(f"{k}={v}\n") + f.write(text) os.replace(tmp, path) + return True def _refresh_desktop_db(directory): diff --git a/host/espdrop/receive.py b/host/espdrop/receive.py index 302bad0..c1f87d1 100644 --- a/host/espdrop/receive.py +++ b/host/espdrop/receive.py @@ -19,6 +19,7 @@ import logging import os import plistlib +import shutil import socket import threading import time @@ -97,22 +98,57 @@ def __getattr__(self, name): return getattr(self._f, name) +def unique_path(folder, name): + """folder/name, or 'name (1).ext', 'name (2).ext' … if that is taken.""" + path = os.path.join(folder, name) + stem, ext = os.path.splitext(name) + n = 1 + while os.path.lexists(path): + path = os.path.join(folder, f"{stem} ({n}){ext}") + n += 1 + return path + + +def move_out(staging, names, dest): + """Move staging/name for each name into dest under a free name; returns + the new paths. Nothing in dest is ever replaced. What cannot move (a file + still locked on Windows) stays in staging, under its staging path, and + moves out at the next start.""" + moved = [] + for name in names: + src = os.path.join(staging, name) + target = unique_path(dest, name) + try: + shutil.move(src, target) # a rename; copy + remove across volumes + except OSError as e: + logger.warning("could not move %s to %s: %s", name, dest, e) + target = src + moved.append(target) + return moved + + class Receiver: """Receive over the device: mDNS responder, listener, local OpenDrop server. on_ask(IncomingAsk) -> bool decides each transfer (None accepts all, as OpenDrop does); it runs on the server thread, which the sender waits on. on_event(kind, **info) gets "asked" (ask, accepted), "receiving" (ask, - received, total) and "received" (ask, files, ok, raw, size, secs). Files - land in the current directory.""" + received, total) and "received" (ask, files, ok, raw, size, secs). + + OpenDrop unpacks into the current directory and replaces what is there. + With dest_dir set, the current directory is only a staging area: each + transfer's files then move to dest_dir under free names ('IMG_1.JPG', + 'IMG_1 (1).JPG'), so nothing the user has gets overwritten. Without it, + files stay in the current directory (the command line's -d).""" def __init__(self, dev, name="EspDrop", flags=0x3fb, on_ask=None, on_event=None, - announce_interval=5.0): + announce_interval=5.0, dest_dir=None): self.dev = dev env.ensure_identity(name) # before OpenDrop would call openssl self.config = AirDropConfig(computer_name=name) self.config.flags = flags self.announce_interval = announce_interval + self.dest_dir = dest_dir self.on_ask = on_ask self.on_event = on_event or (lambda kind, **info: None) self.httpd = None @@ -185,11 +221,18 @@ def on_bytes(n): finally: handler.rfile = rfile new = sorted(set(os.listdir(here)) - before) - raw = [n for n in new if n.startswith(RAW_PREFIX)] - files = [os.path.join(here, n) for n in new if not n.startswith(RAW_PREFIX)] + raw_names = [n for n in new if n.startswith(RAW_PREFIX)] + names = [n for n in new if not n.startswith(RAW_PREFIX)] + dest = self.dest_dir + if dest and os.path.realpath(dest) != os.path.realpath(here): + files = move_out(here, names, dest) + raw = move_out(here, raw_names, dest) + else: + files = [os.path.join(here, n) for n in names] + raw = [os.path.join(here, n) for n in raw_names] ok = bool(files) and not raw and not any(f.endswith(".partial") for f in files) self.on_event("received", ask=ask, files=files, ok=ok, - raw=os.path.join(here, raw[0]) if raw else None, + raw=raw[0] if raw else None, size=got[0], secs=time.monotonic() - start) # ---- one accepted AWDL connection -------------------------------------- diff --git a/host/espdrop/session.py b/host/espdrop/session.py index a5a8981..8f9764b 100644 --- a/host/espdrop/session.py +++ b/host/espdrop/session.py @@ -32,12 +32,23 @@ logger = logging.getLogger(__name__) +STAGING = ".espdrop-incoming" # inside the download folder: OpenDrop unpacks here first RECONNECT_SECS = 2.0 KEEPALIVE_SECS = 30.0 BROWSE_SECS = 60.0 HOLD_SECS = 90 # `power hold` per keepalive: the radio stays on while needed +def _hide(path): + """Hidden on Windows too (a leading dot only hides it on Linux and macOS).""" + if os.name == "nt": + try: + import ctypes + ctypes.windll.kernel32.SetFileAttributesW(str(path), 0x02) # FILE_ATTRIBUTE_HIDDEN + except Exception: # noqa: BLE001 - cosmetic only + pass + + class ReceiverInfo: """An AirDrop receiver seen by the browse, with its name once /Discover told it.""" @@ -120,8 +131,7 @@ def start(self): if self._thread: return if self.download_dir: - os.makedirs(self.download_dir, exist_ok=True) - os.chdir(self.download_dir) # OpenDrop's server writes into cwd + self.set_download_dir(self.download_dir) self._stop.clear() self._thread = threading.Thread(target=self._run, name="espdrop-session", daemon=True) self._thread.start() @@ -164,11 +174,23 @@ def set_name(self, name): self._start_receiver() def set_download_dir(self, path): - """Where received files go (OpenDrop's server writes into the current - directory, so this changes it for the whole process).""" + """Where received files go. OpenDrop's server unpacks into the current + directory (for the whole process) and would replace files of the same + name, so it unpacks into a hidden staging folder inside `path`, and + the receiver moves each transfer out under free names (receive.py). + Anything a crash left in the staging folder moves out now.""" + from .receive import move_out os.makedirs(path, exist_ok=True) - os.chdir(path) + staging = os.path.join(path, STAGING) + os.makedirs(staging, exist_ok=True) + _hide(staging) + leftovers = sorted(os.listdir(staging)) + if leftovers: + move_out(staging, leftovers, path) + os.chdir(staging) self.download_dir = path + if self._receiver: + self._receiver.dest_dir = path def receivers(self): with self._lock: @@ -349,7 +371,8 @@ def _start_receiver(self): # an AirDrop advert, the phone should discover us within its share-sheet # window (it also answers the phone's own queries once awake). self._receiver = Receiver(self.dev, name=self.name, on_ask=self._ask, - on_event=self._on_receive, announce_interval=2.5) + on_event=self._on_receive, announce_interval=2.5, + dest_dir=self.download_dir) self._receiver.start() def _stop_receiver(self): diff --git a/host/espdrop/ui.py b/host/espdrop/ui.py index 52a9366..5e1c935 100644 --- a/host/espdrop/ui.py +++ b/host/espdrop/ui.py @@ -49,6 +49,7 @@ "avatar_top": "#8E8E93", "avatar_bottom": "#636366", "shadow_alpha": 90, } T = dict(LIGHT) # the colours in use; widgets read them when painting +MODE = "auto" # the app's choice: "auto" (system), "light", "dark" def color(key, alpha=None): @@ -59,6 +60,8 @@ def color(key, alpha=None): def is_dark(): + if MODE in ("light", "dark"): + return MODE == "dark" forced = os.environ.get("ESPDROP_THEME", "").lower() if forced in ("dark", "light"): return forced == "dark" @@ -106,10 +109,29 @@ def refresh(app): app.setFont(font(13)) app.setPalette(palette()) app.setStyleSheet(qss()) + for w in app.allWidgets(): + glyph = w.property("glyph") if isinstance(w, QToolButton) else None + if glyph: # line icons are drawn in the text colour + w.setIcon(glyph_icon(glyph, T["secondary"], 18)) for w in app.topLevelWidgets(): w.update() +def set_mode(app, mode): + """Hell, Dunkel or Automatisch (the system's), from now on. Also tells Qt, + which then gives Windows windows a matching title bar (Qt 6.8+).""" + global MODE + MODE = mode if mode in ("light", "dark") else "auto" + try: + scheme = {"light": Qt.ColorScheme.Light, "dark": Qt.ColorScheme.Dark}.get( + MODE, Qt.ColorScheme.Unknown) + app.styleHints().setColorScheme(scheme) + except AttributeError: # Qt before 6.8 + pass + if app.property("espdrop_theme"): + refresh(app) + + def palette(): p = QPalette() roles = { @@ -337,6 +359,88 @@ def app_icon(): return icon +def _icon_image(px): + """Our mark, exactly px x px, straight (not premultiplied) alpha.""" + from PySide6.QtGui import QImage + img = QImage(px, px, QImage.Format.Format_ARGB32_Premultiplied) + img.fill(Qt.GlobalColor.transparent) + p = QPainter(img) + p.setRenderHint(QPainter.RenderHint.Antialiasing) + paint_app_icon(p, QRectF(px * 0.04, px * 0.04, px * 0.92, px * 0.92)) + p.end() + return img.convertToFormat(QImage.Format.Format_ARGB32) + + +def _png_bytes(px): + """Our mark as PNG data (icon files).""" + from PySide6.QtCore import QBuffer, QIODevice + buf = QBuffer() + buf.open(QIODevice.OpenModeFlag.WriteOnly) + _icon_image(px).save(buf, "PNG") + return bytes(buf.data()) + + +def _dib_bytes(px): + """Our mark as an icon's classic bitmap: a BITMAPINFOHEADER of double + height, 32-bit BGRA rows bottom-up, then the 1-bit AND mask (set where + fully transparent). Every Windows icon loader reads this; PNG data is + only safe in the 256 px entry.""" + import struct + img = _icon_image(px) + stride = img.bytesPerLine() + bits = bytes(img.constBits())[:stride * px] # B G R A per pixel (little endian) + rows = [bits[y * stride:y * stride + 4 * px] for y in range(px)] + mask_stride = (px + 31) // 32 * 4 + mask = bytearray() + for row in reversed(rows): + line = bytearray(mask_stride) + for x in range(px): + if row[4 * x + 3] == 0: + line[x // 8] |= 0x80 >> (x % 8) + mask += line + xor = b"".join(reversed(rows)) + header = struct.pack("= 256 else _dib_bytes(s) for s in sizes] + out = struct.pack("= 256 else s # 0 means 256 in an icon directory + out += struct.pack(" 1000) + before = os.path.getmtime(path) + time.sleep(0.01) + self.assertEqual(ui.write_icon_file(self.tmp.name), path) + self.assertEqual(os.path.getmtime(path), before) # unchanged: not rewritten + d = self.gui.default_download_dir() + self.assertTrue(os.path.isabs(d)) + self.assertNotIn("EspDrop", d) # Downloads itself, no subfolder + def test_power_text(self): t = self.gui.power_text self.assertEqual(t("power auto asleep (none) hold=0s wake=0s"), "Funk aus (spart Strom)") diff --git a/host/tests/test_integration.py b/host/tests/test_integration.py index e887922..0990b40 100644 --- a/host/tests/test_integration.py +++ b/host/tests/test_integration.py @@ -122,6 +122,17 @@ def test_open_with_entry(self): integration.set_send_menu(False) self.assertFalse(integration.send_menu_enabled()) + def test_defaults_and_icon(self): + # First start: both on; the entries carry our icon file. + with mock.patch.object(integration, "ICON", "/x/espdrop.png"): + self.assertEqual(integration.apply_defaults(), []) + self.assertTrue(integration.autostart_enabled()) + self.assertTrue(integration.send_menu_enabled()) + entry = self.read(os.path.join(self.tmp.name, "data", "applications", + "espdrop-send.desktop")) + self.assertEqual(entry["Icon"], "/x/espdrop.png") + self.assertEqual(integration.data_dir(), os.path.join(self.tmp.name, "data", "espdrop")) + def test_exec_quoting(self): line = integration._exec_line(["/opt/my venv/bin/python", "/x/50%.py", "gui"]) self.assertEqual(line, '"/opt/my venv/bin/python" /x/50%%.py gui') @@ -158,6 +169,37 @@ def test_autostart_run_key(self): self.assertNotIn("EspDrop", self.reg.keys[integration.RUN_KEY]) self.assertFalse(integration.autostart_enabled()) + def test_icon_and_refresh(self): + ico = os.path.join(self.tmp.name, "EspDrop", "espdrop.ico") + with mock.patch.object(integration, "ICON", ico): + integration.set_send_menu(True) + (argv, kw), = self.runs + self.assertEqual(kw["env"]["ESPDROP_ICON"], ico) + self.assertIn("IconLocation", argv[-1]) + self.assertEqual(self.reg.keys[integration.MENU_KEY]["Icon"], ico) + integration.set_autostart(True) + # Nothing changed (and the shortcut exists): refresh leaves it alone. + os.makedirs(os.path.dirname(integration._sendto_path()), exist_ok=True) + open(integration._sendto_path(), "wb").close() + integration.refresh() + self.assertEqual(len(self.runs), 1) + # A new icon (or a moved checkout): refresh rewrites the entries. + with mock.patch.object(integration, "ICON", ico + "2"): + integration.refresh() + self.assertEqual(len(self.runs), 2) + self.assertEqual(self.reg.keys[integration.MENU_KEY]["Icon"], ico + "2") + + def test_context_menu_without_powershell(self): + # PowerShell blocked (policy): the right-click entry is there anyway, + # the error still reaches the caller. + def fail(argv, **kw): + raise subprocess.CalledProcessError(1, argv) + with mock.patch.object(integration.subprocess, "run", fail): + self.assertEqual(len(integration.apply_defaults()), 1) + self.assertEqual(self.reg.keys[integration.MENU_KEY][""], "Mit EspDrop senden") + self.assertTrue(integration.send_menu_enabled()) + self.assertTrue(integration.autostart_enabled()) + def test_send_to_and_context_menu(self): integration.set_send_menu(True) (argv, kw), = self.runs # one PowerShell call for the .lnk diff --git a/host/tests/test_session.py b/host/tests/test_session.py index 306f619..1c62be9 100644 --- a/host/tests/test_session.py +++ b/host/tests/test_session.py @@ -14,6 +14,7 @@ import threading import time import unittest +from unittest import mock HERE = os.path.dirname(os.path.abspath(__file__)) sys.path.insert(0, os.path.join(HERE, "..")) @@ -21,10 +22,15 @@ from apple_mdns import PAD_ID # noqa: E402 from espdrop import link # noqa: E402 -from espdrop.session import Session # noqa: E402 +from espdrop.session import STAGING, Session # noqa: E402 from fake_device import DuplexBridgeDevice # noqa: E402 +def listing(folder): + """What the user sees in a download folder (not the staging folder).""" + return sorted(f for f in os.listdir(folder) if f != STAGING) + + class Events: def __init__(self): self.items = [] @@ -233,10 +239,62 @@ def test_listen_mode_when_the_esp_can_wake_itself(self): time.sleep(0.05) self.assertIn("power listen off", fake.commands) + def test_nothing_is_overwritten(self): + # The download folder already has a file of that name, and the same + # photo comes twice: the user's file stays, the new ones get numbers. + fake, dl = self.start(lambda ask: True) + with open(os.path.join(dl, "IMG_1.jpg"), "wb") as f: + f.write(b"the user's own") + for k in range(2): + self.assertTrue(self.incoming(fake, "IMG_1.jpg", b"from the phone %d" % k * 100)) + self.events.wait(lambda kind, i, k=k: kind == "receive" and i["phase"] == "received" + and len([x for x in self.events.kinds("receive") + if x["phase"] == "received"]) == k + 1) + self.assertEqual(listing(dl), ["IMG_1 (1).jpg", "IMG_1 (2).jpg", "IMG_1.jpg"]) + with open(os.path.join(dl, "IMG_1.jpg"), "rb") as f: + self.assertEqual(f.read(), b"the user's own") + done = [i for i in self.events.kinds("receive") if i["phase"] == "received"] + self.assertEqual([os.path.basename(i["files"][0]) for i in done], + ["IMG_1 (1).jpg", "IMG_1 (2).jpg"]) + self.assertEqual(os.listdir(os.path.join(dl, STAGING)), []) + + def test_move_out_keeps_what_cannot_move(self): + from espdrop import receive + staging = os.path.join(self.tmp.name, "staging") + dest = os.path.join(self.tmp.name, "dest") + os.makedirs(staging) + os.makedirs(dest) + for n in ("a.jpg", "locked.jpg"): + open(os.path.join(staging, n), "wb").close() + real = receive.shutil.move + + def move(src, dst): + if src.endswith("locked.jpg"): + raise PermissionError("in use") + return real(src, dst) + with mock.patch.object(receive.shutil, "move", move), \ + self.assertLogs("espdrop.receive", "WARNING"): + got = receive.move_out(staging, ["a.jpg", "locked.jpg"], dest) + self.assertEqual(got, [os.path.join(dest, "a.jpg"), os.path.join(staging, "locked.jpg")]) + self.assertEqual(os.listdir(staging), ["locked.jpg"]) + + def test_leftovers_move_out_at_start(self): + dl = os.path.join(self.tmp.name, "downloads") + os.makedirs(os.path.join(dl, STAGING)) + with open(os.path.join(dl, STAGING, "halb.jpg"), "wb") as f: + f.write(b"left by a crash") + s = Session(transport_factory=lambda: DuplexBridgeDevice(), receive=False, + on_event=self.events, download_dir=dl) + self.session = s + s.start() + self.assertEqual(listing(dl), ["halb.jpg"]) + self.assertEqual(os.path.realpath(os.getcwd()), + os.path.realpath(os.path.join(dl, STAGING))) + def test_declined_and_unanswered(self): fake, dl = self.start(lambda ask: False) self.assertFalse(self.incoming(fake, "nein.txt", b"no")) - self.assertEqual(os.listdir(dl), []) + self.assertEqual(listing(dl), []) asked = [i for i in self.events.kinds("receive") if i["phase"] == "asked"] self.assertEqual([i["accepted"] for i in asked], [False]) @@ -245,7 +303,7 @@ def test_unanswered_ask_is_declined(self): t = time.monotonic() self.assertFalse(self.incoming(fake, "spaet.txt", b"late")) self.assertLess(time.monotonic() - t, 4) - self.assertEqual(os.listdir(dl), []) + self.assertEqual(listing(dl), []) class ChoiceTest(SessionCase): diff --git a/tools/airdrop_wake.py b/tools/airdrop_wake.py index a4710bd..a8f39f0 100755 --- a/tools/airdrop_wake.py +++ b/tools/airdrop_wake.py @@ -2,12 +2,13 @@ # EspDrop - wake a nearby iPhone's AirDrop receiver with a Bluetooth LE advert. # # To SEND a file to an iPhone (PC -> iPhone) the phone must answer for its -# AirDrop service (_airdrop._tcp) over AWDL. It does that only while its AWDL is -# awake (share sheet open) AND a nearby sender's Bluetooth LE "Continuity" -# advertisement says someone wants to send (airdrop-mt7921 FINDINGS §27: same -# link, same open sheet, found only while the advert ran). This tool sends that -# advert from the PC's own Bluetooth, so the iPhone then appears to -# `opendrop find` on awdl0 and accepts an upload (tools/opendrop_send.sh). +# AirDrop service (_airdrop._tcp) over AWDL. It does that only while a nearby +# sender's Bluetooth LE "Continuity" advertisement says someone wants to send +# (airdrop-mt7921 FINDINGS §27: found only while the advert ran; the phone's +# own share sheet need not be open, as later tests with the ESP showed). +# This tool sends that advert from the PC's own Bluetooth, so the iPhone then +# appears to `opendrop find` on awdl0 and accepts an upload +# (tools/opendrop_send.sh). # # This is the receiver's side of Apple's own bootstrap: per Stute et al. # (USENIX Security 2019) a receiver wakes its AWDL interface "if at least one @@ -115,7 +116,7 @@ def main() -> int: print("mode:", "Everyone (empty hashes)" if everyone else "contacts-only (hashed identifiers)") print("The target iPhone/iPad must be on AirDrop -> Everyone (or have a matching") - print("contact), unlocked, with its share sheet OPEN. Ctrl-C to stop.") + print("contact) and unlocked. Ctrl-C to stop.") dbus.mainloop.glib.DBusGMainLoop(set_as_default=True) bus = dbus.SystemBus() diff --git a/tools/opendrop_receive.sh b/tools/opendrop_receive.sh index 5052131..0bbccd5 100755 --- a/tools/opendrop_receive.sh +++ b/tools/opendrop_receive.sh @@ -6,9 +6,9 @@ # tools/opendrop_receive.sh # files land in ~/Downloads/airdrop # tools/opendrop_receive.sh -n MyPC -d /tmp/in # -# On the iPhone: AirDrop "Everyone for 10 Minutes", unlock, open the share sheet -# and keep it open, pick the receiver name. Every transfer is accepted -# automatically while this runs (anyone in range can send you files). +# On the iPhone: share the file over AirDrop and pick the receiver name. Every +# transfer is accepted automatically while this runs (anyone in range can send +# you files). # # The script waits until awdl0 has a usable link-local address (OpenDrop binds # its mDNS socket once at startup and never retries), and restarts OpenDrop when diff --git a/tools/opendrop_send.sh b/tools/opendrop_send.sh index 25d0007..7146d46 100755 --- a/tools/opendrop_send.sh +++ b/tools/opendrop_send.sh @@ -8,11 +8,10 @@ # tools/opendrop_send.sh -n MyPC file1.pdf file2.png # tools/opendrop_send.sh -r "Anna's iPhone" clip.mov # pick the receiver # -# On the iPhone/iPad: AirDrop "Everyone for 10 Minutes", unlock, open the share -# sheet and LEAVE IT OPEN: it keeps the device's AWDL awake. With the sheet -# closed it never advertises a receiver, BLE wake or not; airdrop-mt7921's -# working sends (FINDINGS §27, §37) had the sheet open plus the BLE wake. This -# script broadcasts that wake, browses for the receiver on awdl0, and sends. +# On the iPhone/iPad: AirDrop "Everyone for 10 Minutes", unlock. It answers as +# a receiver while a BLE wake runs nearby (airdrop-mt7921 FINDINGS §27, §37; +# its share sheet need not be open, as later tests with the ESP's wake showed). +# This script broadcasts that wake, browses for the receiver on awdl0, and sends. # The device shows an Accept prompt; tap Accept. # # The wake comes from the ESP's own Bluetooth when its firmware has it (bridge @@ -152,14 +151,14 @@ wake_secs=$((BROWSE + 60)) [ "$wake_secs" -gt 600 ] && wake_secs=600 case "$WAKE_MODE" in esp) - echo "broadcasting the AirDrop BLE wake from the ESP (device: AirDrop Everyone, unlocked, share sheet OPEN) ..." + echo "broadcasting the AirDrop BLE wake from the ESP (device: AirDrop Everyone, unlocked) ..." if esp_wake "$wake_secs"; then wake_esp=1 else echo "warning: the ESP did not start the BLE wake ($(esp_cmd wake 2>&1)); browsing anyway" >&2 fi ;; pc) - echo "broadcasting the AirDrop BLE wake from the PC (device: AirDrop Everyone, unlocked, share sheet OPEN) ..." + echo "broadcasting the AirDrop BLE wake from the PC (device: AirDrop Everyone, unlocked) ..." # shellcheck disable=SC2086 "$HERE/airdrop_wake.py" ${ESPDROP_WAKE_ARGS:-} >/dev/null 2>&1 & wake_pid=$! @@ -213,9 +212,9 @@ if [ -z "$recv_id" ] && [ -z "$RECV" ]; then esac echo " Watch it with sudo tcpdump -i $IFACE -n 'tcp port 8770 or icmp6'" >&2 fi - echo " - the iPhone/iPad must be on AirDrop -> Everyone, unlocked, with its share" >&2 - echo " sheet OPEN, and in range on the channel awdl0 is parked on (check 'node'" >&2 - echo " in the bridge: auto=on and chan= the device's 5 GHz channel)." >&2 + echo " - the iPhone/iPad must be on AirDrop -> Everyone, unlocked, and in range on" >&2 + echo " the channel awdl0 is parked on (check 'node' in the bridge: auto=on and" >&2 + echo " chan= the device's 5 GHz channel)." >&2 echo " - 'Everyone for 10 Minutes' falls back to Contacts Only after 10 minutes;" >&2 echo " select it again right before sending." >&2 echo " - the BLE wake (this run: $WAKE_MODE) comes from the ESP's Bluetooth (bridge" >&2 diff --git a/tools/zeroconf_probe.py b/tools/zeroconf_probe.py index 9b24c00..a167410 100755 --- a/tools/zeroconf_probe.py +++ b/tools/zeroconf_probe.py @@ -15,7 +15,7 @@ # can take unicast answers meant for this one. # # Run it with the venv's python while the bridge is up and the target is ready -# (AirDrop Everyone, unlocked, share sheet OPEN, tools/airdrop_wake.py running): +# (AirDrop Everyone, unlocked, tools/airdrop_wake.py running): # # ~/opendrop-venv/bin/python tools/zeroconf_probe.py # ~/opendrop-venv/bin/python tools/zeroconf_probe.py -t 60 -i awdl0 @@ -351,8 +351,8 @@ def repeat(): # the patched OpenDrop's steady query (opendrop-mdns-repeat) print("no AirDrop answer arrived: the device did not answer this run.\n" f" zeroconf did receive from {len(heard)} other host(s), so reception works.\n" " Check the device: AirDrop 'Everyone for 10 Minutes' falls back to\n" - " Contacts Only after 10 minutes (select it again), unlocked, share sheet\n" - " open, and the BLE wake running.") + " Contacts Only after 10 minutes (select it again), unlocked, and the\n" + " BLE wake running.") # zeroconf.close() can hang on this link (see opendrop find); do not wait for it. threading.Thread(target=lambda: (browser.cancel(), zc.close()), daemon=True).start() From d744b9bfa7a079060cff7fd92a4aa1c9c3ae89c0 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 31 Oct 2026 13:43:30 +0000 Subject: [PATCH 84/99] Flash the ESP's firmware from the app - espdrop/flash.py: the firmware is checked before anything is written (an image of the whole flash as releases ship it, or an ESP-IDF build folder of the bridge; ESP image headers for one chip, the bootloader where that chip's ROM looks, the partition table, the app's description with its version), then esptool writes it as a library in this process: the C5's USB-Serial/JTAG resets it into the ROM bootloader (no buttons), write, MD5 verify, restart. The connected chip must match first. Progress comes from esptool's documented custom-logger interface, by flash address, so it stays monotonic across several parts. Errors are explained in words. - Desktop app: Allgemein -> "ESP-Firmware" shows what the ESP runs and offers "Aktualisieren ..." when the app ships a newer one; an ESP that does not answer (no, other or too old firmware) gets "Firmware installieren ..." under the title. The dialog lets go of the port, flashes in a thread, connects again; it cannot be closed (nor the app quit) while writing. - espdrop flash [IMAGE]; espdrop status shows the firmware version; espdrop --version; espdrop check lists esptool; the installer adds it. - Firmware: version from host/espdrop/version.py (PROJECT_VER, 0.1.0) and a `version` command (version, IDF, chip, revision, protocol). Not built here (no ESP-IDF in this environment): the CMake regex was run with cmake, the command's code compiled with gcc -Wall -Wextra -Werror -Wformat=2 against stand-in types. - Session: the firmware version in the connected event; `silent` counts HELLOs in a row an ESP did not answer. - tools/firmware_image.py: the one-file release image from a build (esptool merge-bin), read back and checked the same way. 88 host tests pass. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01XzgaN4Bv1m8Ka9iqo7QVUN --- docs/SPIKES.md | 52 +++++ firmware/README.md | 9 + firmware/spikes/CMakeLists.txt | 6 + firmware/spikes/main/CMakeLists.txt | 2 +- firmware/spikes/main/bridge.c | 14 ++ host/README.md | 31 ++- host/espdrop/cli.py | 56 ++++- host/espdrop/env.py | 6 + host/espdrop/flash.py | 328 +++++++++++++++++++++++++++ host/espdrop/gui.py | 325 ++++++++++++++++++++++++++- host/espdrop/session.py | 22 +- host/espdrop/ui.py | 12 +- host/espdrop/version.py | 10 + host/tests/fake_device.py | 7 +- host/tests/test_flash.py | 337 ++++++++++++++++++++++++++++ host/tests/test_gui.py | 99 ++++++++ host/tests/test_session.py | 39 ++++ tools/firmware_image.py | 68 ++++++ tools/opendrop_install.py | 9 +- 19 files changed, 1413 insertions(+), 19 deletions(-) create mode 100644 host/espdrop/flash.py create mode 100644 host/espdrop/version.py create mode 100644 host/tests/test_flash.py create mode 100755 tools/firmware_image.py diff --git a/docs/SPIKES.md b/docs/SPIKES.md index 19a501a..64d77f3 100644 --- a/docs/SPIKES.md +++ b/docs/SPIKES.md @@ -659,6 +659,58 @@ laufende App, Erscheinungsbild, Zwischenablage, Symboldatei) und Temp-Ordnern, Windows-Registry und „Senden an“ nachgebildet, Voreinstellungen, Symbol). Zusammen 68 Host-Tests. +## Firmware aus der App flashen + +Die App spielt die Firmware selbst auf, ohne ESP-IDF und ohne Tasten am ESP: +esptool (als Bibliothek in der App) schaltet den C5 über dieselbe +USB-Serial/JTAG-Verbindung in seinen ROM-Bootloader, schreibt, prüft (MD5) +und startet ihn neu. Vorher gibt die App den Port frei, danach verbindet sie +sich wieder. Den ROM-Bootloader kann nichts überschreiben: Geht etwas schief, +hilft erneutes Flashen. + +- **Version:** Die Firmware hat jetzt die Version der App + (`host/espdrop/version.py`, 0.1.0) und den Befehl `version` + (`version 0.1.0 idf=v6.0.3 chip=esp32c5 rev=1.0 proto=1`). Ältere Firmware + kennt ihn nicht; die App zeigt dann „ältere Firmware ohne Versionsangabe“ und + bietet „Aktualisieren …“ an. `espdrop status` zeigt die Version in der ersten + Zeile. +- **Welche Firmware:** `ESPDROP_FIRMWARE`, sonst die mitgelieferte + (`host/espdrop/firmware/espdrop-esp32c5.bin`, kommt mit den Release-Builds), + sonst der neueste Bridge-Build für den C5 unter `firmware/spikes/build*` + (ein Build der Test-Konsole wird abgelehnt). „Andere Datei …“ nimmt ein + Image oder einen Build-Ordner. Vor dem Schreiben prüft die App das Image + (Chip, Bootloader an 0x2000, Partitionstabelle, App mit Version) und den + angeschlossenen Chip. +- **ESP antwortet nicht** (keine, eine andere oder zu alte Firmware): Nach zwei + erfolglosen Versuchen steht unter dem Titel „ESP antwortet nicht“ mit + „Firmware installieren …“. +- `tools/firmware_image.py BUILD` macht aus einem Build das eine Image für + Releases (esptool merge-bin, danach genauso geprüft). + +Test auf der Hardware: +1. Bridge-Firmware nur bauen, nicht flashen (`idf.py -B build_c5_bridge … build`, + siehe firmware/README.md), App starten: Allgemein → „ESP-Firmware“ zeigt + „EspDrop, eine ältere Firmware ohne Versionsangabe“ und „Aktualisieren …“. +2. „Aktualisieren …“ → „Installieren“: „Verbinde mit dem ESP …“, der Balken + läuft, „Prüfe …“, „Starte den ESP neu …“, dann „Fertig“. Die App verbindet + sich wieder; „ESP-Firmware“ zeigt „EspDrop 0.1.0 auf dem ESP32-C5“, der + Knopf heißt „Installieren …“. Danach einmal senden und empfangen. +3. Dasselbe ohne Fenster: `espdrop flash` (Linux + `~/opendrop-venv/bin/python tools/espdrop.py flash`, Windows + `.\tools\espdrop.cmd flash`), danach `espdrop status`: erste Zeile + `firmware: 0.1.0 (esp32c5, IDF v6.0.3)`. +4. Fehlerfall: einen seriellen Monitor auf den Port legen (`idf.py monitor`) und + in der App installieren: die Meldung nennt den belegten Anschluss; Monitor + schließen, „Erneut versuchen“. +5. Windows: dasselbe mit dem COM-Port (das Flashen braucht keinen Treiber). + +Tests ohne Hardware: `test_flash.py` (Images prüfen und ablehnen, Build-Ordner, +Version, Fortschritt über mehrere Teile, falscher Chip, Fehler in Worten, +Fortschritt über den Logger des installierten esptool, Release-Image aus einem +Build) sowie Erweiterungen in `test_session.py` (Version beim Verbinden, +stummer ESP) und `test_gui.py` (Aktualisieren mit Neuverbinden, Fehlerfall, +kein Schließen während des Schreibens). + ## Danach Ergebnisse (auch Fehlschläge) in `docs/UEBERBLICK.md` unter „Offene Fragen" diff --git a/firmware/README.md b/firmware/README.md index e697830..486adc5 100644 --- a/firmware/README.md +++ b/firmware/README.md @@ -33,6 +33,15 @@ idf.py -B build_c5_bridge -DSDKCONFIG=build_c5_bridge/sdkconfig -DIDF_TARGET=esp `sdkconfig.defaults.esp32c5` is picked up automatically for the C5. +The firmware's version is the host app's (`host/espdrop/version.py`, read by +`CMakeLists.txt` as `PROJECT_VER`); the bridge answers `version` with +`version 0.1.0 idf=v6.0.3 chip=esp32c5 rev=1.0 proto=1`, so the app knows +whether the ESP runs the firmware it ships. The app can also flash it (no IDF +needed, esptool over the same USB port): `espdrop flash` takes the newest +`build*` of the bridge here, or one image made with +`python tools/firmware_image.py spikes/build_c5_bridge` (bootloader, +partition table and app merged, written from 0). + The bridge firmware includes Bluetooth LE (NimBLE): the broadcaster role for the AirDrop wake (`wake`), and the observer role so it can listen for AirDrop adverts and let the Wi-Fi radio sleep until a device nearby wants to share diff --git a/firmware/spikes/CMakeLists.txt b/firmware/spikes/CMakeLists.txt index 2e0da2d..f63b94c 100644 --- a/firmware/spikes/CMakeLists.txt +++ b/firmware/spikes/CMakeLists.txt @@ -1,4 +1,10 @@ # EspDrop AWDL spikes (ESP32-C3, ESP32-C5). ESP-IDF >= 5.5 / 6.0. cmake_minimum_required(VERSION 3.16) +# The firmware's version is the app's (host/espdrop/version.py): the app reads +# it back with the `version` command and knows whether the ESP runs the +# firmware it ships. Must be set before project.cmake (else git describe). +file(STRINGS "${CMAKE_CURRENT_LIST_DIR}/../../host/espdrop/version.py" _espdrop_version + REGEX "^__version__ *= *\"[^\"]+\"") +string(REGEX REPLACE "^__version__ *= *\"([^\"]+)\".*" "\\1" PROJECT_VER "${_espdrop_version}") include($ENV{IDF_PATH}/tools/cmake/project.cmake) project(espdrop_spikes) diff --git a/firmware/spikes/main/CMakeLists.txt b/firmware/spikes/main/CMakeLists.txt index 0c95fa5..40f3c4d 100644 --- a/firmware/spikes/main/CMakeLists.txt +++ b/firmware/spikes/main/CMakeLists.txt @@ -29,5 +29,5 @@ idf_component_register( "awdl_netif.c" "sockproxy.c" "power.c" INCLUDE_DIRS "." REQUIRES awdl esp_wifi esp_event esp_netif nvs_flash console esp_timer esp_hw_support - bt lwip ${ESPDROP_USJ_COMPONENT} ${ESPDROP_USJ_LL_COMPONENT} + esp_app_format bt lwip ${ESPDROP_USJ_COMPONENT} ${ESPDROP_USJ_LL_COMPONENT} ) diff --git a/firmware/spikes/main/bridge.c b/firmware/spikes/main/bridge.c index e16c75b..04bfb0e 100644 --- a/firmware/spikes/main/bridge.c +++ b/firmware/spikes/main/bridge.c @@ -12,6 +12,9 @@ #include #include "driver/usb_serial_jtag.h" +#include "esp_app_desc.h" /* `version`: the app's own description */ +#include "esp_chip_info.h" +#include "sdkconfig.h" /* CONFIG_IDF_TARGET */ #include "esp_log.h" #include "esp_mac.h" #include "esp_system.h" /* esp_get_free_heap_size */ @@ -370,6 +373,17 @@ static void handle_cmd(const uint8_t *p, size_t n) { char out[192]; if (strcmp(cmd, "ping") == 0) { cmd_reply("pong"); + } else if (strcmp(cmd, "version") == 0) { + /* The host app compares this with the firmware it ships (and can flash + * it); older firmware answers "unknown cmd", which says as much. */ + const esp_app_desc_t *app = esp_app_get_description(); + esp_chip_info_t chip; + esp_chip_info(&chip); + snprintf(out, sizeof(out), "version %s idf=%s chip=%s rev=%u.%u proto=%u", + app->version, app->idf_ver, CONFIG_IDF_TARGET, + (unsigned)(chip.revision / 100), (unsigned)(chip.revision % 100), + (unsigned)BRIDGE_PROTO_VERSION); + cmd_reply(out); } else if (strcmp(cmd, "mac") == 0) { uint8_t mac[6] = {0}; esp_read_mac(mac, ESP_MAC_WIFI_STA); diff --git a/host/README.md b/host/README.md index e6fbabc..52f6512 100644 --- a/host/README.md +++ b/host/README.md @@ -16,7 +16,9 @@ how to test each stage: [`docs/SPIKES.md`](../docs/SPIKES.md). | `espdrop/env.py` | platform setup: libarchive, TLS certificate, Windows fixes, `check` | | `espdrop/session.py` | one long-running link for a GUI: receive windows, sends alongside, events, reconnect | | `espdrop/gui.py` | the desktop app (PySide6) on the session | -| `espdrop/cli.py` | `find`, `send`, `receive`, `status`, `cmd`, `check`, `gui` | +| `espdrop/flash.py` | the ESP's firmware from the app: images checked, then esptool | +| `espdrop/version.py` | the version of the app and of the firmware it ships | +| `espdrop/cli.py` | `find`, `send`, `receive`, `status`, `cmd`, `check`, `flash`, `gui` | | `tests/` | simulated devices with iPad-shaped mDNS answers, end-to-end via OpenDrop | `receive` asks before each transfer (`y`/`n` within 60 s, else declined; @@ -74,6 +76,15 @@ Linux), the icon is drawn in code. taskbar shows our icon instead of Python's. - **One copy per user:** a second start (autostart, the file manager, the launcher) hands its files to the running app over a local socket and exits. +- **Allgemein → "ESP-Firmware"** shows what the ESP runs (the firmware's + `version` command) and offers "Aktualisieren …" when the app ships a newer + one; an ESP that does not answer (no or other firmware) gets "Firmware + installieren …" under the title. The dialog writes the firmware over the + same USB cable, no buttons on the ESP: the app lets go of the port, esptool + resets the C5 into its ROM bootloader, writes, verifies (MD5) and restarts + it, and the app connects again. "Andere Datei …" takes an image or an + ESP-IDF build folder. The same from the command line: `espdrop flash + [IMAGE]`. It needs PySide6 in the venv: `tools/opendrop_install.py` installs it; for a venv made by `opendrop_install.sh`, run `python3 tools/opendrop_install.py` @@ -89,9 +100,23 @@ Arch). ```sh ~/opendrop-venv/bin/python tools/espdrop.py check ~/opendrop-venv/bin/python tools/espdrop.py find +~/opendrop-venv/bin/python tools/espdrop.py flash # the firmware onto the ESP ~/opendrop-venv/bin/python -m unittest discover -s host/tests ``` +## The firmware the app ships + +`espdrop flash` and the app take, in this order: `ESPDROP_FIRMWARE` (an +image or a build folder), `espdrop/firmware/espdrop-esp32c5.bin` (what +release builds ship), or the newest ESP-IDF build of the bridge for the C5 in +`firmware/spikes/build*` (a build of the test console is refused). Before +anything is written the image is checked: ESP image headers for one chip, the +bootloader where that chip's ROM looks (0x2000 on the C5), the partition table +at 0x8000, the app with its description (version, build date, IDF) at +0x10000; the ESP must turn out to be that chip. `tools/firmware_image.py +BUILD` makes the one-file image from a build (esptool's merge-bin, checked the +same way). + ## Windows 10/11 Needs Python 3.9+ from python.org (64 bit, with the `py` launcher) and this @@ -112,8 +137,8 @@ py tools\opendrop_install.py PyPI, the 17 airdrop-mt7921 patches (downloaded at the pinned revision; every patch and every resulting file is checked against pinned digests, so the code is byte-identical to the Linux install), pyserial, cryptography (the TLS -certificate, which OpenDrop would otherwise make with openssl) and PySide6 -(the desktop app). It ends with +certificate, which OpenDrop would otherwise make with openssl), PySide6 +(the desktop app) and esptool (the ESP's firmware from the app). It ends with `espdrop check`. `espdrop.cmd` runs the app with that venv (`OPENDROP_VENV` picks another) and works in cmd and PowerShell. Received files land in the current folder. The port is found by its Espressif USB id, else `-p COM5`. diff --git a/host/espdrop/cli.py b/host/espdrop/cli.py index 0957afb..1ec465d 100644 --- a/host/espdrop/cli.py +++ b/host/espdrop/cli.py @@ -307,9 +307,13 @@ def on_event(kind, **info): def cmd_status(args, transport=None): + from .flash import parse_version dev = _open(args, transport) try: _hello(dev) + fw = parse_version(dev.cmd("version")) + print(f"firmware: {fw['version']} ({fw.get('chip', '?')}, IDF {fw.get('idf', '?')})" + if fw else "firmware: from before 0.1.0 (no `version` command)", flush=True) for c in ("node", "ip", "stats", "wake", "power"): print(f"{c}: {dev.cmd(c)}", flush=True) return 0 @@ -329,6 +333,50 @@ def cmd_cmd(args, transport=None): dev.close() +def cmd_flash(args, transport=None): + """Put the EspDrop firmware on the ESP (esptool, no IDF needed).""" + from . import flash + path = args.image or flash.bundled() + if not path: + print("error: no firmware here: name an image (espdrop-esp32c5.bin from a release) " + "or an ESP-IDF build folder of the bridge", file=sys.stderr) + return 1 + try: + fw = flash.load(path) + port = args.port + if not port: + found = flash.ports() + if len(found) != 1: + print("error: " + ("no ESP found; plug it in" if not found else + f"several ports ({', '.join(found)}); pick one: " + f"espdrop -p PORT flash"), file=sys.stderr) + return 1 + port = found[0] + print(f"firmware {fw.version} for the {fw.chip} (built {fw.built}, IDF {fw.idf}, " + f"{fw.size // 1024} KiB)\n from {fw.source}\nport {port}", flush=True) + state = {"bar": False} + + def progress(stage, fraction=None): + if stage == "write": + print(f"\r writing {fraction * 100:5.1f} %", end="", flush=True) + state["bar"] = True + return + if state["bar"]: + print(flush=True) + state["bar"] = False + text = {"connect": "connecting to the ESP's ROM bootloader ...", + "verify": "verifying ...", "restart": "restarting the ESP ...", + "done": "done: the ESP runs the new firmware."}.get(stage) + if text: + print(text, flush=True) + flash.flash(fw, port, on_progress=progress, + on_log=(lambda text: print(f" {text}", flush=True)) if args.verbose else None) + return 0 + except flash.FirmwareError as e: + print(f"\nerror: {e}", file=sys.stderr) + return 1 + + def cmd_check(args, transport=None): from . import env print("EspDrop host app: checking this PC (no device needed)", flush=True) @@ -352,7 +400,9 @@ def main(argv=None, transport=None): for stream in (sys.stdout, sys.stderr): if hasattr(stream, "reconfigure"): stream.reconfigure(errors="replace") + from .version import __version__ ap = argparse.ArgumentParser(prog="espdrop", description="EspDrop host app (M4)") + ap.add_argument("--version", action="version", version=f"espdrop {__version__}") ap.add_argument("-p", "--port", help="serial port (default: the only ESP found; " "Linux /dev/ttyACM0, Windows COM5)") sub = ap.add_subparsers(dest="action", required=True) @@ -385,6 +435,10 @@ def main(argv=None, transport=None): c.add_argument("--wait", type=float, default=12.0, help="ping6: seconds to show replies") c.set_defaults(verbose=True) sub.add_parser("check", help="is this PC ready: Python, pyserial, OpenDrop, libarchive, TLS") + fl = sub.add_parser("flash", help="put the EspDrop firmware on the ESP (esptool)") + fl.add_argument("image", nargs="?", help="firmware image (espdrop-esp32c5.bin) or ESP-IDF " + "build folder (default: the one this app ships)") + fl.add_argument("-v", "--verbose", action="store_true", help="show esptool's messages") g = sub.add_parser("gui", help="the desktop app (PySide6)") g.add_argument("--background", action="store_true", help="tray only, receiving until switched off (what autostart runs)") @@ -401,7 +455,7 @@ def main(argv=None, transport=None): logging.getLogger("opendrop").setLevel(logging.WARNING) handlers = {"find": cmd_find, "send": cmd_send, "receive": cmd_receive, "status": cmd_status, "cmd": cmd_cmd, "check": cmd_check, - "gui": cmd_gui} + "flash": cmd_flash, "gui": cmd_gui} try: return handlers[args.action](args, transport) except ImportError as e: # OpenDrop or libarchive (env.py says what to do) diff --git a/host/espdrop/env.py b/host/espdrop/env.py index 9da954c..70cae5e 100644 --- a/host/espdrop/env.py +++ b/host/espdrop/env.py @@ -357,6 +357,12 @@ def line(ok, what, detail): except ImportError: line(None, "pyside6", "not installed: the command line works, the desktop app " "(espdrop gui) needs it") + try: + import esptool + line(True, "esptool", f"{esptool.__version__} (espdrop flash, firmware from the app)") + except ImportError: + line(None, "esptool", "not installed: everything works, but the ESP's firmware " + "cannot be put on from here (pip install esptool)") try: path, detail = prepare_libarchive() line(True, "libarchive", detail) diff --git a/host/espdrop/flash.py b/host/espdrop/flash.py new file mode 100644 index 0000000..fd9f030 --- /dev/null +++ b/host/espdrop/flash.py @@ -0,0 +1,328 @@ +# EspDrop - put the firmware on the ESP from the app (esptool, in this process). +# +# Two kinds of firmware are accepted, and checked before anything is written: +# - an image of the whole flash, as release builds ship it (`esptool +# merge-bin`: bootloader, partition table and the app at their offsets), +# written from 0 +# - an ESP-IDF build folder: the files its flasher_args.json lists, each at +# its offset (only a build of the bridge firmware, not the test console) +# Both must carry ESP image headers for one chip, and the app its description +# (esp_app_desc_t at offset 0x20: version, build date, IDF); the chip the ESP +# turns out to be must be that one before esptool erases anything. +# +# The ESP32-C5's USB-Serial/JTAG needs no buttons: esptool resets it into the +# ROM bootloader over the same USB port the app uses, writes, verifies the +# result (MD5) and resets it into the new firmware. The ROM bootloader cannot +# be overwritten, so a failed or interrupted write is fixed by flashing again. +# +# SPDX-License-Identifier: GPL-3.0-or-later +# Copyright (C) 2026 EspDrop contributors + +import glob +import json +import os +import re +import struct + +IMAGE_MAGIC = 0xE9 +APP_DESC_MAGIC = 0xABCD5432 +APP_OFFSET = 0x10000 +PART_TABLE_OFFSET = 0x8000 +CHIPS = {5: "ESP32-C3", 23: "ESP32-C5"} # image chip id -> name (as esptool says it) +BOOT_OFFSET = {5: 0x0, 23: 0x2000} # where each chip's ROM looks for the bootloader +FIRMWARE_NAME = "espdrop-esp32c5.bin" # the image release builds ship + + +class FirmwareError(Exception): + """Not a usable firmware, or flashing failed; the message is for the user.""" + + +class Firmware: + """What to write: parts [(offset, bytes)] for one chip, and its version.""" + + def __init__(self, parts, chip_id, desc, source): + self.parts = parts + self.chip_id = chip_id + self.version = desc["version"] + self.project = desc["project"] + self.built = desc["built"] + self.idf = desc["idf"] + self.source = source + + @property + def chip(self): + return CHIPS.get(self.chip_id, f"Chip {self.chip_id}") + + @property + def size(self): + return sum(len(data) for _, data in self.parts) + + def __repr__(self): + return f"" + + +# ---- reading images ------------------------------------------------------------ + +def _cstr(raw): + return raw.split(b"\0", 1)[0].decode("utf-8", "replace") + + +def image_chip(data, offset=0): + """The chip id in the ESP image header at offset, or None if none is there.""" + if len(data) < offset + 24 or data[offset] != IMAGE_MAGIC: + return None + return struct.unpack_from(" b + + +# ---- writing ------------------------------------------------------------------- + +def ports(): + """Serial ports that look like an ESP, best first.""" + from .transport import candidates + return candidates() + + +def flash(fw, port, on_progress=None, on_log=None): + """Write fw to the ESP on port; the app must have let go of the port. + + on_progress(stage, fraction): "connect", "write" (0..1 over all parts), + "verify", "restart", "done". on_log(text): esptool's messages. Raises + FirmwareError with a message for the user.""" + try: + from esptool.cmds import attach_flash, detect_chip, reset_chip, run_stub, write_flash + from esptool.logger import TemplateLogger, log + except ImportError as e: + raise FirmwareError("esptool fehlt: tools/opendrop_install.py installiert es " + "(oder pip install esptool).") from e + report = on_progress or (lambda stage, fraction=None: None) + parts = sorted(fw.parts) # written in this order + + class Progress(TemplateLogger): + """esptool's output: the write progress, the rest as log lines.""" + writing = False + verifying = False + + def print(self, *args, **kwargs): + text = " ".join(str(a) for a in args).strip() + if text and on_log: + on_log(text) + + def note(self, message): + self.print(f"Note: {message}") + + def warning(self, message): + self.print(f"Warning: {message}") + + def error(self, message): + self.print(f"Error: {message}") + + def stage(self, finish=False): + pass + + def set_verbosity(self, verbosity): + pass + + def progress_bar(self, cur_iter, total_iters, prefix="", suffix="", bar_length=30): + if not self.writing or self.verifying: + return # the stub upload, reading: not ours + # "Writing at 0x00012000": the flash address, counted in bytes of + # the image (the bar itself counts compressed bytes, per part). + m = re.search(r"Writing at (0x[0-9a-fA-F]+)", prefix or "") + if m: + done = written(parts, int(m.group(1), 16)) + elif len(parts) == 1 and total_iters: + done = fw.size * min(cur_iter / total_iters, 1.0) + else: + return + report("write", min(done / fw.size, 1.0)) + if done >= fw.size: + self.verifying = True + report("verify", None) # esptool checks the MD5 next + + progress = Progress() + log.set_logger(progress) + report("connect", None) + try: + with detect_chip(port) as esp: + if esp.IMAGE_CHIP_ID != fw.chip_id: + raise FirmwareError(f"Am Anschluss {port} steckt ein {esp.CHIP_NAME}; " + f"diese Firmware ist für den {fw.chip}.") + esp = run_stub(esp) + attach_flash(esp) + report("write", 0.0) + progress.writing = True + write_flash(esp, parts, compress=True) + progress.writing = False + report("restart", None) + reset_chip(esp, "hard-reset") + except FirmwareError: + raise + except Exception as e: # noqa: BLE001 - esptool, pyserial: explain + raise FirmwareError(explain(e, port)) from e + report("done", 1.0) + + +def written(parts, address): + """Bytes of parts (sorted, written in order) done once esptool writes at + this flash address.""" + done = 0 + for offset, data in parts: + if address >= offset + len(data): + done += len(data) + elif address > offset: + done += address - offset + return done + + +def explain(error, port): + """esptool's and pyserial's errors in words for the user.""" + text = str(error) or type(error).__name__ + low = text.lower() + if any(k in low for k in ("could not open port", "permission denied", "access is denied", + "zugriff verweigert", "resource busy", "errno 16")): + hint = (" Unter Linux muss der Benutzer in der Gruppe 'dialout' sein." + if os.name == "posix" else "") + return (f"Der Anschluss {port} ist belegt oder gesperrt: hat ihn ein anderes Programm " + f"offen (awdl_bridge.py, ein serieller Monitor)?{hint}") + if any(k in low for k in ("failed to connect", "no serial data", "wrong boot mode")): + return ("Der ESP ließ sich nicht in den Bootloader schalten. Abstecken, die BOOT-Taste " + "gedrückt halten, wieder anstecken, loslassen und erneut versuchen.") + return f"Flashen fehlgeschlagen: {text}" diff --git a/host/espdrop/gui.py b/host/espdrop/gui.py index 3a8f6c2..076af1e 100644 --- a/host/espdrop/gui.py +++ b/host/espdrop/gui.py @@ -35,12 +35,12 @@ from PySide6.QtCore import QObject, QSettings, Qt, QTimer, QUrl, Signal from PySide6.QtGui import QColor, QDesktopServices, QFont, QGuiApplication, QPixmap from PySide6.QtNetwork import QLocalServer, QLocalSocket -from PySide6.QtWidgets import (QApplication, QDialog, QFileDialog, QFrame, +from PySide6.QtWidgets import (QApplication, QComboBox, QDialog, QFileDialog, QFrame, QGraphicsDropShadowEffect, QHBoxLayout, QInputDialog, QLabel, QLineEdit, QMainWindow, QMenu, QMessageBox, QPlainTextEdit, QScrollArea, QSystemTrayIcon, QToolButton, QVBoxLayout, QWidget) -from . import integration, ui +from . import flash, integration, ui from .session import Session APP = "EspDrop" @@ -513,6 +513,229 @@ def done(self, result): super().done(result) +# ---- the ESP's firmware --------------------------------------------------------------- + +MONTHS = "Jan Feb Mar Apr May Jun Jul Aug Sep Oct Nov Dec".split() + + +def built_text(built): + """The compiler's 'Oct 31 2026 12:34:56' as '31.10.2026, 12:34'.""" + m = re.match(r"([A-Z][a-z]{2})\s+(\d{1,2}) (\d{4})(?: (\d{2}):(\d{2}))?", built or "") + if not m or m.group(1) not in MONTHS: + return built + text = f"{int(m.group(2))}.{MONTHS.index(m.group(1)) + 1}.{m.group(3)}" + return text + (f", {m.group(4)}:{m.group(5)}" if m.group(4) else "") + + +FLASH_STAGES = {"connect": "Verbinde mit dem ESP …", + "write": "Schreibe die Firmware …", + "verify": "Prüfe, ob alles angekommen ist …", + "restart": "Starte den ESP neu …"} +TRANSFER_DONE = ("done", "declined", "failed", "cancelled") + + +class FlashSignals(QObject): + """From the flashing thread to the dialog.""" + progress = Signal(str, object) # stage, fraction + log = Signal(str) + finished = Signal(object) # None, or what went wrong + + +class FirmwareDialog(QDialog): + """Put EspDrop's firmware on the ESP: what it runs now, what the app comes + with (or a file the user picks), one button. The app lets go of the port + while esptool writes, and connects again afterwards. No buttons on the ESP + are needed (its USB-Serial/JTAG resets it into the ROM bootloader).""" + + def __init__(self, window, flasher=None, loader=None): + super().__init__(None) # top-level: the main window may be hidden + self.w = window + self.flasher = flasher or flash.flash + self.loader = loader or flash.load + self.fw = None + self.busy = False + self.setWindowTitle("ESP-Firmware") + self.setWindowIcon(ui.app_icon()) + self.setAttribute(Qt.WidgetAttribute.WA_DeleteOnClose) + self.setMinimumWidth(460) + lay = QVBoxLayout(self) + lay.setContentsMargins(20, 18, 20, 18) + lay.setSpacing(10) + title = QLabel("ESP-Firmware") + title.setFont(ui.font(15, QFont.Weight.DemiBold)) + lay.addWidget(title) + lay.addWidget(ui.label("Die App bringt die Firmware für den ESP32-C5 mit und spielt sie " + "über das USB-Kabel auf; Tasten am ESP braucht es dafür nicht.", + "secondary", wrap=True)) + card = ui.Card() + self.device_sub = card.add_row("Auf dem ESP", None, "") + self.file_btn = ui.button("Andere Datei …", "link") + self.file_btn.clicked.connect(self._choose) + self.fw_sub = card.add_row("Zum Installieren", self.file_btn, "") + self.port_box = QComboBox() + self.port_box.setMinimumWidth(130) + self.port_sub = card.add_row("Anschluss", self.port_box, "") + lay.addWidget(card) + self.status = ui.label("", "secondary", wrap=True) + lay.addWidget(self.status) + self.bar = ui.ThinProgress() + self.bar.hide() + lay.addWidget(self.bar) + lay.addStretch(1) + row = QHBoxLayout() + row.addStretch(1) + self.close_btn = ui.button("Schließen") + self.close_btn.clicked.connect(self.close) + self.install_btn = ui.button("Installieren", "primary") + self.install_btn.clicked.connect(self._install) + row.addWidget(self.close_btn) + row.addWidget(self.install_btn) + lay.addLayout(row) + self.sig = FlashSignals(self) + self.sig.progress.connect(self._on_progress) + self.sig.log.connect(self.w._log) + self.sig.finished.connect(self._on_finished) + self.w.bridge.event.connect(self._on_event) + self._load(flash.bundled()) + self._fill_ports() + self._refresh() + + # ---- what is where --------------------------------------------------------- + + def _load(self, path): + self.fw = None + if not path: + self.fw_sub.setText("Keine Firmware dabei: eine Datei wählen (espdrop-esp32c5.bin " + "aus einem Release).") + return + try: + self.fw = self.loader(path) + except flash.FirmwareError as e: + self.fw_sub.setText(str(e)) + return + name = os.path.basename(self.fw.source.rstrip("/\\")) + self.fw_sub.setText(f"EspDrop {self.fw.version} für den {self.fw.chip}, gebaut " + f"{built_text(self.fw.built)}\n{name}") + self._fit() + + def _choose(self): + path, _ = QFileDialog.getOpenFileName( + self, "Firmware wählen", os.path.expanduser("~"), + "ESP-Firmware (*.bin flasher_args.json);;Alle Dateien (*)") + if path: + self._load(path) + self._refresh() + + def _fill_ports(self): + found = _safe(flash.ports, []) + if self.w.port and self.w.port not in found: + found.insert(0, self.w.port) + self.port_box.clear() + self.port_box.addItems(found) + if self.w.port: + self.port_box.setCurrentText(self.w.port) + self.port_box.setVisible(len(found) > 1) + self.port_sub.setText(found[0] if len(found) == 1 else + "Kein ESP gefunden: per USB anstecken." if not found else + "Mehrere gefunden: den ESP wählen.") + + def _refresh(self): + self.device_sub.setText(self.w.firmware_text()) + if not self.busy: + self.install_btn.setEnabled(self.fw is not None and self.port_box.count() > 0) + + def _on_event(self, kind, info): + if kind == "device": + if not self.busy and info.get("state") in ("connected", "lost"): + self._fill_ports() # plugged in or out meanwhile + self._refresh() + + # ---- installing ---------------------------------------------------------- + + def _install(self): + self._fill_ports() + port = self.port_box.currentText() + if not port or self.fw is None: + self._refresh() + return + if self.w.transfer_running(): + self._say("Gerade läuft eine Übertragung; danach installieren.", "bad") + return + self.busy = True + for b in (self.install_btn, self.close_btn, self.file_btn, self.port_box): + b.setEnabled(False) + self.bar.set_value(None) + self.bar.show() + self._say(FLASH_STAGES["connect"]) + self.w.set_flashing(True) + session, fw, sig, flasher = self.w.session, self.fw, self.sig, self.flasher + + def work(): + error = None + try: + session.stop() # let go of the port + flasher(fw, port, on_progress=sig.progress.emit, on_log=sig.log.emit) + except flash.FirmwareError as e: + error = str(e) + except Exception as e: # noqa: BLE001 - shown to the user + error = f"Flashen fehlgeschlagen: {e}" + finally: + session.start() # connect again, new firmware or old + sig.finished.emit(error) + threading.Thread(target=work, name="espdrop-flash", daemon=True).start() + + def _on_progress(self, stage, fraction): + if stage == "write" and fraction is not None: + self.bar.set_value(fraction) + self._say(f"{FLASH_STAGES['write']} {fraction * 100:.0f} %") + elif stage in FLASH_STAGES: + if stage != "connect": + self.bar.set_value(1.0) + self._say(FLASH_STAGES[stage]) + + def _on_finished(self, error): + self.busy = False + self.w.set_flashing(False) + for b in (self.close_btn, self.file_btn, self.port_box): + b.setEnabled(True) + if error: + self.bar.hide() + self._say(error, "bad") + self.install_btn.setText("Erneut versuchen") + else: + self.bar.set_value(1.0) + self._say(f"Fertig: EspDrop {self.fw.version} ist auf dem ESP. Er startet neu und " + "verbindet sich gleich wieder.", "good") + self.install_btn.setText("Erneut installieren") + self._refresh() + + def _say(self, text, tone=None): + self.status.setText(text) + self.status.setProperty("tone", tone or "") + self.status.style().unpolish(self.status) + self.status.style().polish(self.status) + self._fit() + + def _fit(self): + """Grow to fit longer text (wrapped lines need the height for this width).""" + lay = self.layout() + lay.activate() + need = (lay.totalHeightForWidth(self.width()) if lay.hasHeightForWidth() + else self.sizeHint().height()) + if self.height() < need: + self.resize(self.width(), need) + + def closeEvent(self, e): + if self.busy: # half a firmware helps nobody + e.ignore() + return + super().closeEvent(e) + + def reject(self): # Esc + if not self.busy: + super().reject() + + # ---- the window ------------------------------------------------------------------- class MainWindow(QMainWindow): @@ -542,7 +765,13 @@ def __init__(self, port=None, settings=None, session_factory=None, ask_timeout=6 self._send_dialogs = [] # open AirDrop sheets self._ask_dialogs = [] # open accept cards (may have no parent) self._notify_files = None # what clicking the last notification opens + self.firmware = None # the ESP's `version` reply (dict), None: too old + self._silent = 0 # HELLOs in a row an ESP did not answer + self._flashing = False + self._bundled = None # (path, Firmware or None), loaded when needed + self._fw_dialog = None self._build() + self._update_firmware_ui() self.tray = self._make_tray() if tray and QSystemTrayIcon.isSystemTrayAvailable() else None self.session = self._make_session() self.session.start() @@ -591,6 +820,10 @@ def _build(self): self.power_label = ui.label("", "secondary") status.addWidget(self.device_label) status.addWidget(self.power_label) + self.fw_hint = ui.button("Firmware installieren …", "link") + self.fw_hint.clicked.connect(self.open_firmware_dialog) + self.fw_hint.hide() + status.addWidget(self.fw_hint) status.addStretch(1) titles.addLayout(status) titles.addStretch(1) @@ -600,6 +833,7 @@ def _build(self): menu = QMenu(self.more_btn) self.log_action = menu.addAction("Protokoll anzeigen") self.log_action.setCheckable(True) + menu.addAction("ESP-Firmware …", self.open_firmware_dialog) menu.addAction("Serieller Port …", self._pick_port) menu.addSeparator() menu.addAction("Beenden", self.quit) @@ -710,6 +944,9 @@ def _build(self): s.set_quiet(_safe(integration.send_menu_enabled, False)) s.toggled.connect(lambda on, s=s: self._integrate(integration.set_send_menu, on, s)) gcard.add_row(caps["send"], s, caps.get("send_hint")) + self.fw_btn = ui.button("Installieren …") + self.fw_btn.clicked.connect(self.open_firmware_dialog) + self.fw_sub = gcard.add_row("ESP-Firmware", self.fw_btn, "") col.addWidget(gcard) # the log, off unless asked for @@ -830,10 +1067,16 @@ def closeEvent(self, e): self.hide() self._notify("Läuft im Hintergrund weiter (Symbol im Infobereich).", None, 3000) return + if self._flashing: # no tray: wait for the firmware + e.ignore() + return self._shutdown() e.accept() def quit(self): + if self._flashing: # half a firmware: finish first + self._notify("Die Firmware wird gerade installiert; danach beenden.", None, 4000) + return self._quitting = True self._shutdown() QApplication.quit() @@ -853,6 +1096,8 @@ def _on_event(self, kind, info): state = info.get("state") if state == "connected": self._connected = True + self._silent = 0 + self.firmware = (info.get("info") or {}).get("firmware") self.dot.set_state("green") self.device_label.setText("ESP verbunden") mac = (info.get("info") or {}).get("mac", "") @@ -860,13 +1105,24 @@ def _on_event(self, kind, info): self._set_device_ui(True) # the session restarts receiving itself elif state == "lost": self._connected = False + self._silent = info.get("silent", 0) self.dot.set_state("red") - self.device_label.setText("Kein ESP") + # An ESP that does not answer twice in a row (not just booting): + # other or no firmware, or one too old for this app. + self.device_label.setText("ESP antwortet nicht" if self._silent >= 2 + else "Kein ESP") self.device_label.setToolTip(info.get("error") or "") self._set_device_ui(False) elif state == "stopped": - self.device_label.setText("Beendet") - if state in ("connected", "lost"): + self._connected = False + self._set_device_ui(False) + if self._flashing: # the dialog has the port for esptool + self.dot.set_state("orange") + self.device_label.setText("Firmware wird installiert …") + else: + self.device_label.setText("Beendet") + self._update_firmware_ui() + if state in ("connected", "lost", "stopped"): for d in list(self._send_dialogs): d.on_device(state == "connected") elif kind == "power": @@ -906,6 +1162,65 @@ def _send_notice(self, info): self._notify(f"Nicht gesendet an {t.receiver.label}: " f"{'abgelehnt' if state == 'declined' else info.get('error')}") + # ---- the ESP's firmware ------------------------------------------------------ + + def bundled_firmware(self): + """The firmware the app comes with (flash.Firmware); None if there is none.""" + path = flash.bundled() + if self._bundled is None or self._bundled[0] != path: + fw = None + if path: + try: + fw = flash.load(path) + except flash.FirmwareError as e: + self._log(f"Firmware {path}: {e}") + self._bundled = (path, fw) + return self._bundled[1] + + def firmware_text(self): + """What the ESP runs, in words.""" + if self._flashing: + return "Wird gerade installiert …" + if self._connected: + fw = self.firmware + if not fw: + return "EspDrop, eine ältere Firmware ohne Versionsangabe" + chip = {"esp32c5": "ESP32-C5", "esp32c3": "ESP32-C3"}.get(fw.get("chip"), + fw.get("chip")) + return f"EspDrop {fw['version']}" + (f" auf dem {chip}" if chip else "") + if self._silent >= 2: + return "Ein ESP steckt, antwortet aber nicht: keine oder eine andere Firmware" + return "Kein ESP verbunden" + + def _update_firmware_ui(self): + bundled = self.bundled_firmware() + newer = (bundled is not None and self._connected + and flash.is_newer(bundled.version, (self.firmware or {}).get("version"))) + text = self.firmware_text() + if newer: + text += f" · neu in der App: {bundled.version}" + self.fw_sub.setText(text) + self.fw_btn.setText("Aktualisieren …" if newer else "Installieren …") + self.fw_hint.setVisible(self._silent >= 2 and not self._flashing) + + def open_firmware_dialog(self): + d = self._fw_dialog + if d is not None and shiboken6.isValid(d): + d.raise_() + d.activateWindow() + return d + d = self._fw_dialog = FirmwareDialog(self) + d.show() + return d + + def transfer_running(self): + return (self._recv_row is not None + or any(t.state not in TRANSFER_DONE for t in self.session.transfers())) + + def set_flashing(self, on): + self._flashing = on + self._update_firmware_ui() + def _log(self, text): self.log.appendPlainText(f"{time.strftime('%H:%M:%S')} {text}") diff --git a/host/espdrop/session.py b/host/espdrop/session.py index 8f9764b..91acc48 100644 --- a/host/espdrop/session.py +++ b/host/espdrop/session.py @@ -7,7 +7,9 @@ # shape and reports everything as events: # # on_event(kind, **info), from worker threads (a GUI hands it to its own): -# "device" state="connecting"|"connected"|"lost"|"stopped", info, error +# "device" state="connecting"|"connected"|"lost"|"stopped", info (HELLO reply +# plus firmware: the `version` reply as a dict, None if too old), +# error, silent (lost: HELLOs in a row an ESP did not answer) # "power" text= # "browse" on=True|False # "receiving" on=True|False, until= @@ -113,6 +115,7 @@ def __init__(self, port=None, name="EspDrop", receive=True, download_dir=None, self._held = None # last hold request sent: True hold, False release self._listening = None # last listen request sent self._scan_ok = False # the ESP can wake itself on AirDrop adverts + self._silent = 0 # HELLOs in a row an ESP did not answer self._lock = threading.RLock() self._stop = threading.Event() self._thread = None @@ -292,13 +295,19 @@ def _run(self): dev = Device(self._open(), on_log=lambda text: self._emit("log", text=text)) try: info = dev.hello(link.MODE_IP) + info["firmware"] = self._firmware(dev) except Exception: dev.close() raise except Exception as e: # noqa: BLE001 - not there yet: try again - self._set_state("lost", error=str(e)) + # An ESP is there but does not speak our protocol (other or no + # firmware, or too old): `silent` counts it, the app offers to + # flash ours. Just plugged in, it may also still be booting. + self._silent = self._silent + 1 if isinstance(e, DeviceError) else 0 + self._set_state("lost", error=str(e), silent=self._silent) self._stop.wait(RECONNECT_SECS) continue + self._silent = 0 with self._lock: self.dev = dev self._scan_ok = bool(info.get("caps", 0) & link.CAP_SCAN) @@ -319,6 +328,15 @@ def _run(self): self._stop.wait(RECONNECT_SECS) self._set_state("stopped") + @staticmethod + def _firmware(dev): + """The firmware's version (flash.parse_version), None if too old to say.""" + from .flash import parse_version + try: + return parse_version(dev.cmd("version", timeout=1.5)) + except DeviceError: + return None + def _serve(self, dev): next_keepalive = time.monotonic() + KEEPALIVE_SECS while not self._stop.wait(0.25): diff --git a/host/espdrop/ui.py b/host/espdrop/ui.py index 5e1c935..a5dcafd 100644 --- a/host/espdrop/ui.py +++ b/host/espdrop/ui.py @@ -33,7 +33,7 @@ "field": "#FFFFFF", "field_border": "#D2D2D7", "segment": "#E8E8ED", "control": "#FFFFFF", "switch_off": "#E2E2E7", "track": "#E5E5EA", "scroll": "#C7C7CC", "hover": "#EDEDF2", - "green": "#34C759", "orange": "#FF9500", "red": "#FF3B30", + "green": "#34C759", "orange": "#FF9500", "red": "#FF3B30", "red_text": "#D70015", "avatar_top": "#A9AEBA", "avatar_bottom": "#858A96", "shadow_alpha": 40, } DARK = { @@ -45,7 +45,7 @@ "field": "#1C1C1E", "field_border": "#48484A", "segment": "#1C1C1E", "control": "#636366", "switch_off": "#48484A", "track": "#3A3A3C", "scroll": "#5A5A5E", "hover": "#38383A", - "green": "#30D158", "orange": "#FF9F0A", "red": "#FF453A", + "green": "#30D158", "orange": "#FF9F0A", "red": "#FF453A", "red_text": "#FF6961", "avatar_top": "#8E8E93", "avatar_bottom": "#636366", "shadow_alpha": 90, } T = dict(LIGHT) # the colours in use; widgets read them when painting @@ -179,6 +179,7 @@ def qss(): QPushButton[kind="primary"] {{ background: {t['accent']}; color: white; font-weight: 600; }} QPushButton[kind="primary"]:hover {{ background: {t['accent_hover']}; }} QPushButton[kind="primary"]:pressed {{ background: {t['accent_pressed']}; }} +QPushButton[kind="primary"]:disabled {{ background: {t['fill']}; color: {t['tertiary']}; }} QPushButton[kind="link"] {{ background: transparent; color: {t['accent']}; padding: 2px 4px; min-height: 0; }} QPushButton[kind="link"]:hover {{ color: {t['accent_hover']}; }} @@ -191,6 +192,13 @@ def qss(): border-radius: 7px; padding: 4px 8px; selection-background-color: {t['accent']}; selection-color: white; }} QLineEdit:focus {{ border: 2px solid {t['focus']}; padding: 3px 7px; }} +QComboBox {{ background: {t['field']}; border: 1px solid {t['field_border']}; + border-radius: 7px; padding: 3px 8px; }} +QComboBox:focus {{ border: 2px solid {t['focus']}; padding: 2px 7px; }} +QComboBox QAbstractItemView {{ background: {t['card']}; border: 1px solid {t['separator']}; + selection-background-color: {t['accent']}; + selection-color: white; }} +QLabel[tone="bad"] {{ color: {t['red_text']}; }} QPlainTextEdit {{ background: {t['card']}; border: 1px solid {t['card_border']}; border-radius: 10px; padding: 6px; }} QMenu {{ background: {t['card']}; border: 1px solid {t['separator']}; border-radius: 9px; diff --git a/host/espdrop/version.py b/host/espdrop/version.py new file mode 100644 index 0000000..558bc9c --- /dev/null +++ b/host/espdrop/version.py @@ -0,0 +1,10 @@ +# EspDrop - the version of the app and of the firmware it ships. +# +# The one place it is set: firmware/spikes/CMakeLists.txt reads it from here +# for the firmware's own version (esp_app_desc_t, the `version` command), so +# the app can tell whether the ESP runs the firmware it comes with. +# +# SPDX-License-Identifier: GPL-3.0-or-later +# Copyright (C) 2026 EspDrop contributors + +__version__ = "0.1.0" diff --git a/host/tests/fake_device.py b/host/tests/fake_device.py index be2dce9..b2d58a0 100644 --- a/host/tests/fake_device.py +++ b/host/tests/fake_device.py @@ -13,6 +13,7 @@ import threading from espdrop import link +from espdrop.version import __version__ try: from apple_mdns import PAD_IP, response @@ -24,12 +25,13 @@ class FakeDevice: """A transport (read/write/close) with a device behind it.""" def __init__(self, answers=None, caps=link.CAP_BLE | link.CAP_IP, version=link.PROTO_VERSION, - ip_ok=True, mac=bytes.fromhex("020000000001")): + ip_ok=True, mac=bytes.fromhex("020000000001"), fw_version=__version__): # answers: {n: [kind, ...]} - after the n-th AirDrop query (1-based) the # device delivers these apple_mdns.response() kinds from the iPad. self.answers = answers or {} self.mdns_port = 8770 # SRV port in the canned iPad answers self.caps, self.version, self.ip_ok, self.mac = caps, version, ip_ok, mac + self.fw_version = fw_version # None: firmware from before the `version` command self.mode = link.MODE_L2 self.udp_ports = set() self.commands = [] @@ -78,6 +80,9 @@ def _handle(self, t, p): reply = "wake off" if arg == "off" else f"wake on {arg} s left" elif text == "wake": reply = "wake off" + elif text == "version": + reply = (f"version {self.fw_version} idf=v6.0.3 chip=esp32c5 rev=1.0 proto=1" + if self.fw_version else "unknown cmd 'version'") elif text.startswith("power"): listen = "on" if "listen on" in text else "off" reply = (f"power auto awake (hold) hold=90s wake=0s listen={listen} " diff --git a/host/tests/test_flash.py b/host/tests/test_flash.py new file mode 100644 index 0000000..09bbf82 --- /dev/null +++ b/host/tests/test_flash.py @@ -0,0 +1,337 @@ +# EspDrop tests - the firmware from the app (flash.py): images are checked +# before anything is written, the version on the ESP, and the esptool calls +# (against stand-ins, and once through the installed esptool's own logger). +# +# ~/opendrop-venv/bin/python -m unittest discover -s host/tests +# +# SPDX-License-Identifier: GPL-3.0-or-later +# Copyright (C) 2026 EspDrop contributors + +import json +import os +import struct +import sys +import tempfile +import types +import unittest +from unittest import mock + +HERE = os.path.dirname(os.path.abspath(__file__)) +sys.path.insert(0, os.path.join(HERE, "..")) + +from espdrop import flash # noqa: E402 +from espdrop.flash import APP_OFFSET, FirmwareError # noqa: E402 + +C5, C3 = 23, 5 + + +def image_header(chip, segments=1): + h = bytearray(24) + h[0], h[1] = 0xE9, segments + struct.pack_into(" 1: + return queue.pop(0) + return queue[0] + return Session(transport_factory=transport, name="MeinPC", receive=False, + download_dir=self.dl, on_event=w.bridge.on_event, + on_ask=w.bridge.on_ask, ask_timeout=w.bridge.ask_timeout) + self.w = self.gui.MainWindow(settings=self.settings, session_factory=factory, + ask_timeout=20.0, tray=False) + self.w.show() + return self.w + + def test_firmware_update(self): + from espdrop.version import __version__ + old = DuplexBridgeDevice(fw_version="0.0.9") + new = DuplexBridgeDevice(fw_version=__version__) + w = self.firmware_window([old, new]) # new: after the restart + self.assertTrue(self.pump(lambda: w.firmware is not None)) + self.assertEqual(w.fw_btn.text(), "Aktualisieren …") + self.assertIn(f"EspDrop 0.0.9 auf dem ESP32-C5 · neu in der App: {__version__}", + w.fw_sub.text()) + d = w.open_firmware_dialog() + self.assertIs(w.open_firmware_dialog(), d) # one dialog at a time + self.assertTrue(d.install_btn.isEnabled()) + self.assertIn(f"EspDrop {__version__} für den ESP32-C5", d.fw_sub.text()) + self.assertEqual(d.port_sub.text(), "/dev/ttyFAKE") + seen = {} + + def flasher(fw, port, on_progress, on_log): + seen["port"], seen["version"] = port, fw.version + seen["port_free"] = w.session._thread is None # the app let go of it + for stage, fraction in (("connect", None), ("write", 0.0), ("write", 0.5), + ("write", 1.0), ("verify", None), ("restart", None)): + on_progress(stage, fraction) + on_log("Hash of data verified.") + d.flasher = flasher + d.install_btn.click() + self.assertTrue(self.pump(lambda: d.status.text().startswith("Fertig"), 10), + d.status.text()) + self.assertEqual(seen, {"port": "/dev/ttyFAKE", "version": __version__, + "port_free": True}) + self.assertTrue(self.pump(lambda: (w.firmware or {}).get("version") == __version__)) + self.assertTrue(w.device_label.text().startswith("ESP verbunden")) + self.assertEqual(w.fw_btn.text(), "Installieren …") + self.assertIn("Hash of data verified.", w.log.toPlainText()) + self.assertEqual(d.install_btn.text(), "Erneut installieren") + d.close() + + def test_firmware_for_an_esp_that_does_not_answer(self): + from espdrop import flash + w = self.firmware_window([DuplexBridgeDevice(version=99)]) # another firmware + self.assertTrue(self.pump(lambda: not w.fw_hint.isHidden()), w.device_label.text()) + self.assertEqual(w.device_label.text(), "ESP antwortet nicht") + d = w.open_firmware_dialog() + self.assertIn("antwortet aber nicht", d.device_sub.text()) + gate = threading.Event() + + def flasher(fw, port, on_progress, on_log): + on_progress("connect", None) + gate.wait(10) + raise flash.FirmwareError("Der ESP ließ sich nicht in den Bootloader schalten.") + d.flasher = flasher + d.install_btn.click() + self.assertTrue(self.pump(lambda: d.status.text() == "Verbinde mit dem ESP …", 5)) + d.close() # not while writing + self.pump(lambda: False, 0.2) + self.assertTrue(d.isVisible()) + self.assertFalse(d.close_btn.isEnabled()) + gate.set() + self.assertTrue(self.pump(lambda: "Bootloader" in d.status.text(), 10)) + self.assertEqual(d.status.property("tone"), "bad") + self.assertEqual(d.install_btn.text(), "Erneut versuchen") + self.assertTrue(d.close_btn.isEnabled()) + d.close() + + def test_built_text(self): + b = self.gui.built_text + self.assertEqual(b("Oct 31 2026 12:34:56"), "31.10.2026, 12:34") + self.assertEqual(b("Jan 5 2027"), "5.1.2027") + self.assertEqual(b("gestern"), "gestern") + def test_power_text(self): t = self.gui.power_text self.assertEqual(t("power auto asleep (none) hold=0s wake=0s"), "Funk aus (spart Strom)") diff --git a/host/tests/test_session.py b/host/tests/test_session.py index 1c62be9..87c7f4a 100644 --- a/host/tests/test_session.py +++ b/host/tests/test_session.py @@ -394,5 +394,44 @@ def factory(): self.assertEqual(made[1].listening, 8771, "receiving should restart on the new device") +class FirmwareTest(SessionCase): + def connected_info(self, fake): + events = Events() + s = Session(transport_factory=lambda: fake, receive=False, on_event=events) + s.start() + try: + events.wait(device_state("connected")) + return [i for i in events.kinds("device") if i["state"] == "connected"][0]["info"] + finally: + s.stop() + + def test_version_on_connect(self): + from espdrop.version import __version__ + fw = self.connected_info(DuplexBridgeDevice())["firmware"] + self.assertEqual((fw["version"], fw["chip"]), (__version__, "esp32c5")) + # Firmware from before the command: connected all the same, version unknown. + self.assertIsNone(self.connected_info(DuplexBridgeDevice(fw_version=None))["firmware"]) + + def test_an_esp_that_does_not_answer(self): + # Another firmware (here: another protocol) fails every HELLO; `silent` + # counts them, so the app can offer to flash ours. No ESP at all: 0. + with mock.patch("espdrop.session.RECONNECT_SECS", 0.05): + self.session = s = Session(transport_factory=lambda: DuplexBridgeDevice(version=99), + receive=False, on_event=self.events) + s.start() + self.events.wait(lambda k, i: k == "device" and i.get("silent", 0) >= 3) + s.stop() + + def absent(): + raise OSError("no ESP found") + events = Events() + self.session = s = Session(transport_factory=absent, receive=False, on_event=events) + s.start() + events.wait(lambda k, i: len([x for x in events.kinds("device") + if x["state"] == "lost"]) >= 3) + s.stop() + self.assertEqual({i["silent"] for i in events.kinds("device") if i["state"] == "lost"}, {0}) + + if __name__ == "__main__": unittest.main() diff --git a/tools/firmware_image.py b/tools/firmware_image.py new file mode 100755 index 0000000..27f273f --- /dev/null +++ b/tools/firmware_image.py @@ -0,0 +1,68 @@ +#!/usr/bin/env python3 +# EspDrop - one image of the whole flash from an ESP-IDF build of the bridge, +# for the app to flash (release builds ship it as espdrop/firmware/ +# espdrop-esp32c5.bin; `espdrop flash FILE` and the app's "Andere Datei …" +# take it too). +# +# python tools/firmware_image.py firmware/spikes/build_c5_bridge +# python tools/firmware_image.py BUILD -o host/espdrop/firmware/espdrop-esp32c5.bin +# +# The build is checked as the app checks it (bridge, not the test console; +# image headers for one chip; the app's version), merged with esptool's +# merge-bin (bootloader, partition table and app at their offsets, 0xFF in +# between, starting at 0), and the result is read back the same way. +# +# SPDX-License-Identifier: GPL-3.0-or-later +# Copyright (C) 2026 EspDrop contributors + +import argparse +import os +import sys + +HERE = os.path.dirname(os.path.abspath(__file__)) +sys.path.insert(0, os.path.join(HERE, "..", "host")) + +from espdrop import flash # noqa: E402 + +CHIP_ARG = {5: "esp32c3", 23: "esp32c5"} + + +def make_image(build, output): + """Merge the build into output; returns the image as flash.load reads it.""" + fw = flash.load(build) + if len(fw.parts) < 2: + raise flash.FirmwareError(f"{build} is already one image") + from esptool.cmds import merge_bin + data = merge_bin(fw.parts, CHIP_ARG[fw.chip_id], output=None) + os.makedirs(os.path.dirname(os.path.abspath(output)), exist_ok=True) + tmp = output + ".tmp" + with open(tmp, "wb") as f: + f.write(data) + os.replace(tmp, output) + merged = flash.load(output) + if (merged.version, merged.chip_id) != (fw.version, fw.chip_id): + raise flash.FirmwareError("the merged image does not read back as the build") + for offset, part in fw.parts: # every part where the ROM expects it + if data[offset:offset + len(part)] != part: + raise flash.FirmwareError(f"part at {offset:#x} differs in the merged image") + return merged + + +def main(argv=None): + ap = argparse.ArgumentParser(description=__doc__) + ap.add_argument("build", help="ESP-IDF build folder of the bridge (flasher_args.json)") + ap.add_argument("-o", "--output", default=flash.FIRMWARE_NAME, + help=f"image to write (default: {flash.FIRMWARE_NAME})") + args = ap.parse_args(argv) + try: + fw = make_image(args.build, args.output) + except (flash.FirmwareError, ImportError) as e: + print(f"error: {e}", file=sys.stderr) + return 1 + print(f"{args.output}: EspDrop {fw.version} for the {fw.chip}, built {fw.built}, " + f"IDF {fw.idf}, {fw.size // 1024} KiB") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tools/opendrop_install.py b/tools/opendrop_install.py index 575e493..d4b2ff4 100644 --- a/tools/opendrop_install.py +++ b/tools/opendrop_install.py @@ -7,7 +7,8 @@ # The patches and every resulting file are checked against pinned digests (those # of the install the bash script makes), so Windows and Linux run byte-identical # OpenDrop code. On top come pyserial (COM ports), cryptography (the TLS -# certificate, in place of the openssl command) and PySide6 (the desktop app). +# certificate, in place of the openssl command), PySide6 (the desktop app) and +# esptool (putting the firmware on the ESP from the app). # # py tools\opendrop_install.py Windows (Python 3.9+ from python.org) # python3 tools/opendrop_install.py works on Linux too @@ -79,7 +80,7 @@ "opendrop/server.py": "be731940c314f2b2409c263d6585c407cb935c93c00de805affd602693d76503", "opendrop/util.py": "390ee35c01c3d332e57be49266610a44bd658dbe1fe16084a5e5bfeccb30fe2c", } -EXTRAS = ["pyserial", "cryptography", "PySide6"] +EXTRAS = ["pyserial", "cryptography", "PySide6", "esptool"] # Markers in the venv, shared with the bash script: WIP while one of us builds # it, OK (holding the series signature) once every patch landed and was checked. STAMP_WIP = ".espdrop-installing" @@ -379,10 +380,10 @@ def main(): if up_to_date(venv): print(f"\nalready installed and up to date: {venv}") - extras = [m for m in ("serial", "cryptography", "PySide6") + extras = [m for m in ("serial", "cryptography", "PySide6", "esptool") if subprocess.run([py, "-c", f"import {m}"], capture_output=True).returncode] if extras: # a venv the bash script made, or one from before the GUI - step("adding pyserial, cryptography and PySide6") + step("adding pyserial, cryptography, PySide6 and esptool") run([py, "-m", "pip", "install", "-q"] + EXTRAS, "pip install") else: step(f"fetching the patch series (rev {REV})") From 172fe227a821391df9addff6b96ba339380eba4b Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 31 Oct 2026 13:56:05 +0000 Subject: [PATCH 85/99] The app as a native program: Nuitka build for Windows and Linux - tools/build_app.py: Nuitka translates the Python code (ours, the patched OpenDrop, every library) to C and compiles it with the Python runtime, Qt and the ESP firmware into one folder (EspDrop/EspDrop(.exe)), packed as a zip (Windows) or tar.gz (Linux). It checks the build venv (patched OpenDrop, PySide6, esptool, Nuitka), draws the icons, takes a firmware image or build folder (checked as the app checks it), bundles OpenDrop's root certificate and esptool's flasher stubs, on Linux libarchive as a fallback, LICENSE and a list of the packages with their licenses. --smoke starts the result: --version, check, the window offscreen. - The compiled program is the whole app: without arguments the window, with arguments the command line (Windows: attaches to the console it was started from). espdrop/frozen.py finds the program file (Nuitka sets __compiled__, not sys.frozen), so autostart and the right-click entries start the program itself; libarchive next to it is a last candidate. - Linux: an application menu entry (espdrop.desktop) at every start, and the windows name it as their desktop file, so Wayland docks show our icon. - espdrop check verifies OpenDrop's root certificate the way AirDropConfig looks it up. ESPDROP_INSTANCE and ESPDROP_NO_DESKTOP let a test copy run beside the real app without touching the desktop. - .gitignore: the test_awdl_power binary. 91 host tests pass. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01XzgaN4Bv1m8Ka9iqo7QVUN --- .gitignore | 1 + docs/SPIKES.md | 40 +++++ host/README.md | 46 ++++++ host/espdrop/cli.py | 5 + host/espdrop/env.py | 13 ++ host/espdrop/frozen.py | 36 +++++ host/espdrop/gui.py | 14 +- host/espdrop/integration.py | 37 ++++- host/tests/test_integration.py | 41 +++++ tools/build_app.py | 273 +++++++++++++++++++++++++++++++++ 10 files changed, 501 insertions(+), 5 deletions(-) create mode 100644 host/espdrop/frozen.py create mode 100644 tools/build_app.py diff --git a/.gitignore b/.gitignore index 3f3a71c..1f254e0 100644 --- a/.gitignore +++ b/.gitignore @@ -37,6 +37,7 @@ logs/ # Native host test binary test_awdl_frame test_awdl_cobs +test_awdl_power # Separate build dirs for the bridge-mode / per-chip firmware build_bridge/ diff --git a/docs/SPIKES.md b/docs/SPIKES.md index 64d77f3..9881f70 100644 --- a/docs/SPIKES.md +++ b/docs/SPIKES.md @@ -711,6 +711,46 @@ Build) sowie Erweiterungen in `test_session.py` (Version beim Verbinden, stummer ESP) und `test_gui.py` (Aktualisieren mit Neuverbinden, Fehlerfall, kein Schließen während des Schreibens). +## Die App als Programm (Nuitka, Windows und Linux) + +Die App gibt es jetzt auch als eigenständiges Programm, ohne Python: Nuitka +übersetzt den Python-Code (unseren, das gepatchte OpenDrop und alle +Bibliotheken) nach C, kompiliert ihn und legt ihn mit Python-Laufzeit, Qt und +der ESP-Firmware in einen Ordner (`tools/build_app.py`, host/README.md). Das +Programm `EspDrop.exe` bzw. `EspDrop` ist die ganze App: ohne Argumente das +Fenster, mit Argumenten die Kommandozeile (`EspDrop flash`, `EspDrop check`). + +GitHub baut es bei jedem Push (`.github/workflows/build.yml`): erst die +Firmware mit ESP-IDF 6.0.3 als ein Image, dann das Programm für Windows und +Linux mit dieser Firmware, jeweils mit einem Starttest; dazu die Tests. Die +Dateien liegen auf der Seite des Laufs unter „Artifacts“ +(`EspDrop-Windows`, `EspDrop-Linux`, `firmware`). Ein Tag `v0.1.0` legt einen +Release-Entwurf mit allen dreien an. + +Unter Linux bringt die App jetzt auch einen Eintrag im Anwendungsmenü mit +(`~/.local/share/applications/espdrop.desktop`, mit Symbol); die Fenster sind +ihm zugeordnet, so zeigen Dock und Taskleiste auch unter Wayland das Symbol. + +Test unter Windows: +1. `EspDrop-0.1.0-windows-x64.zip` entpacken, z. B. nach + `%LOCALAPPDATA%\Programs\EspDrop`, `EspDrop.exe` starten (SmartScreen: + „Weitere Informationen“ → „Trotzdem ausführen“, das Programm ist nicht + signiert). Vorher die laufende App aus dem Checkout beenden. +2. Das Fenster erscheint mit Symbol in der Taskleiste; Autostart und + Rechtsklickmenü zeigen jetzt auf `EspDrop.exe` (Rechtsklick auf eine Datei → + „Mit EspDrop senden“ öffnet das AirDrop-Blatt). +3. Allgemein → „ESP-Firmware“ → „Aktualisieren …“: die Firmware aus dem + Programm aufspielen (Abschnitt oben), danach senden und empfangen. +4. In einer Eingabeaufforderung im Ordner: `EspDrop.exe check` und + `EspDrop.exe status` geben ihre Ausgabe in diesem Fenster aus. + +Test unter Linux: +1. `tar xzf EspDrop-0.1.0-linux-x86_64.tar.gz`, `EspDrop/EspDrop` starten + (Zugriff auf `/dev/ttyACM0` über die Gruppe `dialout` wie bisher). +2. Im Anwendungsmenü steht danach „EspDrop“ mit Symbol; Autostart und + „Öffnen mit → AirDrop (EspDrop)“ zeigen auf das Programm. +3. Firmware aktualisieren, senden, empfangen wie unter Windows. + ## Danach Ergebnisse (auch Fehlschläge) in `docs/UEBERBLICK.md` unter „Offene Fragen" diff --git a/host/README.md b/host/README.md index 52f6512..b0f3053 100644 --- a/host/README.md +++ b/host/README.md @@ -117,6 +117,52 @@ at 0x8000, the app with its description (version, build date, IDF) at BUILD` makes the one-file image from a build (esptool's merge-bin, checked the same way). +## The app as a program (Nuitka) + +`tools/build_app.py` makes the app a native program that needs no Python: +Nuitka translates the Python code (ours, the patched OpenDrop and every +library they use) to C, compiles it, and puts it with the Python runtime, Qt +and the ESP firmware into one folder, `build/app/EspDrop/`, packed as +`build/EspDrop--windows-x64.zip` or `-linux-x86_64.tar.gz`. The +program `EspDrop(.exe)` is the whole app: without arguments the window, with +arguments the command line (`EspDrop find`, `EspDrop flash`, ...; on Windows +it uses the console it was started from and opens none of its own). + +```sh +~/opendrop-venv/bin/pip install nuitka patchelf # Linux +~/opendrop-venv/bin/python tools/build_app.py --firmware espdrop-esp32c5.bin --smoke +``` +```bat +%USERPROFILE%\opendrop-venv\Scripts\pip install nuitka +%USERPROFILE%\opendrop-venv\Scripts\python tools\build_app.py --zig --smoke +``` + +It must run with the venv of `opendrop_install.py` (it checks the OpenDrop is +the patched one). Windows needs a C compiler: Visual Studio or its Build Tools +with C++, or `--zig` (Nuitka downloads Zig). `--firmware` takes an image +(`tools/firmware_image.py`), `--firmware-build` an ESP-IDF build folder; +without either it takes what `espdrop flash` would, else it builds without +firmware. `--smoke` starts the result: `--version`, `check` (OpenDrop, its +root certificate, libarchive, esptool, Qt) and the window offscreen. + +The program finds its parts next to itself (the firmware as +`espdrop/firmware/espdrop-esp32c5.bin`). On Linux it uses the system's +libarchive and carries `libarchive.so.13` from the build machine as a +fallback. At its first start it puts its entries where the desktop looks +(autostart, the right-click menu, on Linux also the application menu), all +pointing at the program itself; a moved folder is followed at the next start. + +**Builds on GitHub** (`.github/workflows/build.yml`), on every push that +touches the code: the firmware with ESP-IDF 6.0.3 (container +`espressif/idf:v6.0.3`) merged into one image; the app with Nuitka on Windows +and on Ubuntu 22.04 (its glibc is the oldest the Linux build needs), the +firmware inside, each started with `--smoke`; the host and firmware tests. The +files are on the run's page (Artifacts). A tag `v*` drafts a release with +them. + +The Windows program is not signed: SmartScreen asks once ("Weitere +Informationen" → "Trotzdem ausführen"). + ## Windows 10/11 Needs Python 3.9+ from python.org (64 bit, with the `py` launcher) and this diff --git a/host/espdrop/cli.py b/host/espdrop/cli.py index 1ec465d..6204a47 100644 --- a/host/espdrop/cli.py +++ b/host/espdrop/cli.py @@ -444,6 +444,11 @@ def main(argv=None, transport=None): help="tray only, receiving until switched off (what autostart runs)") g.add_argument("--send", nargs="*", metavar="FILE", help="ask whom to send these files to (Explorer's \"Senden an\")") + if argv is None: + argv = sys.argv[1:] + from .frozen import compiled + if not argv and compiled(): + argv = ["gui"] # double-clicked: the desktop app args = ap.parse_args(argv) # Our steps (connect, accept, upload rate) at INFO; OpenDrop's own only from # WARNING on, unless -v asks for everything, with milliseconds since start. diff --git a/host/espdrop/env.py b/host/espdrop/env.py index 70cae5e..238c3f9 100644 --- a/host/espdrop/env.py +++ b/host/espdrop/env.py @@ -73,6 +73,9 @@ def recorded_libarchive(): return None +BUNDLED_LIBARCHIVE = ("libarchive.so.13",) # next to the compiled program (Linux) + + def libarchive_candidates(): """Where to look, best first: a path, or None for the normal library search (PATH on Windows, ldconfig on Linux; what libarchive-c does by itself).""" @@ -86,6 +89,11 @@ def libarchive_candidates(): if os.name == "nt": out.append(os.path.join(os.environ.get("SystemRoot", r"C:\Windows"), "System32", "archiveint.dll")) + from .frozen import program_dir + bundled = program_dir() + if bundled: # the Linux build carries a copy + out += [os.path.join(bundled, n) for n in BUNDLED_LIBARCHIVE + if os.path.isfile(os.path.join(bundled, n))] return out @@ -381,6 +389,11 @@ def line(ok, what, detail): except ImportError as e: line(False, "opendrop", str(e)) return problems + try: # what AirDropConfig needs (and looks up so) + ca = opendrop.config.resource_filename("opendrop", "certs/apple_root_ca.pem") + line(os.path.isfile(ca), "root ca", ca if os.path.isfile(ca) else f"missing: {ca}") + except Exception as e: # noqa: BLE001 - report, do not crash + line(False, "root ca", f"{type(e).__name__}: {e}") try: ok, detail, warnings = _archive_selftest() line(ok, "archive", detail) diff --git a/host/espdrop/frozen.py b/host/espdrop/frozen.py new file mode 100644 index 0000000..05ea702 --- /dev/null +++ b/host/espdrop/frozen.py @@ -0,0 +1,36 @@ +# EspDrop - running as the compiled program (Nuitka, tools/build_app.py) or +# from the checkout with Python. +# +# Nuitka marks every compiled module with a global `__compiled__` (it does not +# set sys.frozen): containing_dir is the program's folder, original_argv0 the +# onefile binary's argv[0] (None in a standalone folder). +# +# SPDX-License-Identifier: GPL-3.0-or-later +# Copyright (C) 2026 EspDrop contributors + +import os +import sys + + +def compiled(): + """True in the compiled program (or another freezer's, sys.frozen).""" + return "__compiled__" in globals() or bool(getattr(sys, "frozen", False)) + + +def program(): + """The program file to start again (autostart, the file manager): the + onefile binary itself rather than its unpacked copy. None from Python.""" + if "__compiled__" in globals(): + info = globals()["__compiled__"] + folder = getattr(info, "containing_dir", None) or os.path.dirname(sys.executable) + name = getattr(info, "original_argv0", None) or sys.executable + return os.path.join(folder, os.path.basename(name)) + if getattr(sys, "frozen", False): + return sys.executable + return None + + +def program_dir(): + """The compiled program's folder (bundled libraries sit there), else None.""" + p = program() + return os.path.dirname(p) if p else None diff --git a/host/espdrop/gui.py b/host/espdrop/gui.py index 076af1e..0c16336 100644 --- a/host/espdrop/gui.py +++ b/host/espdrop/gui.py @@ -42,6 +42,7 @@ from . import flash, integration, ui from .session import Session +from .version import __version__ APP = "EspDrop" # Who may send to us, as AirDrop on the iPhone puts it: label, seconds (0: off, @@ -111,7 +112,10 @@ def what_text(files, urls=()): # ---- one copy per user ------------------------------------------------------- def instance_name(): - """The local socket (a named pipe on Windows) of this user's running copy.""" + """The local socket (a named pipe on Windows) of this user's running copy + (ESPDROP_INSTANCE: another one, for a test run beside the real app).""" + if os.environ.get("ESPDROP_INSTANCE"): + return re.sub(r"[^A-Za-z0-9_-]", "_", os.environ["ESPDROP_INSTANCE"]) user = re.sub(r"[^A-Za-z0-9_-]", "_", getpass.getuser() or "user") return f"espdrop-{user}" @@ -1504,7 +1508,10 @@ def _set_app_id(): def _desktop_setup(settings): """Icon file for the desktop, then autostart and the file manager entry: - on by default at the very first start, kept pointing at this app after.""" + on by default at the very first start, kept pointing at this app after. + ESPDROP_NO_DESKTOP leaves the desktop alone (a test run of a build).""" + if os.environ.get("ESPDROP_NO_DESKTOP"): + return try: integration.ICON = ui.write_icon_file(integration.data_dir()) except OSError: @@ -1521,6 +1528,9 @@ def main(port=None, background=False, send=None): _set_app_id() app = QApplication.instance() or QApplication(sys.argv) app.setApplicationName(APP) + app.setApplicationVersion(__version__) + if sys.platform.startswith("linux"): + app.setDesktopFileName("espdrop") # integration.set_launcher() app.setQuitOnLastWindowClosed(False) app.setWindowIcon(ui.app_icon()) ui.apply(app) diff --git a/host/espdrop/integration.py b/host/espdrop/integration.py index e8bb824..5c0b3bb 100644 --- a/host/espdrop/integration.py +++ b/host/espdrop/integration.py @@ -11,6 +11,9 @@ # Linux (XDG): # - autostart: ~/.config/autostart/espdrop.desktop # - "Öffnen mit": ~/.local/share/applications/espdrop-send.desktop +# - the application menu: ~/.local/share/applications/espdrop.desktop, always +# (gui.py names it as the windows' desktop file, so Wayland docks show our +# icon) # # Each entry starts the app as `espdrop gui ...` with the interpreter and # checkout it runs from now; the app forwards to an already running copy @@ -40,9 +43,11 @@ def launch_command(*args): """argv that starts the app with these arguments, as it runs now: the venv's pythonw on Windows (no console window), tools/espdrop.py of this - checkout; a frozen build starts itself.""" - if getattr(sys, "frozen", False): - return [sys.executable, "gui", *args] + checkout; the compiled program starts itself.""" + from .frozen import program + exe = program() + if exe: + return [exe, "gui", *args] exe = sys.executable if IS_WINDOWS: w = os.path.join(os.path.dirname(exe), "pythonw.exe") @@ -173,10 +178,27 @@ def _sendto_path(): return os.path.join(appdata, "Microsoft", "Windows", "SendTo", SENDTO_NAME) +def set_launcher(): + """Linux: the app in the desktop's application menu (not a choice: it is + how one starts an installed program there).""" + if IS_WINDOWS or not sys.platform.startswith("linux"): + return + path = _xdg_launcher() + if _write_desktop(path, { + "Type": "Application", "Name": APP, "GenericName": "AirDrop", + "Comment": "AirDrop mit iPhone, iPad und Mac über den ESP32-C5", + "Exec": _exec_line(launch_command()), "Icon": ICON or "document-send", + "Terminal": "false", "Categories": "Network;FileTransfer;", + "Keywords": "AirDrop;iPhone;iPad;Mac;teilen;senden;", + "StartupWMClass": APP}): + _refresh_desktop_db(os.path.dirname(path)) + + def refresh(): """Keep enabled entries pointing at the app as it runs now: a checkout or venv that moved, a new icon. Rewrites only what differs (on Windows the "Senden an" shortcut costs a PowerShell start).""" + set_launcher() cmd = launch_command("--background") if autostart_enabled(): if IS_WINDOWS: @@ -199,6 +221,10 @@ def apply_defaults(): """First start: autostart and the file manager entry are on by default (the user can switch them off; this runs only once). Returns what failed.""" errors = [] + try: + set_launcher() + except OSError as e: + errors.append(f"set_launcher: {e}") caps = supported() for ok, fn in ((caps["autostart"], set_autostart), (caps["send"], set_send_menu)): if ok: @@ -258,6 +284,11 @@ def _xdg_autostart(): return os.path.join(base, "autostart", "espdrop.desktop") +def _xdg_launcher(): + base = os.environ.get("XDG_DATA_HOME") or os.path.expanduser("~/.local/share") + return os.path.join(base, "applications", "espdrop.desktop") + + def _xdg_send(): base = os.environ.get("XDG_DATA_HOME") or os.path.expanduser("~/.local/share") return os.path.join(base, "applications", "espdrop-send.desktop") diff --git a/host/tests/test_integration.py b/host/tests/test_integration.py index 0990b40..27508c3 100644 --- a/host/tests/test_integration.py +++ b/host/tests/test_integration.py @@ -133,11 +133,52 @@ def test_defaults_and_icon(self): self.assertEqual(entry["Icon"], "/x/espdrop.png") self.assertEqual(integration.data_dir(), os.path.join(self.tmp.name, "data", "espdrop")) + def test_launcher_in_the_application_menu(self): + with mock.patch.object(integration, "ICON", "/x/espdrop.png"): + integration.refresh() # every start, nothing switched on + entry = self.read(os.path.join(self.tmp.name, "data", "applications", + "espdrop.desktop")) + self.assertTrue(entry["Exec"].endswith(" gui"), entry["Exec"]) + self.assertEqual((entry["Name"], entry["Icon"], entry["StartupWMClass"]), + ("EspDrop", "/x/espdrop.png", "EspDrop")) + self.assertNotIn("NoDisplay", entry) + self.assertFalse(integration.autostart_enabled()) + + def test_compiled_program_starts_itself(self): + with mock.patch("espdrop.frozen.program", lambda: "/opt/EspDrop/EspDrop"): + self.assertEqual(integration.launch_command("--send"), + ["/opt/EspDrop/EspDrop", "gui", "--send"]) + integration.set_autostart(True) + entry = self.read(os.path.join(self.tmp.name, "config", "autostart", "espdrop.desktop")) + self.assertEqual(entry["Exec"], "/opt/EspDrop/EspDrop gui --background") + def test_exec_quoting(self): line = integration._exec_line(["/opt/my venv/bin/python", "/x/50%.py", "gui"]) self.assertEqual(line, '"/opt/my venv/bin/python" /x/50%%.py gui') +class FrozenTest(unittest.TestCase): + def test_program_paths(self): + from espdrop import frozen + self.assertIsNone(frozen.program()) # from Python + self.assertFalse(frozen.compiled()) + + class Compiled: # Nuitka's __compiled__ + containing_dir = os.path.join(os.sep, "opt", "EspDrop") + original_argv0 = None + with mock.patch.dict(frozen.__dict__, {"__compiled__": Compiled}), \ + mock.patch.object(sys, "executable", os.path.join(os.sep, "opt", "EspDrop", + "EspDrop")): + self.assertTrue(frozen.compiled()) + self.assertEqual(frozen.program(), os.path.join(os.sep, "opt", "EspDrop", "EspDrop")) + # onefile: the binary the user started, not its unpacked copy + Compiled.containing_dir = os.path.join(os.sep, "home", "u", "Apps") + Compiled.original_argv0 = "EspDrop-x86_64" + self.assertEqual(frozen.program(), + os.path.join(os.sep, "home", "u", "Apps", "EspDrop-x86_64")) + self.assertEqual(frozen.program_dir(), os.path.join(os.sep, "home", "u", "Apps")) + + class WindowsTest(unittest.TestCase): def setUp(self): self.tmp = tempfile.TemporaryDirectory() diff --git a/tools/build_app.py b/tools/build_app.py new file mode 100644 index 0000000..c8d47eb --- /dev/null +++ b/tools/build_app.py @@ -0,0 +1,273 @@ +#!/usr/bin/env python3 +"""EspDrop - the app as a native program, which needs no Python to run. + +Nuitka translates the Python code (ours, the patched OpenDrop and every +library they use) to C, compiles it, and puts it together with the Python +runtime, Qt and the ESP's firmware into one folder: + + build/app/EspDrop/EspDrop(.exe) the program + build/EspDrop--windows-x64.zip what a release ships + build/EspDrop--linux-x86_64.tar.gz + +The program is the whole app: started without arguments it is the window, +with arguments the command line (EspDrop find, EspDrop flash, ...; on Windows +it uses the console it was started from and opens none of its own). + +Run it with the Python of the venv tools/opendrop_install.py made (the patched +OpenDrop must be in there), after `pip install nuitka` (Linux also +`pip install patchelf`). Windows needs a C compiler: Visual Studio or its +Build Tools with C++, or --zig (Nuitka then downloads Zig itself). + + ~/opendrop-venv/bin/python tools/build_app.py --firmware espdrop-esp32c5.bin + %USERPROFILE%\\opendrop-venv\\Scripts\\python tools\\build_app.py --zig + +Without --firmware/--firmware-build it takes what `espdrop flash` would +(ESPDROP_FIRMWARE, a local ESP-IDF build of the bridge); with none at all the +app is built without one and asks for a file when it should flash. +""" +# SPDX-License-Identifier: GPL-3.0-or-later +# Copyright (C) 2026 EspDrop contributors + +import argparse +import os +import platform +import shutil +import subprocess +import sys +import tarfile +import time +import zipfile + +HERE = os.path.dirname(os.path.abspath(__file__)) +ROOT = os.path.dirname(HERE) +HOST = os.path.join(ROOT, "host") +sys.path[:0] = [HOST, HERE] + +from espdrop import flash # noqa: E402 +from espdrop.version import __version__ # noqa: E402 + +IS_WINDOWS = os.name == "nt" +NAME = "EspDrop" +# What the compiled program carries besides its code (and what it is built from). +PACKAGES = ["espdrop", "opendrop", "esptool"] +DATA_PACKAGES = ["opendrop", "esptool"] # Apple's root certificate; the flasher stubs + + +def step(text): + print(f"\n== {text}", flush=True) + + +def check_build_env(): + """The venv has what the app needs, and Nuitka; else SystemExit with why.""" + missing = [] + for module in ("PySide6", "serial", "cryptography", "zeroconf", "esptool", "nuitka"): + try: + __import__(module) + except ImportError: + missing.append(module) + if missing: + sys.exit(f"error: {', '.join(missing)} missing in {sys.executable}; run it with the " + "venv of tools/opendrop_install.py, after: pip install nuitka" + + ("" if IS_WINDOWS else " patchelf")) + from espdrop import env + try: + env.load_opendrop() + import opendrop.server + except ImportError as e: + sys.exit(f"error: {e}") + with open(opendrop.server.__file__, encoding="utf-8") as f: + if "trailing CRLF of the final chunk" not in f.read(): + sys.exit("error: this OpenDrop is not patched; build with the venv of " + "tools/opendrop_install.py") + + +def make_icons(res): + """The app's mark as files: .ico for the Windows program, .png for Linux.""" + os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") + from PySide6.QtGui import QGuiApplication + if QGuiApplication.instance() is None: # painting needs one, alive meanwhile + make_icons.app = QGuiApplication([]) + from espdrop import ui + os.makedirs(res, exist_ok=True) + paths = {} + for name, data in (("espdrop.ico", ui.ico_bytes()), ("espdrop.png", ui._png_bytes(256))): + paths[name] = os.path.join(res, name) + with open(paths[name], "wb") as f: + f.write(data) + return paths + + +def firmware(args, res): + """The firmware image to ship (path), checked; None to build without one.""" + import firmware_image + source = args.firmware or args.firmware_build or flash.bundled() + if not source: + print("warning: no firmware to ship (--firmware IMAGE or --firmware-build DIR); " + "the app will ask for a file when it flashes", flush=True) + return None + fw = flash.load(source) + if len(fw.parts) > 1: # a build folder: merge it into one image + out = os.path.join(res, flash.FIRMWARE_NAME) + fw = firmware_image.make_image(source, out) + source = out + if fw.chip_id != 23: + sys.exit(f"error: {source} is for the {fw.chip}; the app ships the ESP32-C5's") + print(f"firmware: EspDrop {fw.version} for the {fw.chip}, built {fw.built} ({source})") + if fw.version != __version__: + print(f"warning: the firmware is {fw.version}, the app {__version__}", flush=True) + return source + + +def system_libarchive(): + """The libarchive the build machine's OpenDrop uses, as a file (Linux).""" + from espdrop import env + path, _detail = env.prepare_libarchive() + if path and os.path.isfile(path): + return os.path.realpath(path) + out = subprocess.run(["ldconfig", "-p"], capture_output=True, text=True).stdout + for line in out.splitlines(): + name, _, where = line.strip().partition(" => ") + if name.startswith(env.BUNDLED_LIBARCHIVE[0] + " ") and os.path.isfile(where): + return os.path.realpath(where) + sys.exit("error: libarchive.so.13 not found on this machine (apt install libarchive13)") + + +def nuitka_command(args, out, icons, fw): + version4 = ".".join((__version__.split(".") + ["0", "0", "0"])[:4]) + cmd = [sys.executable, "-m", "nuitka", "--mode=standalone", + f"--output-dir={out}", f"--output-folder-name={NAME}", f"--output-filename={NAME}", + "--enable-plugin=pyside6", "--include-qt-plugins=platforminputcontexts", + "--noinclude-qt-translations", "--nofollow-import-to=tkinter", + "--no-prefer-source-code", # zeroconf's compiled modules as they are + "--assume-yes-for-downloads", f"--report={os.path.join(out, 'nuitka-report.xml')}", + f"--product-name={NAME}", f"--file-version={version4}", + f"--product-version={version4}", "--company-name=EspDrop contributors", + "--file-description=EspDrop - AirDrop über den ESP32-C5", + "--copyright=GPL-3.0-or-later"] + cmd += [f"--include-package={p}" for p in PACKAGES] + cmd += [f"--include-package-data={p}" for p in DATA_PACKAGES] + if fw: + cmd.append(f"--include-data-files={fw}=espdrop/firmware/{flash.FIRMWARE_NAME}") + if IS_WINDOWS: + cmd += ["--windows-console-mode=attach", f"--windows-icon-from-ico={icons['espdrop.ico']}"] + if args.zig: + cmd.append("--zig") + if args.jobs: + cmd.append(f"--jobs={args.jobs}") + cmd.append(os.path.join(HERE, "espdrop.py")) + return cmd + + +def third_party(dist): + """LICENSE and a list of what the program carries, with each license.""" + from importlib import metadata + shutil.copy2(os.path.join(ROOT, "LICENSE"), os.path.join(dist, "LICENSE.txt")) + rows = [] + for d in metadata.distributions(): + name = d.metadata.get("Name") or "?" + if name.lower() in ("nuitka", "pip", "patchelf", "ordered-set", "zstandard", "wheel"): + continue + lic = (d.metadata.get("License-Expression") or d.metadata.get("License") or "").strip() + if not lic or len(lic) > 80: # a whole license text: name the classifier + lic = next((c.split(" :: ")[-1] for c in d.metadata.get_all("Classifier") or [] + if c.startswith("License ::")), lic.splitlines()[0] if lic else "?") + rows.append(f"{name} {d.version}: {lic}") + with open(os.path.join(dist, "THIRD_PARTY.txt"), "w", encoding="utf-8") as f: + f.write(f"{NAME} {__version__} (GPL-3.0-or-later, LICENSE.txt; source code: " + "https://github.com/antiwireless/espdrop) is built with Python and these " + "packages, each under its own license:\n\n" + "\n".join(sorted(rows, key=str.lower)) + + "\n\nQt (in PySide6) is used under the LGPL-3.0; its libraries are the " + "separate files in this folder.\nlibarchive (Linux, libarchive.so.13): " + "BSD-2-Clause.\n") + + +def package(dist, build): + machine = platform.machine().lower() + if IS_WINDOWS: + arch = {"amd64": "x64", "x86_64": "x64", "arm64": "arm64"}.get(machine, machine) + path = os.path.join(build, f"{NAME}-{__version__}-windows-{arch}.zip") + with zipfile.ZipFile(path, "w", zipfile.ZIP_DEFLATED, compresslevel=9) as z: + for folder, _dirs, files in os.walk(dist): + for name in files: + full = os.path.join(folder, name) + z.write(full, os.path.join(NAME, os.path.relpath(full, dist))) + else: + path = os.path.join(build, f"{NAME}-{__version__}-linux-{machine}.tar.gz") + with tarfile.open(path, "w:gz") as t: + t.add(dist, arcname=NAME) + return path + + +def smoke(dist): + """The built program starts: --version, check, and the window (offscreen).""" + exe = os.path.join(dist, NAME + (".exe" if IS_WINDOWS else "")) + v = subprocess.run([exe, "--version"], capture_output=True, text=True, timeout=120) + if v.stdout.strip() != f"espdrop {__version__}": + sys.exit(f"smoke: --version said {v.stdout!r} {v.stderr!r}") + c = subprocess.run([exe, "check"], capture_output=True, text=True, timeout=300) + print(c.stdout, c.stderr, flush=True) + for need in ("ok opendrop", "ok root ca", "ok libarchive", "ok esptool", + "ok pyside6"): + if need not in c.stdout: + sys.exit(f"smoke: `check` lacks {need!r}") + # Hidden, no desktop entries for this test copy, beside a running EspDrop. + env = dict(os.environ, QT_QPA_PLATFORM="offscreen", ESPDROP_NO_DESKTOP="1", + ESPDROP_INSTANCE=f"espdrop-smoke-{os.getpid()}") + p = subprocess.Popen([exe, "-p", "none-such-port", "gui", "--background"], env=env, + stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True) + try: + time.sleep(15) + if p.poll() is not None: + sys.exit(f"smoke: the window ended (rc {p.returncode}): {p.stdout.read()}") + finally: + p.kill() + p.wait(10) + print("smoke: --version, check and the window are fine", flush=True) + + +def main(argv=None): + ap = argparse.ArgumentParser(description=__doc__.split("\n\n")[0]) + ap.add_argument("--firmware", help="firmware image to ship (espdrop-esp32c5.bin)") + ap.add_argument("--firmware-build", help="ESP-IDF build folder of the bridge to ship") + ap.add_argument("--zig", action="store_true", help="Windows: compile with Zig, not MSVC") + ap.add_argument("--jobs", type=int, help="parallel C compilations (default: CPUs)") + ap.add_argument("--smoke", action="store_true", help="start the built program to check it") + ap.add_argument("--out", default=os.path.join(ROOT, "build"), help="build folder") + args = ap.parse_args(argv) + + step("checking the build environment") + check_build_env() + build = os.path.abspath(args.out) + out, res = os.path.join(build, "app"), os.path.join(build, "app-res") + icons = make_icons(res) + fw = firmware(args, res) + + step(f"compiling {NAME} {__version__} with Nuitka (takes a while)") + dist = os.path.join(out, NAME) + if os.path.isdir(dist): + shutil.rmtree(dist) + env = dict(os.environ, PYTHONPATH=os.pathsep.join( + p for p in (HOST, os.environ.get("PYTHONPATH")) if p)) + r = subprocess.run(nuitka_command(args, out, icons, fw), env=env) + if r.returncode or not os.path.isdir(dist): + sys.exit(f"error: Nuitka failed (rc {r.returncode})") + + step("adding what the program needs beside its code") + if not IS_WINDOWS: + lib = system_libarchive() + shutil.copy2(lib, os.path.join(dist, "libarchive.so.13")) + print(f"libarchive: {lib} (a fallback; the system's comes first)") + shutil.copy2(icons["espdrop.png"], os.path.join(dist, "espdrop.png")) + third_party(dist) + if args.smoke: + step("starting the built program") + smoke(dist) + step("packing") + path = package(dist, build) + print(f"{path} ({os.path.getsize(path) / 1e6:.0f} MB)\nthe program: " + f"{os.path.join(dist, NAME + ('.exe' if IS_WINDOWS else ''))}") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) From 44ed92ed6e1cc5723bf4ecb262d01f98c73d7604 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 31 Oct 2026 13:56:05 +0000 Subject: [PATCH 86/99] CI: firmware, the app for Windows and Linux, tests On pushes that touch the code: the ESP32-C5 bridge firmware with ESP-IDF 6.0.3 (espressif/idf:v6.0.3), merged into one image with tools/firmware_image.py; the app compiled with Nuitka on Windows and on Ubuntu 22.04 with that firmware inside, each started with --smoke; the host tests (offscreen) and the firmware host tests. The files are the run's artifacts; a tag v* drafts a release with them. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01XzgaN4Bv1m8Ka9iqo7QVUN --- .github/workflows/build.yml | 134 ++++++++++++++++++++++++++++++++++++ 1 file changed, 134 insertions(+) create mode 100644 .github/workflows/build.yml diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 0000000..4896d91 --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,134 @@ +# EspDrop - builds: the ESP32-C5 firmware with ESP-IDF, then the app compiled +# with Nuitka (Python -> C) for Windows and Linux with that firmware inside, +# and the tests. A tag v* also drafts a GitHub release with the files. +# +# SPDX-License-Identifier: GPL-3.0-or-later +# Copyright (C) 2026 EspDrop contributors + +name: build + +on: + push: + branches: [main, test] + tags: ["v*"] + paths: ["host/**", "firmware/**", "tools/**", ".github/workflows/build.yml"] + pull_request: + paths: ["host/**", "firmware/**", "tools/**", ".github/workflows/build.yml"] + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: build-${{ github.ref }} + cancel-in-progress: true + +jobs: + firmware: + name: firmware (ESP32-C5) + runs-on: ubuntu-latest + container: espressif/idf:v6.0.3 + steps: + - uses: actions/checkout@v4 + - name: Build the bridge + shell: bash + run: | + . "$IDF_PATH/export.sh" + cd firmware/spikes + idf.py -B build_c5_bridge -DSDKCONFIG=build_c5_bridge/sdkconfig -DIDF_TARGET=esp32c5 \ + -DSDKCONFIG_DEFAULTS="sdkconfig.defaults;sdkconfig.bridge.defaults" build + - name: One image of the whole flash + shell: bash + run: | + . "$IDF_PATH/export.sh" + python tools/firmware_image.py firmware/spikes/build_c5_bridge -o espdrop-esp32c5.bin + - uses: actions/upload-artifact@v4 + with: + name: firmware + path: espdrop-esp32c5.bin + if-no-files-found: error + + test: + name: tests + runs-on: ubuntu-24.04 + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + - name: Libraries (libarchive; what Qt needs offscreen) + run: | + sudo apt-get update + sudo apt-get install -y libarchive13t64 libegl1 libgl1 libxkbcommon0 libfontconfig1 libdbus-1-3 + - name: The venv with the patched OpenDrop + run: python tools/opendrop_install.py --venv "$RUNNER_TEMP/venv" + - name: Host tests + env: + QT_QPA_PLATFORM: offscreen + run: '"$RUNNER_TEMP/venv/bin/python" -m unittest discover -s host/tests' + - name: Firmware host tests (protocol logic, no ESP) + run: make -C firmware/spikes/components/awdl/test/host run + + app: + name: app (${{ matrix.name }}) + needs: firmware + strategy: + fail-fast: false + matrix: + include: + - name: Windows + os: windows-latest + - name: Linux + os: ubuntu-22.04 # the oldest glibc we build on: runs on newer ones + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + - uses: actions/download-artifact@v4 + with: + name: firmware + path: build/firmware + - name: Libraries (Linux) + if: runner.os == 'Linux' + run: | + sudo apt-get update + sudo apt-get install -y libarchive13 libegl1 libgl1 libxkbcommon0 libfontconfig1 libdbus-1-3 + - name: The venv with the patched OpenDrop, and Nuitka + shell: bash + run: | + python tools/opendrop_install.py --venv "$RUNNER_TEMP/venv" + if [ "$RUNNER_OS" = Windows ]; then PY="$RUNNER_TEMP/venv/Scripts/python.exe"; else PY="$RUNNER_TEMP/venv/bin/python"; fi + "$PY" -m pip install -q nuitka + if [ "$RUNNER_OS" = Linux ]; then "$PY" -m pip install -q patchelf; fi + echo "PY=$PY" >> "$GITHUB_ENV" + - name: Compile (Nuitka) and check the program + shell: bash + run: | + if [ "$RUNNER_OS" = Linux ]; then export PATH="$RUNNER_TEMP/venv/bin:$PATH"; fi + "$PY" tools/build_app.py --firmware build/firmware/espdrop-esp32c5.bin --smoke + - uses: actions/upload-artifact@v4 + with: + name: EspDrop-${{ matrix.name }} + path: | + build/EspDrop-*.zip + build/EspDrop-*.tar.gz + if-no-files-found: error + + release: + name: draft release + if: startsWith(github.ref, 'refs/tags/v') + needs: [firmware, test, app] + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - uses: actions/download-artifact@v4 + with: + path: dist + merge-multiple: true + - uses: softprops/action-gh-release@v2 + with: + draft: true + files: dist/* From 5d58b5d5d25e595a94c0c198d2bf5132e88a1914 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 31 Oct 2026 14:00:56 +0000 Subject: [PATCH 87/99] Build: the program's own path, espdrop.cmd, license list - frozen.program(): in a Nuitka standalone folder sys.executable is the folder plus the build Python's name (python / python.exe), not the program, so autostart and the right-click entries would have started a file that is not there. Now __compiled__.process_exe (the program file), and for a onefile build the binary the user started (containing_dir plus argv[0], .exe added on Windows when typed without). - espdrop check shows that path in the program; the build's smoke test requires it to be the built file, so this cannot come back unnoticed. - Windows: espdrop.cmd next to EspDrop.exe, so cmd and PowerShell wait for the program (it opens no console of its own) and get its exit code. - THIRD_PARTY.txt: a license given as UNKNOWN falls back to the package's classifier (OpenDrop: GPLv3). Nuitka: zeroconf's compiled modules as they are (--no-prefer-source-code). 91 host tests pass. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01XzgaN4Bv1m8Ka9iqo7QVUN --- docs/SPIKES.md | 5 +++-- host/README.md | 3 ++- host/espdrop/env.py | 8 ++++++-- host/espdrop/frozen.py | 20 +++++++++++++++----- host/tests/test_integration.py | 18 ++++++++++++------ tools/build_app.py | 18 ++++++++++++++++-- 6 files changed, 54 insertions(+), 18 deletions(-) diff --git a/docs/SPIKES.md b/docs/SPIKES.md index 9881f70..b95bb25 100644 --- a/docs/SPIKES.md +++ b/docs/SPIKES.md @@ -741,8 +741,9 @@ Test unter Windows: „Mit EspDrop senden“ öffnet das AirDrop-Blatt). 3. Allgemein → „ESP-Firmware“ → „Aktualisieren …“: die Firmware aus dem Programm aufspielen (Abschnitt oben), danach senden und empfangen. -4. In einer Eingabeaufforderung im Ordner: `EspDrop.exe check` und - `EspDrop.exe status` geben ihre Ausgabe in diesem Fenster aus. +4. In einer Eingabeaufforderung im Ordner: `espdrop check` und + `espdrop status` (über `espdrop.cmd`, das auf das Programm wartet) geben ihre + Ausgabe in diesem Fenster aus. Test unter Linux: 1. `tar xzf EspDrop-0.1.0-linux-x86_64.tar.gz`, `EspDrop/EspDrop` starten diff --git a/host/README.md b/host/README.md index b0f3053..debff1e 100644 --- a/host/README.md +++ b/host/README.md @@ -126,7 +126,8 @@ and the ESP firmware into one folder, `build/app/EspDrop/`, packed as `build/EspDrop--windows-x64.zip` or `-linux-x86_64.tar.gz`. The program `EspDrop(.exe)` is the whole app: without arguments the window, with arguments the command line (`EspDrop find`, `EspDrop flash`, ...; on Windows -it uses the console it was started from and opens none of its own). +it uses the console it was started from and opens none of its own, and +`espdrop.cmd` next to it makes cmd and PowerShell wait for it). ```sh ~/opendrop-venv/bin/pip install nuitka patchelf # Linux diff --git a/host/espdrop/env.py b/host/espdrop/env.py index 238c3f9..bdb56b9 100644 --- a/host/espdrop/env.py +++ b/host/espdrop/env.py @@ -343,9 +343,13 @@ def line(ok, what, detail): problems += ok is False out(f" {tag} {what:<11} {detail}") + from .frozen import compiled, program v = sys.version_info - line(v >= (3, 9), "python", f"{platform.python_version()} {platform.architecture()[0]} " - f"({sys.executable})") + if compiled(): # what autostart and the menus start + line(True, "program", f"{program()} (Python {platform.python_version()} inside)") + else: + line(v >= (3, 9), "python", f"{platform.python_version()} {platform.architecture()[0]} " + f"({sys.executable})") try: import serial from .transport import candidates diff --git a/host/espdrop/frozen.py b/host/espdrop/frozen.py index 05ea702..7649723 100644 --- a/host/espdrop/frozen.py +++ b/host/espdrop/frozen.py @@ -2,8 +2,10 @@ # from the checkout with Python. # # Nuitka marks every compiled module with a global `__compiled__` (it does not -# set sys.frozen): containing_dir is the program's folder, original_argv0 the -# onefile binary's argv[0] (None in a standalone folder). +# set sys.frozen). Its process_exe is the program file; in a onefile build +# that is the unpacked copy, and containing_dir with original_argv0 (argv[0] +# as typed) name the binary the user started. sys.executable is no help: in +# a standalone folder it is the folder plus the build Python's name. # # SPDX-License-Identifier: GPL-3.0-or-later # Copyright (C) 2026 EspDrop contributors @@ -22,9 +24,17 @@ def program(): onefile binary itself rather than its unpacked copy. None from Python.""" if "__compiled__" in globals(): info = globals()["__compiled__"] - folder = getattr(info, "containing_dir", None) or os.path.dirname(sys.executable) - name = getattr(info, "original_argv0", None) or sys.executable - return os.path.join(folder, os.path.basename(name)) + folder = getattr(info, "containing_dir", None) + argv0 = getattr(info, "original_argv0", None) + if getattr(info, "onefile", False) and folder and argv0: + name = os.path.basename(argv0) + if os.name == "nt" and not os.path.splitext(name)[1]: + name += ".exe" # started as "EspDrop" from a console + return os.path.join(folder, name) + exe = getattr(info, "process_exe", None) + if exe: + return exe + return os.path.abspath(sys.argv[0]) if getattr(sys, "frozen", False): return sys.executable return None diff --git a/host/tests/test_integration.py b/host/tests/test_integration.py index 27508c3..1893fa9 100644 --- a/host/tests/test_integration.py +++ b/host/tests/test_integration.py @@ -162,21 +162,27 @@ def test_program_paths(self): from espdrop import frozen self.assertIsNone(frozen.program()) # from Python self.assertFalse(frozen.compiled()) + apps = os.path.join(os.sep, "home", "u", "Apps") class Compiled: # Nuitka's __compiled__ + onefile = False containing_dir = os.path.join(os.sep, "opt", "EspDrop") - original_argv0 = None + original_argv0 = "./EspDrop" # as typed, even in a folder build + process_exe = os.path.join(os.sep, "opt", "EspDrop", "EspDrop") with mock.patch.dict(frozen.__dict__, {"__compiled__": Compiled}), \ mock.patch.object(sys, "executable", os.path.join(os.sep, "opt", "EspDrop", - "EspDrop")): + "python")): self.assertTrue(frozen.compiled()) + # The program file itself, not sys.executable (the build Python's name). self.assertEqual(frozen.program(), os.path.join(os.sep, "opt", "EspDrop", "EspDrop")) # onefile: the binary the user started, not its unpacked copy - Compiled.containing_dir = os.path.join(os.sep, "home", "u", "Apps") + Compiled.onefile = True + Compiled.containing_dir = apps Compiled.original_argv0 = "EspDrop-x86_64" - self.assertEqual(frozen.program(), - os.path.join(os.sep, "home", "u", "Apps", "EspDrop-x86_64")) - self.assertEqual(frozen.program_dir(), os.path.join(os.sep, "home", "u", "Apps")) + Compiled.process_exe = os.path.join(os.sep, "tmp", "onefile_1", "EspDrop-x86_64") + want = "EspDrop-x86_64.exe" if os.name == "nt" else "EspDrop-x86_64" + self.assertEqual(frozen.program(), os.path.join(apps, want)) + self.assertEqual(frozen.program_dir(), apps) class WindowsTest(unittest.TestCase): diff --git a/tools/build_app.py b/tools/build_app.py index c8d47eb..0259483 100644 --- a/tools/build_app.py +++ b/tools/build_app.py @@ -168,7 +168,9 @@ def third_party(dist): if name.lower() in ("nuitka", "pip", "patchelf", "ordered-set", "zstandard", "wheel"): continue lic = (d.metadata.get("License-Expression") or d.metadata.get("License") or "").strip() - if not lic or len(lic) > 80: # a whole license text: name the classifier + if lic.upper() == "UNKNOWN": + lic = "" + if not lic or len(lic) > 80: # none, or a whole license text: the classifier lic = next((c.split(" :: ")[-1] for c in d.metadata.get_all("Classifier") or [] if c.startswith("License ::")), lic.splitlines()[0] if lic else "?") rows.append(f"{name} {d.version}: {lic}") @@ -181,6 +183,15 @@ def third_party(dist): "BSD-2-Clause.\n") +CONSOLE_CMD = """@echo off +rem EspDrop on the command line (espdrop status, espdrop flash, ...). +rem EspDrop.exe opens no console of its own and writes into this one; this +rem makes cmd and PowerShell wait for it and pass on its exit code. +start "" /b /wait "%~dp0EspDrop.exe" %* +exit /b %errorlevel% +""" + + def package(dist, build): machine = platform.machine().lower() if IS_WINDOWS: @@ -207,7 +218,7 @@ def smoke(dist): c = subprocess.run([exe, "check"], capture_output=True, text=True, timeout=300) print(c.stdout, c.stderr, flush=True) for need in ("ok opendrop", "ok root ca", "ok libarchive", "ok esptool", - "ok pyside6"): + "ok pyside6", f"ok program {os.path.realpath(exe)} "): if need not in c.stdout: sys.exit(f"smoke: `check` lacks {need!r}") # Hidden, no desktop entries for this test copy, beside a running EspDrop. @@ -258,6 +269,9 @@ def main(argv=None): shutil.copy2(lib, os.path.join(dist, "libarchive.so.13")) print(f"libarchive: {lib} (a fallback; the system's comes first)") shutil.copy2(icons["espdrop.png"], os.path.join(dist, "espdrop.png")) + if IS_WINDOWS: + with open(os.path.join(dist, "espdrop.cmd"), "w", newline="\r\n") as f: + f.write(CONSOLE_CMD) third_party(dist) if args.smoke: step("starting the built program") From ee3307be7780472098408ccdae35a71c40a73111 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 31 Oct 2026 14:01:39 +0000 Subject: [PATCH 88/99] Build smoke test: compare the program path normalized On Windows the path the program reports (Nuitka resolves it) and Python's realpath may differ in case or by a \\?\ prefix; compare both normalized. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01XzgaN4Bv1m8Ka9iqo7QVUN --- tools/build_app.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/tools/build_app.py b/tools/build_app.py index 0259483..e235176 100644 --- a/tools/build_app.py +++ b/tools/build_app.py @@ -218,9 +218,16 @@ def smoke(dist): c = subprocess.run([exe, "check"], capture_output=True, text=True, timeout=300) print(c.stdout, c.stderr, flush=True) for need in ("ok opendrop", "ok root ca", "ok libarchive", "ok esptool", - "ok pyside6", f"ok program {os.path.realpath(exe)} "): + "ok pyside6", "ok program"): if need not in c.stdout: sys.exit(f"smoke: `check` lacks {need!r}") + # The path autostart and the menus will start must be this very file. + said = c.stdout.split("ok program", 1)[1].split("(Python", 1)[0].strip() + + def same(p): + return os.path.normcase(os.path.realpath(p.replace("\\\\?\\", "", 1))) + if same(said) != same(exe): + sys.exit(f"smoke: the program calls itself {said!r}, but it is {exe!r}") # Hidden, no desktop entries for this test copy, beside a running EspDrop. env = dict(os.environ, QT_QPA_PLATFORM="offscreen", ESPDROP_NO_DESKTOP="1", ESPDROP_INSTANCE=f"espdrop-smoke-{os.getpid()}") From 13b6b4a78164b39f515448868824fb47e9203516 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 31 Oct 2026 14:04:41 +0000 Subject: [PATCH 89/99] Build: own entry beside the package, the .dist folder - The first local build ran, but the program stopped at once: "'espdrop' is not a package". Python, and Nuitka when it compiles, look in the main script's folder first, and from tools/ `import espdrop` finds tools/espdrop.py rather than the package. The compiled program now starts from host/espdrop_app.py, next to the package. - Nuitka names the program folder .dist: build/app/EspDrop.dist. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01XzgaN4Bv1m8Ka9iqo7QVUN --- host/README.md | 2 +- host/espdrop_app.py | 13 +++++++++++++ tools/build_app.py | 6 +++--- 3 files changed, 17 insertions(+), 4 deletions(-) create mode 100644 host/espdrop_app.py diff --git a/host/README.md b/host/README.md index debff1e..8766669 100644 --- a/host/README.md +++ b/host/README.md @@ -122,7 +122,7 @@ same way). `tools/build_app.py` makes the app a native program that needs no Python: Nuitka translates the Python code (ours, the patched OpenDrop and every library they use) to C, compiles it, and puts it with the Python runtime, Qt -and the ESP firmware into one folder, `build/app/EspDrop/`, packed as +and the ESP firmware into one folder, `build/app/EspDrop.dist/`, packed as `build/EspDrop--windows-x64.zip` or `-linux-x86_64.tar.gz`. The program `EspDrop(.exe)` is the whole app: without arguments the window, with arguments the command line (`EspDrop find`, `EspDrop flash`, ...; on Windows diff --git a/host/espdrop_app.py b/host/espdrop_app.py new file mode 100644 index 0000000..bd2d4e6 --- /dev/null +++ b/host/espdrop_app.py @@ -0,0 +1,13 @@ +# EspDrop - the entry of the compiled program (tools/build_app.py compiles +# this file). It sits next to the espdrop package on purpose: Python, and +# Nuitka when it compiles, look in the main script's folder first, and from +# tools/ `import espdrop` would find tools/espdrop.py instead of the package. +# +# SPDX-License-Identifier: GPL-3.0-or-later +# Copyright (C) 2026 EspDrop contributors + +import sys + +from espdrop.cli import main + +sys.exit(main()) diff --git a/tools/build_app.py b/tools/build_app.py index e235176..8cb43a9 100644 --- a/tools/build_app.py +++ b/tools/build_app.py @@ -5,7 +5,7 @@ library they use) to C, compiles it, and puts it together with the Python runtime, Qt and the ESP's firmware into one folder: - build/app/EspDrop/EspDrop(.exe) the program + build/app/EspDrop.dist/EspDrop(.exe) the program build/EspDrop--windows-x64.zip what a release ships build/EspDrop--linux-x86_64.tar.gz @@ -154,7 +154,7 @@ def nuitka_command(args, out, icons, fw): cmd.append("--zig") if args.jobs: cmd.append(f"--jobs={args.jobs}") - cmd.append(os.path.join(HERE, "espdrop.py")) + cmd.append(os.path.join(HOST, "espdrop_app.py")) # beside the package: see that file return cmd @@ -261,7 +261,7 @@ def main(argv=None): fw = firmware(args, res) step(f"compiling {NAME} {__version__} with Nuitka (takes a while)") - dist = os.path.join(out, NAME) + dist = os.path.join(out, NAME + ".dist") # Nuitka adds .dist to the folder name if os.path.isdir(dist): shutil.rmtree(dist) env = dict(os.environ, PYTHONPATH=os.pathsep.join( From 754a6f91dc2a6617283d9b38edf76598f8a1a83e Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 31 Oct 2026 14:17:37 +0000 Subject: [PATCH 90/99] Build: bundle fleep's data file; say which file OpenDrop lacks The second local build started and checked itself: program path, Qt, esptool, libarchive fine, but OpenDrop did not load: fleep (its file type lookup) reads fleep/data.json at import, and Nuitka bundles data files only for packages it is told about. A scan of every bundled package for data files missing in the program found only this one that is needed (the others: markdown_it's port.yaml and pkg_resources' api_tests.txt). A missing file of OpenDrop's own was reported as a libarchive problem; now it says so. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01XzgaN4Bv1m8Ka9iqo7QVUN --- host/espdrop/env.py | 3 +++ tools/build_app.py | 4 +++- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/host/espdrop/env.py b/host/espdrop/env.py index bdb56b9..0f53fab 100644 --- a/host/espdrop/env.py +++ b/host/espdrop/env.py @@ -139,6 +139,9 @@ def load_opendrop(): except ImportError as e: raise ImportError(f"cannot import OpenDrop ({e}); {INSTALL_HINT}") from e except (OSError, AttributeError, TypeError) as e: # libarchive-c binding it + missing = getattr(e, "filename", None) + if isinstance(e, FileNotFoundError) and missing and "archive" not in str(missing): + raise ImportError(f"OpenDrop lacks a file of its own ({e})") from e raise ImportError(f"libarchive does not work with OpenDrop ({e}); " f"{LIBARCHIVE_HINT}") from e if os.name == "nt": diff --git a/tools/build_app.py b/tools/build_app.py index 8cb43a9..71e8cd3 100644 --- a/tools/build_app.py +++ b/tools/build_app.py @@ -50,7 +50,9 @@ NAME = "EspDrop" # What the compiled program carries besides its code (and what it is built from). PACKAGES = ["espdrop", "opendrop", "esptool"] -DATA_PACKAGES = ["opendrop", "esptool"] # Apple's root certificate; the flasher stubs +# Their data files: Apple's root certificate, the flasher stubs, the file types +# OpenDrop looks up (fleep/data.json, read at import). +DATA_PACKAGES = ["opendrop", "esptool", "fleep"] def step(text): From 58b14edcef8d78cd449972a6e400914e96aafcfd Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 31 Oct 2026 17:53:58 +0000 Subject: [PATCH 91/99] Build: check OpenDrop without its source; X11 libraries in the program - The CI builds (Windows with MSVC, Linux) compiled and started: program path, Qt, esptool, libarchive (Windows' own archiveint.dll) fine. Then `check` stopped: it tells the patched OpenDrop by reading opendrop/server.py, which the program does not have (the marker is a comment, gone when compiled). In the program it now says "checked when this program was built": the build refuses an unpatched OpenDrop. - Qt's X11 plugin needs xcb libraries (since Qt 6.5 libxcb-cursor0 and a few more) that minimal installs lack; the window then aborts under X11. check names the missing ones with their Debian packages; the build refuses to run without them, so the program carries them (CI installs them); --smoke on Linux also starts the window under Xvfb through that plugin. Wayland does without. 93 host tests pass. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01XzgaN4Bv1m8Ka9iqo7QVUN --- .github/workflows/build.yml | 6 +++- host/README.md | 10 +++++-- host/espdrop/env.py | 56 ++++++++++++++++++++++++++++++++++--- host/tests/test_env.py | 23 +++++++++++++++ tools/build_app.py | 30 ++++++++++++++++++-- 5 files changed, 116 insertions(+), 9 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 4896d91..b0a0d97 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -92,9 +92,13 @@ jobs: path: build/firmware - name: Libraries (Linux) if: runner.os == 'Linux' + # libarchive and Qt's X11 plugin's xcb libraries go into the program + # (users need not install them); xvfb for the start test under X11. run: | sudo apt-get update - sudo apt-get install -y libarchive13 libegl1 libgl1 libxkbcommon0 libfontconfig1 libdbus-1-3 + sudo apt-get install -y libarchive13 libegl1 libgl1 libxkbcommon0 libfontconfig1 libdbus-1-3 \ + libxcb-cursor0 libxcb-icccm4 libxcb-image0 libxcb-keysyms1 libxcb-render-util0 \ + libxcb-xkb1 libxkbcommon-x11-0 libxcb-util1 xvfb - name: The venv with the patched OpenDrop, and Nuitka shell: bash run: | diff --git a/host/README.md b/host/README.md index 8766669..d622291 100644 --- a/host/README.md +++ b/host/README.md @@ -95,7 +95,10 @@ once (it only adds what is missing) or `~/opendrop-venv/bin/pip install PySide6` The OpenDrop venv from `tools/opendrop_install.sh` (needs python3, git, openssl, libarchive). pyserial is optional; without it the app uses the tty directly. No root: only access to `/dev/ttyACM0` (group `dialout`, `uucp` on -Arch). +Arch). The window under X11 needs the xcb libraries Qt's X11 plugin uses +(since Qt 6.5 `libxcb-cursor0` and a few more, missing on minimal installs); +`check` names any missing package (`sudo apt install …`). Wayland does +without; the compiled program carries them. ```sh ~/opendrop-venv/bin/python tools/espdrop.py check @@ -149,7 +152,10 @@ root certificate, libarchive, esptool, Qt) and the window offscreen. The program finds its parts next to itself (the firmware as `espdrop/firmware/espdrop-esp32c5.bin`). On Linux it uses the system's libarchive and carries `libarchive.so.13` from the build machine as a -fallback. At its first start it puts its entries where the desktop looks +fallback, and it carries the xcb libraries Qt's X11 plugin needs (since Qt +6.5 `libxcb-cursor0` and a few more, missing on minimal installs; the build +refuses to run without them and names the packages; Wayland does without). +`--smoke` on Linux also starts the window under Xvfb through that plugin. At its first start it puts its entries where the desktop looks (autostart, the right-click menu, on Linux also the application menu), all pointing at the program itself; a moved folder is followed at the next start. diff --git a/host/espdrop/env.py b/host/espdrop/env.py index 0f53fab..0a1425d 100644 --- a/host/espdrop/env.py +++ b/host/espdrop/env.py @@ -335,6 +335,41 @@ def _archive_selftest(): return True, f"cpio+gzip pack/read/extract ok ({len(blob)} bytes)", warnings +def xcb_missing(): + """Libraries Qt's X11 plugin needs that this system lacks (Linux), [] if + none, None if that cannot be told. Wayland sessions do not need them; a + build carries them (tools/build_app.py).""" + if not sys.platform.startswith("linux"): + return None + import shutil + import subprocess + from .frozen import program_dir + base = program_dir() + if base: + plugin = os.path.join(base, "PySide6", "qt-plugins", "platforms", "libqxcb.so") + else: + try: + import PySide6 + except ImportError: + return None + plugin = os.path.join(os.path.dirname(PySide6.__file__), "Qt", "plugins", "platforms", + "libqxcb.so") + if not os.path.isfile(plugin) or not shutil.which("ldd"): + return None + try: + out = subprocess.run(["ldd", plugin], capture_output=True, text=True, timeout=20).stdout + except (OSError, subprocess.SubprocessError): + return None + return sorted({ln.split()[0] for ln in out.splitlines() if "not found" in ln}) + + +def debian_package(soname): + """'libxcb-cursor.so.0' -> 'libxcb-cursor0' (Debian's naming).""" + name, _, version = soname.partition(".so.") + major = version.split(".")[0] + return name + ("-" if name[-1:].isdigit() else "") + major + + def check(out=print): """What `espdrop check` reports. Returns the number of problems.""" import platform @@ -372,6 +407,13 @@ def line(ok, what, detail): except ImportError: line(None, "pyside6", "not installed: the command line works, the desktop app " "(espdrop gui) needs it") + missing = xcb_missing() + if missing: + line(None, "x11", f"Qt's X11 plugin lacks {', '.join(missing)}: under X11 (Wayland " + f"does without) the window needs: sudo apt install " + f"{' '.join(debian_package(m) for m in missing)}") + elif missing == []: + line(True, "x11", "Qt's X11 plugin has what it needs") try: import esptool line(True, "esptool", f"{esptool.__version__} (espdrop flash, firmware from the app)") @@ -388,11 +430,17 @@ def line(ok, what, detail): load_opendrop() import opendrop import opendrop.server - with open(opendrop.server.__file__, encoding="utf-8") as f: - patched = "trailing CRLF of the final chunk" in f.read() + from .frozen import compiled + if compiled(): + # No source to look into (the marker is a comment); the build + # refuses an OpenDrop without the patches (tools/build_app.py). + patched, where = True, "checked when this program was built" + else: + with open(opendrop.server.__file__, encoding="utf-8") as f: + patched = "trailing CRLF of the final chunk" in f.read() + where = os.path.dirname(opendrop.__file__) line(patched, "opendrop", f"{opendrop.__version__}, " - f"{'patched' if patched else 'NOT patched'} " - f"({os.path.dirname(opendrop.__file__)})") + f"{'patched' if patched else 'NOT patched'} ({where})") except ImportError as e: line(False, "opendrop", str(e)) return problems diff --git a/host/tests/test_env.py b/host/tests/test_env.py index 3b91ad9..1c30bf8 100644 --- a/host/tests/test_env.py +++ b/host/tests/test_env.py @@ -199,6 +199,29 @@ def test_probe(self): self.assertTrue(detail.startswith("libarchive 3."), detail) +class X11LibrariesTest(unittest.TestCase): + LDD = ("\tlibxcb.so.1 => /usr/lib/x86_64-linux-gnu/libxcb.so.1 (0x1)\n" + "\tlibxcb-cursor.so.0 => not found\n" + "\tlibxkbcommon-x11.so.0 => not found\n" + "\tlibxcb-cursor.so.0 => not found\n") + + def test_package_names(self): + self.assertEqual([env.debian_package(s) for s in + ("libxcb-cursor.so.0", "libxkbcommon-x11.so.0", "libxcb-util.so.1")], + ["libxcb-cursor0", "libxkbcommon-x11-0", "libxcb-util1"]) + + def test_missing_from_ldd(self): + import subprocess + done = subprocess.CompletedProcess(["ldd"], 0, self.LDD, "") + with mock.patch.object(sys, "platform", "linux"), \ + mock.patch("os.path.isfile", return_value=True), \ + mock.patch("shutil.which", return_value="/usr/bin/ldd"), \ + mock.patch("subprocess.run", return_value=done): + self.assertEqual(env.xcb_missing(), ["libxcb-cursor.so.0", "libxkbcommon-x11.so.0"]) + with mock.patch.object(sys, "platform", "win32"): + self.assertIsNone(env.xcb_missing()) + + class CheckTest(unittest.TestCase): def test_this_venv_is_ready(self): out = io.StringIO() diff --git a/tools/build_app.py b/tools/build_app.py index 71e8cd3..423dd0c 100644 --- a/tools/build_app.py +++ b/tools/build_app.py @@ -120,6 +120,17 @@ def firmware(args, res): return source +def check_x11_libraries(): + """Linux: Qt's X11 plugin needs some xcb libraries; present here, Nuitka + puts them into the program, so users need not install them.""" + from espdrop import env + missing = env.xcb_missing() + if missing: + sys.exit("error: Qt's X11 plugin lacks " + ", ".join(missing) + " on this machine; " + "install them so the program carries them: sudo apt install " + + " ".join(env.debian_package(m) for m in missing)) + + def system_libarchive(): """The libarchive the build machine's OpenDrop uses, as a file (Linux).""" from espdrop import env @@ -219,8 +230,11 @@ def smoke(dist): sys.exit(f"smoke: --version said {v.stdout!r} {v.stderr!r}") c = subprocess.run([exe, "check"], capture_output=True, text=True, timeout=300) print(c.stdout, c.stderr, flush=True) - for need in ("ok opendrop", "ok root ca", "ok libarchive", "ok esptool", - "ok pyside6", "ok program"): + needs = ["ok opendrop", "ok root ca", "ok libarchive", "ok esptool", "ok pyside6", + "ok program"] + if not IS_WINDOWS: + needs.append("ok x11") # the X11 plugin's libraries came along + for need in needs: if need not in c.stdout: sys.exit(f"smoke: `check` lacks {need!r}") # The path autostart and the menus will start must be this very file. @@ -242,6 +256,16 @@ def same(p): finally: p.kill() p.wait(10) + if not IS_WINDOWS and shutil.which("xvfb-run"): + # The window once more through Qt's X11 plugin, as on most desktops + # (offscreen does not load it): still running after 12 s is fine. + x11 = {k: v for k, v in env.items() if k != "QT_QPA_PLATFORM"} + r = subprocess.run(["xvfb-run", "-a", "-s", "-screen 0 1280x1000x24", "timeout", "12", + exe, "-p", "none-such-port", "gui"], env=x11, + capture_output=True, text=True, timeout=180) + if r.returncode != 124: + sys.exit(f"smoke: under X11 the window ended (rc {r.returncode}): " + f"{r.stdout[-1500:]}{r.stderr[-1500:]}") print("smoke: --version, check and the window are fine", flush=True) @@ -257,6 +281,8 @@ def main(argv=None): step("checking the build environment") check_build_env() + if not IS_WINDOWS: + check_x11_libraries() build = os.path.abspath(args.out) out, res = os.path.join(build, "app"), os.path.join(build, "app-res") icons = make_icons(res) From 21dffee749f3c02ea9309fd20d87587f15a1aa2d Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 31 Oct 2026 17:59:49 +0000 Subject: [PATCH 92/99] CI: libxcb-shape0 for the Linux build The build's own check found it missing on Ubuntu 22.04 (Qt's X11 plugin needs it) and named the package. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01XzgaN4Bv1m8Ka9iqo7QVUN --- .github/workflows/build.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index b0a0d97..971a0c5 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -98,7 +98,7 @@ jobs: sudo apt-get update sudo apt-get install -y libarchive13 libegl1 libgl1 libxkbcommon0 libfontconfig1 libdbus-1-3 \ libxcb-cursor0 libxcb-icccm4 libxcb-image0 libxcb-keysyms1 libxcb-render-util0 \ - libxcb-xkb1 libxkbcommon-x11-0 libxcb-util1 xvfb + libxcb-shape0 libxcb-xkb1 libxkbcommon-x11-0 libxcb-util1 xvfb - name: The venv with the patched OpenDrop, and Nuitka shell: bash run: | From 410d82ef8a11329ddf78b8ffbca30d2223a2ef19 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 1 Nov 2026 02:57:28 +0000 Subject: [PATCH 93/99] This PC's name by default; drop iOS's ._ metadata files - The name others see is now this PC's name (Windows' computer name; on Linux the "pretty" hostname if set, else the hostname, without a domain), not "EspDrop". In the window the field stays empty and shows that name greyed until the user types one; espdrop -n has it as default. - Receiving a photo from an iPhone left test.JPG and ._test.JPG: iOS sends AppleDouble metadata (extended attributes) beside each file, which Apple's own unpacking folds into the file. They are removed in the staging folder now, also inside sent folders, recognised by the AppleDouble magic so a real file named "._x" stays. - build_app.py prints the newest glibc the Linux program asks for, measured from every file of it. 96 host tests pass. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01XzgaN4Bv1m8Ka9iqo7QVUN --- host/espdrop/cli.py | 9 ++++++--- host/espdrop/env.py | 21 +++++++++++++++++++++ host/espdrop/gui.py | 16 +++++++++++----- host/espdrop/receive.py | 36 +++++++++++++++++++++++++++++++++++- host/tests/test_env.py | 20 ++++++++++++++++++++ host/tests/test_session.py | 18 ++++++++++++++++++ tools/build_app.py | 22 ++++++++++++++++++++++ 7 files changed, 133 insertions(+), 9 deletions(-) diff --git a/host/espdrop/cli.py b/host/espdrop/cli.py index 6204a47..36385ec 100644 --- a/host/espdrop/cli.py +++ b/host/espdrop/cli.py @@ -400,6 +400,7 @@ def main(argv=None, transport=None): for stream in (sys.stdout, sys.stderr): if hasattr(stream, "reconfigure"): stream.reconfigure(errors="replace") + from .env import device_name from .version import __version__ ap = argparse.ArgumentParser(prog="espdrop", description="EspDrop host app (M4)") ap.add_argument("--version", action="version", version=f"espdrop {__version__}") @@ -410,18 +411,20 @@ def main(argv=None, transport=None): f.add_argument("-t", "--time", type=float, default=30.0, help="seconds to browse") f.add_argument("--no-wake", action="store_true", help="do not send the BLE wake") f.add_argument("-v", "--verbose", action="store_true", help="show every AirDrop mDNS message") - f.set_defaults(name="EspDrop") + f.set_defaults(name=device_name()) s = sub.add_parser("send", help="send files to an AirDrop receiver") s.add_argument("file", nargs="+") s.add_argument("-r", "--receiver", help="receiver name or id (default: the only one found, " "else you pick from a list)") - s.add_argument("-n", "--name", default="EspDrop", help="sender name shown on the device") + s.add_argument("-n", "--name", default=device_name(), + help="sender name shown on the device (default: this PC's name)") s.add_argument("-t", "--time", type=float, default=30.0, help="seconds to look for the receiver") s.add_argument("--no-wake", action="store_true", help="do not send the BLE wake") s.add_argument("-v", "--verbose", action="store_true") rv = sub.add_parser("receive", help="receive files from an Apple device") - rv.add_argument("-n", "--name", default="EspDrop", help="name shown on the sender") + rv.add_argument("-n", "--name", default=device_name(), + help="name shown on the sender (default: this PC's name)") rv.add_argument("-v", "--verbose", action="store_true", help="debug log with times") rv.add_argument("-y", "--yes", action="store_true", help="accept every transfer without asking") rv.add_argument("-d", "--dir", help="folder for received files (default: the current one)") diff --git a/host/espdrop/env.py b/host/espdrop/env.py index 0a1425d..8acddf4 100644 --- a/host/espdrop/env.py +++ b/host/espdrop/env.py @@ -76,6 +76,27 @@ def recorded_libarchive(): BUNDLED_LIBARCHIVE = ("libarchive.so.13",) # next to the compiled program (Linux) +def device_name(): + """The name others see by default: this PC's name, as the system shows it + (Linux: the "pretty" hostname if set), without a domain; "EspDrop" if + there is none.""" + name = "" + if sys.platform.startswith("linux"): + try: + with open("/etc/machine-info", encoding="utf-8") as f: + for line in f: + if line.startswith("PRETTY_HOSTNAME="): + name = line.split("=", 1)[1].strip().strip('"\'') + except OSError: + pass + if not name: + import socket + name = (os.environ.get("COMPUTERNAME") if os.name == "nt" else None) \ + or socket.gethostname() + name = name.split(".")[0] # "pc.fritz.box" -> "pc" + return name.strip()[:63] or "EspDrop" + + def libarchive_candidates(): """Where to look, best first: a path, or None for the normal library search (PATH on Windows, ldconfig on Linux; what libarchive-c does by itself).""" diff --git a/host/espdrop/gui.py b/host/espdrop/gui.py index 0c16336..00c4ac5 100644 --- a/host/espdrop/gui.py +++ b/host/espdrop/gui.py @@ -40,7 +40,7 @@ QLineEdit, QMainWindow, QMenu, QMessageBox, QPlainTextEdit, QScrollArea, QSystemTrayIcon, QToolButton, QVBoxLayout, QWidget) -from . import flash, integration, ui +from . import env, flash, integration, ui from .session import Session from .version import __version__ @@ -795,7 +795,7 @@ def __init__(self, port=None, settings=None, session_factory=None, ask_timeout=6 def _default_session(self): d = self.settings.value("download_dir", "") or default_download_dir() - return Session(port=self.port, name=self.name_edit.text() or APP, receive=False, + return Session(port=self.port, name=self._name(), receive=False, download_dir=d, on_event=self.bridge.on_event, on_ask=self.bridge.on_ask, ask_timeout=self.bridge.ask_timeout) @@ -862,7 +862,9 @@ def _build(self): self.receive_left = ui.label("", "caption") vv.addWidget(self.receive_left) card.add(vis, margins=(16, 10, 14, 9)) - self.name_edit = QLineEdit(self.settings.value("name", "") or APP) + # Empty (never changed): this PC's name; the field shows it greyed. + self.name_edit = QLineEdit(self.settings.value("name", "") or "") + self.name_edit.setPlaceholderText(env.device_name()) self.name_edit.setFixedWidth(210) self.name_edit.editingFinished.connect(self._name_changed) card.add_row("Name", self.name_edit, "So sehen dich iPhone, iPad und Mac.") @@ -1388,11 +1390,15 @@ def _update_dir(self): or default_download_dir() self.dir_label.setText(d) + def _name(self): + """The name others see: what the user typed, else this PC's name.""" + return self.name_edit.text().strip() or env.device_name() + def _name_changed(self): - name = self.name_edit.text().strip() or APP + name = self._name() + self.settings.setValue("name", self.name_edit.text().strip()) if name == self.session.name: return - self.settings.setValue("name", name) run_bg(self.session.set_name, name, on_error=self._log) # ---- sending --------------------------------------------------------------- diff --git a/host/espdrop/receive.py b/host/espdrop/receive.py index c1f87d1..f820022 100644 --- a/host/espdrop/receive.py +++ b/host/espdrop/receive.py @@ -109,6 +109,40 @@ def unique_path(folder, name): return path +APPLEDOUBLE_MAGIC = b"\x00\x05\x16\x07" + + +def is_appledouble(path): + """A '._name' file of Apple's AppleDouble format: the metadata (extended + attributes) iOS sends along with each file. Apple's own unpacking folds it + into the file; elsewhere it would land beside it as a second file.""" + if not os.path.basename(path).startswith("._") or not os.path.isfile(path): + return False + try: + with open(path, "rb") as f: + return f.read(4) == APPLEDOUBLE_MAGIC + except OSError: + return False + + +def drop_appledouble(folder, names): + """Remove the AppleDouble companions among folder/names (in subfolders + too); returns the names left.""" + left = [] + for name in names: + path = os.path.join(folder, name) + if is_appledouble(path): + os.remove(path) + continue + if os.path.isdir(path): + for sub, _dirs, files in os.walk(path): + for f in files: + if is_appledouble(os.path.join(sub, f)): + os.remove(os.path.join(sub, f)) + left.append(name) + return left + + def move_out(staging, names, dest): """Move staging/name for each name into dest under a free name; returns the new paths. Nothing in dest is ever replaced. What cannot move (a file @@ -222,7 +256,7 @@ def on_bytes(n): handler.rfile = rfile new = sorted(set(os.listdir(here)) - before) raw_names = [n for n in new if n.startswith(RAW_PREFIX)] - names = [n for n in new if not n.startswith(RAW_PREFIX)] + names = drop_appledouble(here, [n for n in new if not n.startswith(RAW_PREFIX)]) dest = self.dest_dir if dest and os.path.realpath(dest) != os.path.realpath(here): files = move_out(here, names, dest) diff --git a/host/tests/test_env.py b/host/tests/test_env.py index 1c30bf8..8cdcc9d 100644 --- a/host/tests/test_env.py +++ b/host/tests/test_env.py @@ -199,6 +199,26 @@ def test_probe(self): self.assertTrue(detail.startswith("libarchive 3."), detail) +class DeviceNameTest(unittest.TestCase): + def test_pc_name(self): + with mock.patch("socket.gethostname", return_value="werkstatt-pc.fritz.box"), \ + mock.patch.dict(os.environ, {"COMPUTERNAME": "WERKSTATT-PC"}), \ + mock.patch("builtins.open", side_effect=OSError): + want = "WERKSTATT-PC" if os.name == "nt" else "werkstatt-pc" + self.assertEqual(env.device_name(), want) + with mock.patch("socket.gethostname", return_value=""), \ + mock.patch.dict(os.environ, {"COMPUTERNAME": ""}), \ + mock.patch("builtins.open", side_effect=OSError): + self.assertEqual(env.device_name(), "EspDrop") + + def test_pretty_hostname_on_linux(self): + if not sys.platform.startswith("linux"): + self.skipTest("Linux only") + info = io.StringIO('PRETTY_HOSTNAME="Bens Laptop"\nCHASSIS=laptop\n') + with mock.patch("builtins.open", return_value=info): + self.assertEqual(env.device_name(), "Bens Laptop") + + class X11LibrariesTest(unittest.TestCase): LDD = ("\tlibxcb.so.1 => /usr/lib/x86_64-linux-gnu/libxcb.so.1 (0x1)\n" "\tlibxcb-cursor.so.0 => not found\n" diff --git a/host/tests/test_session.py b/host/tests/test_session.py index 87c7f4a..89ed5b3 100644 --- a/host/tests/test_session.py +++ b/host/tests/test_session.py @@ -278,6 +278,24 @@ def move(src, dst): self.assertEqual(got, [os.path.join(dest, "a.jpg"), os.path.join(staging, "locked.jpg")]) self.assertEqual(os.listdir(staging), ["locked.jpg"]) + def test_appledouble_companions_are_dropped(self): + # iOS sends "._name" AppleDouble metadata with each file; only that + # goes, not a real file whose name happens to start with "._". + from espdrop import receive + d = os.path.join(self.tmp.name, "x") + os.makedirs(os.path.join(d, "Ordner")) + meta = receive.APPLEDOUBLE_MAGIC + b"\x00\x02\x00\x00" + bytes(40) + for name, data in (("test.JPG", b"\xff\xd8 photo"), ("._test.JPG", meta), + ("._notiz.txt", b"echte Datei"), + (os.path.join("Ordner", "a.txt"), b"a"), + (os.path.join("Ordner", "._a.txt"), meta)): + with open(os.path.join(d, name), "wb") as f: + f.write(data) + left = receive.drop_appledouble(d, sorted(os.listdir(d))) + self.assertEqual(left, ["._notiz.txt", "Ordner", "test.JPG"]) + self.assertEqual(sorted(os.listdir(d)), left) + self.assertEqual(os.listdir(os.path.join(d, "Ordner")), ["a.txt"]) + def test_leftovers_move_out_at_start(self): dl = os.path.join(self.tmp.name, "downloads") os.makedirs(os.path.join(dl, STAGING)) diff --git a/tools/build_app.py b/tools/build_app.py index 423dd0c..4533e24 100644 --- a/tools/build_app.py +++ b/tools/build_app.py @@ -171,6 +171,25 @@ def nuitka_command(args, out, icons, fw): return cmd +def glibc_needed(dist): + """The newest glibc version any file of the program asks for (Linux), as + "2.35", measured from the symbol versions; None if objdump is missing.""" + import re + if not shutil.which("objdump"): + return None + best = (0,) + for folder, _dirs, files in os.walk(dist): + for name in files: + path = os.path.join(folder, name) + with open(path, "rb") as f: + if f.read(4) != b"\x7fELF": + continue + out = subprocess.run(["objdump", "-T", path], capture_output=True, text=True).stdout + for v in re.findall(r"GLIBC_(\d+(?:\.\d+)+)", out): + best = max(best, tuple(int(x) for x in v.split("."))) + return ".".join(map(str, best)) if best != (0,) else None + + def third_party(dist): """LICENSE and a list of what the program carries, with each license.""" from importlib import metadata @@ -300,6 +319,9 @@ def main(argv=None): step("adding what the program needs beside its code") if not IS_WINDOWS: + need = glibc_needed(dist) + print(f"runs on Linux with glibc {need} or newer (x86_64)" if need + else "glibc needed: unknown (no objdump)", flush=True) lib = system_libarchive() shutil.copy2(lib, os.path.join(dist, "libarchive.so.13")) print(f"libarchive: {lib} (a fallback; the system's comes first)") From 350cf4ef7bb16bc4ef1da5e4210a3ad26bd5f414 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 1 Nov 2026 03:18:27 +0000 Subject: [PATCH 94/99] Docs: which Linux the program runs on Measured in the CI build: glibc 2.35 or newer, x86_64. Named the distributions that have it and those that do not, X11 and Wayland, what the tray icon needs, the serial port group; Windows 10/11, 64 bit. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01XzgaN4Bv1m8Ka9iqo7QVUN --- docs/SPIKES.md | 11 +++++++++++ host/README.md | 25 ++++++++++++++++++++++--- 2 files changed, 33 insertions(+), 3 deletions(-) diff --git a/docs/SPIKES.md b/docs/SPIKES.md index b95bb25..6237374 100644 --- a/docs/SPIKES.md +++ b/docs/SPIKES.md @@ -745,6 +745,17 @@ Test unter Windows: `espdrop status` (über `espdrop.cmd`, das auf das Programm wartet) geben ihre Ausgabe in diesem Fenster aus. +Für welche Linux-Versionen: 64-Bit-PCs (x86_64, kein ARM/Raspberry Pi) mit +glibc 2.35 oder neuer (beim Build gemessen; `ldd --version` zeigt sie). Das +sind Ubuntu 22.04 LTS und neuer samt Kubuntu, Xubuntu, Linux Mint 21+, +Pop!_OS 22.04+, Zorin OS 17+, elementary OS 7+; Debian 12 und neuer; Fedora 36 +und neuer; Arch, Manjaro, EndeavourOS, openSUSE Tumbleweed. Nicht: Ubuntu +20.04, Linux Mint 20, Debian 11, RHEL/Alma/Rocky 9, openSUSE Leap 15. X11 und +Wayland gehen beide; das Symbol im Infobereich braucht einen Tray (KDE, Xfce, +Cinnamon, Ubuntus GNOME; reines GNOME wie bei Fedora nur mit der Erweiterung +„AppIndicator“), sonst bleibt das Fenster offen. Alles Weitere (Python, Qt, +X11-Bibliotheken) bringt das Programm mit. + Test unter Linux: 1. `tar xzf EspDrop-0.1.0-linux-x86_64.tar.gz`, `EspDrop/EspDrop` starten (Zugriff auf `/dev/ttyACM0` über die Gruppe `dialout` wie bisher). diff --git a/host/README.md b/host/README.md index d622291..d234810 100644 --- a/host/README.md +++ b/host/README.md @@ -162,13 +162,32 @@ pointing at the program itself; a moved folder is followed at the next start. **Builds on GitHub** (`.github/workflows/build.yml`), on every push that touches the code: the firmware with ESP-IDF 6.0.3 (container `espressif/idf:v6.0.3`) merged into one image; the app with Nuitka on Windows -and on Ubuntu 22.04 (its glibc is the oldest the Linux build needs), the -firmware inside, each started with `--smoke`; the host and firmware tests. The +and on Ubuntu 22.04 (built there, the program needs glibc 2.35, see below), +the firmware inside, each started with `--smoke`; the host and firmware tests. The files are on the run's page (Artifacts). A tag `v*` drafts a release with them. The Windows program is not signed: SmartScreen asks once ("Weitere -Informationen" → "Trotzdem ausführen"). +Informationen" → "Trotzdem ausführen"). It runs on Windows 10 and 11, 64 bit. + +**Which Linux the program runs on.** 64-bit PCs (x86_64; not ARM, so not a +Raspberry Pi) with glibc 2.35 or newer; the build measures this from every +file of the program and prints it ("runs on Linux with glibc 2.35 or +newer"). `ldd --version` shows a system's glibc. That is: + +| runs | does not run (glibc too old) | +|---|---| +| Ubuntu 22.04 LTS and newer, also Kubuntu, Xubuntu, Linux Mint 21+, Pop!_OS 22.04+, Zorin OS 17+, elementary OS 7+ | Ubuntu 20.04, Linux Mint 20 (2.31) | +| Debian 12 "Bookworm" and newer (2.36) | Debian 11 (2.31) | +| Fedora 36 and newer | RHEL, AlmaLinux, Rocky Linux 9 (2.34) | +| Arch, Manjaro, EndeavourOS, openSUSE Tumbleweed | openSUSE Leap 15 (2.31) | + +Everything else the program needs it carries (Python, Qt with its X11 and +Wayland plugins, libarchive as a fallback). The desktop may be X11 or +Wayland, GNOME, KDE, Xfce, Cinnamon or another. The icon in the panel needs +a tray (KDE, Xfce, Cinnamon, Ubuntu's GNOME; plain GNOME as on Fedora only +with the "AppIndicator" extension); without one the window simply stays +open. The ESP's port needs the group `dialout` (`uucp` on Arch). ## Windows 10/11 From 3f7c3c89968d9471f595a4926d5944dd9841475b Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 1 Nov 2026 09:09:31 +0000 Subject: [PATCH 95/99] One file per platform; release title and text - build_app.py makes a single file by default (Nuitka onefile): EspDrop--windows-x64.exe and EspDrop--linux-x86_64, plus THIRD_PARTY-.txt. It unpacks itself once per build into the user's cache ({CACHE_DIR}/EspDrop/app/v-) and starts from there, so autostart does not unpack 200 MB each time; the app removes older builds' folders there (and only there). Licenses, the icon and Linux' libarchive go into the file. --folder keeps the folder build. - frozen.bundle_dir(): the program's own files are in the unpacked folder, not beside the single file (libarchive fallback, X11 check). - CI: zstandard for the packing; uploads the single files; a tag v* drafts "EspDrop v" with docs/RELEASE.md as its text (checkout first, it would clean the downloaded files away). - docs/RELEASE.md: downloads, Windows and Linux start, which Linux, the ESP, use. 98 host tests pass. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01XzgaN4Bv1m8Ka9iqo7QVUN --- .github/workflows/build.yml | 11 ++-- docs/RELEASE.md | 58 +++++++++++++++++ docs/SPIKES.md | 17 ++--- host/README.md | 26 +++++--- host/espdrop/cli.py | 4 +- host/espdrop/env.py | 8 +-- host/espdrop/frozen.py | 30 +++++++++ host/tests/test_integration.py | 27 ++++++++ tools/build_app.py | 110 +++++++++++++++++++++++---------- 9 files changed, 233 insertions(+), 58 deletions(-) create mode 100644 docs/RELEASE.md diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 971a0c5..a0e7ae6 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -104,7 +104,7 @@ jobs: run: | python tools/opendrop_install.py --venv "$RUNNER_TEMP/venv" if [ "$RUNNER_OS" = Windows ]; then PY="$RUNNER_TEMP/venv/Scripts/python.exe"; else PY="$RUNNER_TEMP/venv/bin/python"; fi - "$PY" -m pip install -q nuitka + "$PY" -m pip install -q nuitka zstandard # zstandard: the single file's packing if [ "$RUNNER_OS" = Linux ]; then "$PY" -m pip install -q patchelf; fi echo "PY=$PY" >> "$GITHUB_ENV" - name: Compile (Nuitka) and check the program @@ -116,8 +116,8 @@ jobs: with: name: EspDrop-${{ matrix.name }} path: | - build/EspDrop-*.zip - build/EspDrop-*.tar.gz + build/EspDrop-* + build/THIRD_PARTY-*.txt if-no-files-found: error release: @@ -128,11 +128,14 @@ jobs: permissions: contents: write steps: + - uses: actions/checkout@v4 # the release text (first: it cleans the folder) - uses: actions/download-artifact@v4 with: path: dist merge-multiple: true - uses: softprops/action-gh-release@v2 with: - draft: true + draft: true # published by hand, after a look + name: EspDrop ${{ github.ref_name }} + body_path: docs/RELEASE.md files: dist/* diff --git a/docs/RELEASE.md b/docs/RELEASE.md new file mode 100644 index 0000000..8d958de --- /dev/null +++ b/docs/RELEASE.md @@ -0,0 +1,58 @@ +AirDrop zwischen iPhone, iPad, Mac und einem Windows- oder Linux-PC, mit einem +ESP32-C5 als AirDrop-Funkmodul am USB-Anschluss. + +## Herunterladen + +| Datei | Für | +|---|---| +| `EspDrop-…-windows-x64.exe` | Windows 10 und 11, 64 Bit | +| `EspDrop-…-linux-x86_64` | Linux, 64-Bit-PC (siehe unten) | +| `espdrop-esp32c5.bin` | die Firmware für den ESP32-C5 einzeln; sie steckt auch im Programm | +| `THIRD_PARTY-….txt` | was im Programm steckt, mit den Lizenzen | + +Das Programm ist eine einzige Datei und braucht weder Python noch eine +Installation. Beim ersten Start entpackt es sich einmal (einige Sekunden), +danach startet es sofort. + +## Windows + +1. `EspDrop-…-windows-x64.exe` an einen festen Ort legen, z. B. + `%LOCALAPPDATA%\Programs\EspDrop`, und doppelklicken. Autostart und das + Rechtsklickmenü zeigen auf diesen Ort; nicht im Download-Ordner lassen. +2. Windows fragt einmal, weil das Programm nicht signiert ist: „Weitere + Informationen“ → „Trotzdem ausführen“. + +## Linux + +Läuft auf 64-Bit-PCs (x86_64, kein ARM/Raspberry Pi) mit glibc 2.35 oder +neuer (`ldd --version` zeigt sie): Ubuntu 22.04 LTS und neuer samt Kubuntu, +Xubuntu, Linux Mint 21+, Pop!_OS 22.04+, Zorin OS 17+, elementary OS 7+; +Debian 12 und neuer; Fedora 36 und neuer; Arch, Manjaro, EndeavourOS, openSUSE +Tumbleweed. Nicht: Ubuntu 20.04, Linux Mint 20, Debian 11, RHEL/Alma/Rocky 9, +openSUSE Leap 15. X11 und Wayland gehen beide. + +```sh +chmod +x EspDrop-*-linux-x86_64 # Downloads verlieren das Ausführrecht +./EspDrop-*-linux-x86_64 +``` + +Für den ESP braucht der Benutzer die Gruppe `dialout` (Arch: `uucp`): +`sudo usermod -aG dialout $USER`, danach ab- und anmelden. Das Symbol im +Infobereich braucht einen Tray (KDE, Xfce, Cinnamon, Ubuntus GNOME; reines GNOME +nur mit der Erweiterung „AppIndicator“), sonst bleibt das Fenster offen. + +## Der ESP32-C5 + +Den ESP32-C5 per USB anstecken und die App starten. Hat er noch nicht die +passende Firmware, bietet die App unter Allgemein → „ESP-Firmware“ das +Installieren an (über dasselbe USB-Kabel, ohne Tasten am ESP). + +## Benutzen + +- **Empfangen:** „Empfangen von: 10 Minuten“ oder „Für alle“; am iPhone teilen + und den PC wählen. Dateien landen in Downloads. +- **Senden:** „Suchen“, Datei auf das Gerät ziehen, oder Rechtsklick auf eine + Datei → „Mit EspDrop senden“. Am iPhone: AirDrop „Für alle“, entsperrt. + +Quellcode und ausführliche Doku: dieses Repository (`host/README.md`, +`docs/SPIKES.md`). EspDrop steht unter der GPL-3.0. diff --git a/docs/SPIKES.md b/docs/SPIKES.md index 6237374..dd251b7 100644 --- a/docs/SPIKES.md +++ b/docs/SPIKES.md @@ -732,8 +732,9 @@ Unter Linux bringt die App jetzt auch einen Eintrag im Anwendungsmenü mit ihm zugeordnet, so zeigen Dock und Taskleiste auch unter Wayland das Symbol. Test unter Windows: -1. `EspDrop-0.1.0-windows-x64.zip` entpacken, z. B. nach - `%LOCALAPPDATA%\Programs\EspDrop`, `EspDrop.exe` starten (SmartScreen: +1. `EspDrop-0.1.0-windows-x64.exe` (eine einzige Datei) an einen festen Ort + legen, z. B. `%LOCALAPPDATA%\Programs\EspDrop`, und starten; der erste Start + entpackt sie einmal in den Cache (einige Sekunden). (SmartScreen: „Weitere Informationen“ → „Trotzdem ausführen“, das Programm ist nicht signiert). Vorher die laufende App aus dem Checkout beenden. 2. Das Fenster erscheint mit Symbol in der Taskleiste; Autostart und @@ -741,9 +742,9 @@ Test unter Windows: „Mit EspDrop senden“ öffnet das AirDrop-Blatt). 3. Allgemein → „ESP-Firmware“ → „Aktualisieren …“: die Firmware aus dem Programm aufspielen (Abschnitt oben), danach senden und empfangen. -4. In einer Eingabeaufforderung im Ordner: `espdrop check` und - `espdrop status` (über `espdrop.cmd`, das auf das Programm wartet) geben ihre - Ausgabe in diesem Fenster aus. +4. In einer Eingabeaufforderung im Ordner: + `start "" /b /wait EspDrop-0.1.0-windows-x64.exe check` gibt die Ausgabe in + diesem Fenster aus (ohne `start /wait` kommt die Eingabe schon vorher zurück). Für welche Linux-Versionen: 64-Bit-PCs (x86_64, kein ARM/Raspberry Pi) mit glibc 2.35 oder neuer (beim Build gemessen; `ldd --version` zeigt sie). Das @@ -757,8 +758,10 @@ Cinnamon, Ubuntus GNOME; reines GNOME wie bei Fedora nur mit der Erweiterung X11-Bibliotheken) bringt das Programm mit. Test unter Linux: -1. `tar xzf EspDrop-0.1.0-linux-x86_64.tar.gz`, `EspDrop/EspDrop` starten - (Zugriff auf `/dev/ttyACM0` über die Gruppe `dialout` wie bisher). +1. `chmod +x EspDrop-0.1.0-linux-x86_64` (Downloads verlieren das + Ausführrecht), dann starten; der erste Start entpackt sich einmal nach + `~/.cache/EspDrop/app`. Zugriff auf `/dev/ttyACM0` über die Gruppe `dialout` + wie bisher. 2. Im Anwendungsmenü steht danach „EspDrop“ mit Symbol; Autostart und „Öffnen mit → AirDrop (EspDrop)“ zeigen auf das Programm. 3. Firmware aktualisieren, senden, empfangen wie unter Windows. diff --git a/host/README.md b/host/README.md index d234810..dd4bd67 100644 --- a/host/README.md +++ b/host/README.md @@ -125,12 +125,17 @@ same way). `tools/build_app.py` makes the app a native program that needs no Python: Nuitka translates the Python code (ours, the patched OpenDrop and every library they use) to C, compiles it, and puts it with the Python runtime, Qt -and the ESP firmware into one folder, `build/app/EspDrop.dist/`, packed as -`build/EspDrop--windows-x64.zip` or `-linux-x86_64.tar.gz`. The -program `EspDrop(.exe)` is the whole app: without arguments the window, with -arguments the command line (`EspDrop find`, `EspDrop flash`, ...; on Windows -it uses the console it was started from and opens none of its own, and -`espdrop.cmd` next to it makes cmd and PowerShell wait for it). +and the ESP firmware into one file: `build/EspDrop--windows-x64.exe` +or `build/EspDrop--linux-x86_64`. It unpacks itself once per build +into the user's cache (`%LOCALAPPDATA%\EspDrop\app`, `~/.cache/EspDrop/app`) +and starts from there, so only the first start of a build takes a few +seconds; the app removes older builds' copies there. `--folder` builds the +folder instead (`build/app/EspDrop.dist/`, packed as .zip or .tar.gz, with +`espdrop.cmd` beside the program on Windows), which starts without unpacking. +The program is the whole app: without arguments the window, with arguments +the command line (`EspDrop find`, `EspDrop flash`, ...; on Windows it uses the +console it was started from and opens none of its own: cmd and PowerShell do +not wait for it, `start "" /b /wait EspDrop.exe status` does). ```sh ~/opendrop-venv/bin/pip install nuitka patchelf # Linux @@ -149,7 +154,7 @@ without either it takes what `espdrop flash` would, else it builds without firmware. `--smoke` starts the result: `--version`, `check` (OpenDrop, its root certificate, libarchive, esptool, Qt) and the window offscreen. -The program finds its parts next to itself (the firmware as +The program finds its parts in its own (unpacked) folder (the firmware as `espdrop/firmware/espdrop-esp32c5.bin`). On Linux it uses the system's libarchive and carries `libarchive.so.13` from the build machine as a fallback, and it carries the xcb libraries Qt's X11 plugin needs (since Qt @@ -157,15 +162,16 @@ fallback, and it carries the xcb libraries Qt's X11 plugin needs (since Qt refuses to run without them and names the packages; Wayland does without). `--smoke` on Linux also starts the window under Xvfb through that plugin. At its first start it puts its entries where the desktop looks (autostart, the right-click menu, on Linux also the application menu), all -pointing at the program itself; a moved folder is followed at the next start. +pointing at the program file itself; a moved file is followed at the next start. **Builds on GitHub** (`.github/workflows/build.yml`), on every push that touches the code: the firmware with ESP-IDF 6.0.3 (container `espressif/idf:v6.0.3`) merged into one image; the app with Nuitka on Windows and on Ubuntu 22.04 (built there, the program needs glibc 2.35, see below), the firmware inside, each started with `--smoke`; the host and firmware tests. The -files are on the run's page (Artifacts). A tag `v*` drafts a release with -them. +files are on the run's page (Artifacts). A tag `v*` (e.g. `v0.1.0`) also +drafts a release with them, titled "EspDrop v0.1.0", its text from +`docs/RELEASE.md`; it stays a draft until published by hand on GitHub. The Windows program is not signed: SmartScreen asks once ("Weitere Informationen" → "Trotzdem ausführen"). It runs on Windows 10 and 11, 64 bit. diff --git a/host/espdrop/cli.py b/host/espdrop/cli.py index 36385ec..99b68c9 100644 --- a/host/espdrop/cli.py +++ b/host/espdrop/cli.py @@ -449,9 +449,11 @@ def main(argv=None, transport=None): help="ask whom to send these files to (Explorer's \"Senden an\")") if argv is None: argv = sys.argv[1:] - from .frozen import compiled + from .frozen import clean_old_unpacked, compiled if not argv and compiled(): argv = ["gui"] # double-clicked: the desktop app + if compiled(): + clean_old_unpacked() # older builds' unpacked copies args = ap.parse_args(argv) # Our steps (connect, accept, upload rate) at INFO; OpenDrop's own only from # WARNING on, unless -v asks for everything, with milliseconds since start. diff --git a/host/espdrop/env.py b/host/espdrop/env.py index 8acddf4..abbdb91 100644 --- a/host/espdrop/env.py +++ b/host/espdrop/env.py @@ -110,8 +110,8 @@ def libarchive_candidates(): if os.name == "nt": out.append(os.path.join(os.environ.get("SystemRoot", r"C:\Windows"), "System32", "archiveint.dll")) - from .frozen import program_dir - bundled = program_dir() + from .frozen import bundle_dir + bundled = bundle_dir() if bundled: # the Linux build carries a copy out += [os.path.join(bundled, n) for n in BUNDLED_LIBARCHIVE if os.path.isfile(os.path.join(bundled, n))] @@ -364,8 +364,8 @@ def xcb_missing(): return None import shutil import subprocess - from .frozen import program_dir - base = program_dir() + from .frozen import bundle_dir + base = bundle_dir() if base: plugin = os.path.join(base, "PySide6", "qt-plugins", "platforms", "libqxcb.so") else: diff --git a/host/espdrop/frozen.py b/host/espdrop/frozen.py index 7649723..91221c4 100644 --- a/host/espdrop/frozen.py +++ b/host/espdrop/frozen.py @@ -44,3 +44,33 @@ def program_dir(): """The compiled program's folder (bundled libraries sit there), else None.""" p = program() return os.path.dirname(p) if p else None + + +def bundle_dir(): + """Where the compiled program's own files are (Qt, the bundled + libraries): its folder, or for a onefile build the folder it unpacked + itself into. None from Python.""" + if not compiled(): + return None + return os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + + +UNPACK_PARENT = ("EspDrop", "app") # tools/build_app.py: {CACHE_DIR}/EspDrop/app/ + + +def clean_old_unpacked(): + """A onefile build unpacks once per build into the cache; remove what + older builds left there (one that still runs keeps its files: deleting + them fails, they go next time).""" + import shutil + here = bundle_dir() + if not here: + return + parent = os.path.dirname(here) + if (os.path.basename(parent), os.path.basename(os.path.dirname(parent))) != \ + tuple(reversed(UNPACK_PARENT)): + return # not our cache folder: leave it alone + for name in os.listdir(parent): + path = os.path.join(parent, name) + if path != here and os.path.isdir(path) and name.startswith("v"): + shutil.rmtree(path, ignore_errors=True) diff --git a/host/tests/test_integration.py b/host/tests/test_integration.py index 1893fa9..72105e7 100644 --- a/host/tests/test_integration.py +++ b/host/tests/test_integration.py @@ -185,6 +185,33 @@ class Compiled: # Nuitka's __compiled__ self.assertEqual(frozen.program_dir(), apps) +class UnpackedCopiesTest(unittest.TestCase): + def setUp(self): + self.tmp = tempfile.TemporaryDirectory() + self.addCleanup(self.tmp.cleanup) + + def run_in(self, here): + from espdrop import frozen + with mock.patch.object(frozen, "bundle_dir", lambda: here): + frozen.clean_old_unpacked() + + def test_older_builds_go(self): + app = os.path.join(self.tmp.name, "cache", "EspDrop", "app") + for name in ("v0.1.0-20261031", "v0.1.0-20261101", "notes"): + os.makedirs(os.path.join(app, name)) + self.run_in(os.path.join(app, "v0.1.0-20261101")) + self.assertEqual(sorted(os.listdir(app)), ["notes", "v0.1.0-20261101"]) + + def test_anything_else_is_left_alone(self): + # A folder build, or a cache path we did not choose: nothing is removed. + other = os.path.join(self.tmp.name, "Programs", "EspDrop") + os.makedirs(os.path.join(self.tmp.name, "Programs", "vOld")) + os.makedirs(other) + self.run_in(other) + self.assertEqual(sorted(os.listdir(os.path.join(self.tmp.name, "Programs"))), + ["EspDrop", "vOld"]) + + class WindowsTest(unittest.TestCase): def setUp(self): self.tmp = tempfile.TemporaryDirectory() diff --git a/tools/build_app.py b/tools/build_app.py index 4533e24..ca5a83f 100644 --- a/tools/build_app.py +++ b/tools/build_app.py @@ -3,11 +3,17 @@ Nuitka translates the Python code (ours, the patched OpenDrop and every library they use) to C, compiles it, and puts it together with the Python -runtime, Qt and the ESP's firmware into one folder: +runtime, Qt and the ESP's firmware into one file: - build/app/EspDrop.dist/EspDrop(.exe) the program - build/EspDrop--windows-x64.zip what a release ships - build/EspDrop--linux-x86_64.tar.gz + build/EspDrop--windows-x64.exe what a release ships + build/EspDrop--linux-x86_64 + build/THIRD_PARTY-.txt what is inside, with licenses + +The single file unpacks itself once per build into the user's cache folder +(Windows %LOCALAPPDATA%\\EspDrop\\app, Linux ~/.cache/EspDrop/app) and starts +from there; later starts reuse it, and the app removes older builds' copies. +--folder makes the folder build instead (build/app/EspDrop.dist/, packed as +.zip or .tar.gz), which starts without unpacking. The program is the whole app: started without arguments it is the window, with arguments the command line (EspDrop find, EspDrop flash, ...; on Windows @@ -145,9 +151,11 @@ def system_libarchive(): sys.exit("error: libarchive.so.13 not found on this machine (apt install libarchive13)") -def nuitka_command(args, out, icons, fw): +def nuitka_command(args, out, icons, fw, extra): + """extra: {file: path in the program} besides the code (licenses, ...).""" version4 = ".".join((__version__.split(".") + ["0", "0", "0"])[:4]) - cmd = [sys.executable, "-m", "nuitka", "--mode=standalone", + mode = "standalone" if args.folder else "onefile" + cmd = [sys.executable, "-m", "nuitka", f"--mode={mode}", f"--output-dir={out}", f"--output-folder-name={NAME}", f"--output-filename={NAME}", "--enable-plugin=pyside6", "--include-qt-plugins=platforminputcontexts", "--noinclude-qt-translations", "--nofollow-import-to=tkinter", @@ -161,6 +169,13 @@ def nuitka_command(args, out, icons, fw): cmd += [f"--include-package-data={p}" for p in DATA_PACKAGES] if fw: cmd.append(f"--include-data-files={fw}=espdrop/firmware/{flash.FIRMWARE_NAME}") + cmd += [f"--include-data-files={src}={dst}" for src, dst in extra.items()] + if not args.folder: + # Unpacked once per build into the cache, reused at every later start + # (frozen.clean_old_unpacked removes older builds' folders there). + build_id = time.strftime("%Y%m%d%H%M%S", time.gmtime()) + cmd.append("--onefile-tempdir-spec={CACHE_DIR}/EspDrop/app/" + f"v{__version__}-{build_id}") if IS_WINDOWS: cmd += ["--windows-console-mode=attach", f"--windows-icon-from-ico={icons['espdrop.ico']}"] if args.zig: @@ -191,8 +206,10 @@ def glibc_needed(dist): def third_party(dist): - """LICENSE and a list of what the program carries, with each license.""" + """LICENSE and a list of what the program carries, with each license + (into folder `dist`).""" from importlib import metadata + os.makedirs(dist, exist_ok=True) shutil.copy2(os.path.join(ROOT, "LICENSE"), os.path.join(dist, "LICENSE.txt")) rows = [] for d in metadata.distributions(): @@ -210,8 +227,9 @@ def third_party(dist): f.write(f"{NAME} {__version__} (GPL-3.0-or-later, LICENSE.txt; source code: " "https://github.com/antiwireless/espdrop) is built with Python and these " "packages, each under its own license:\n\n" + "\n".join(sorted(rows, key=str.lower)) - + "\n\nQt (in PySide6) is used under the LGPL-3.0; its libraries are the " - "separate files in this folder.\nlibarchive (Linux, libarchive.so.13): " + + "\n\nQt (in PySide6) is used under the LGPL-3.0; its libraries stay " + "separate files (in the program folder, or in the folder the single-file " + "program unpacks itself into).\nlibarchive (Linux, libarchive.so.13): " "BSD-2-Clause.\n") @@ -224,6 +242,18 @@ def third_party(dist): """ +def package_onefile(exe, build): + """The single file under its release name.""" + machine = platform.machine().lower() + if IS_WINDOWS: + arch = {"amd64": "x64", "x86_64": "x64", "arm64": "arm64"}.get(machine, machine) + path = os.path.join(build, f"{NAME}-{__version__}-windows-{arch}.exe") + else: + path = os.path.join(build, f"{NAME}-{__version__}-linux-{machine}") + shutil.copy2(exe, path) + return path + + def package(dist, build): machine = platform.machine().lower() if IS_WINDOWS: @@ -241,9 +271,8 @@ def package(dist, build): return path -def smoke(dist): +def smoke(exe): """The built program starts: --version, check, and the window (offscreen).""" - exe = os.path.join(dist, NAME + (".exe" if IS_WINDOWS else "")) v = subprocess.run([exe, "--version"], capture_output=True, text=True, timeout=120) if v.stdout.strip() != f"espdrop {__version__}": sys.exit(f"smoke: --version said {v.stdout!r} {v.stderr!r}") @@ -273,8 +302,12 @@ def same(p): if p.poll() is not None: sys.exit(f"smoke: the window ended (rc {p.returncode}): {p.stdout.read()}") finally: - p.kill() - p.wait(10) + p.terminate() # a single file passes it on to the app + try: + p.wait(15) + except subprocess.TimeoutExpired: + p.kill() + p.wait(10) if not IS_WINDOWS and shutil.which("xvfb-run"): # The window once more through Qt's X11 plugin, as on most desktops # (offscreen does not load it): still running after 12 s is fine. @@ -295,6 +328,8 @@ def main(argv=None): ap.add_argument("--zig", action="store_true", help="Windows: compile with Zig, not MSVC") ap.add_argument("--jobs", type=int, help="parallel C compilations (default: CPUs)") ap.add_argument("--smoke", action="store_true", help="start the built program to check it") + ap.add_argument("--folder", action="store_true", + help="a program folder (zip/tar.gz) instead of one file") ap.add_argument("--out", default=os.path.join(ROOT, "build"), help="build folder") args = ap.parse_args(argv) @@ -307,36 +342,47 @@ def main(argv=None): icons = make_icons(res) fw = firmware(args, res) + step("what the program carries besides its code") + third_party(res) + extra = {os.path.join(res, n): n for n in ("LICENSE.txt", "THIRD_PARTY.txt")} + extra[icons["espdrop.png"]] = "espdrop.png" + if not IS_WINDOWS: + lib = system_libarchive() + extra[lib] = "libarchive.so.13" + print(f"libarchive: {lib} (a fallback; the system's comes first)") + elif args.folder: + cmd_file = os.path.join(res, "espdrop.cmd") + with open(cmd_file, "w", newline="\r\n") as f: + f.write(CONSOLE_CMD) + extra[cmd_file] = "espdrop.cmd" + step(f"compiling {NAME} {__version__} with Nuitka (takes a while)") dist = os.path.join(out, NAME + ".dist") # Nuitka adds .dist to the folder name - if os.path.isdir(dist): - shutil.rmtree(dist) + exe_name = NAME + (".exe" if IS_WINDOWS else "") + exe = os.path.join(dist, exe_name) if args.folder else os.path.join(out, exe_name) + for old in (dist, exe): + if os.path.isdir(old): + shutil.rmtree(old) + elif os.path.isfile(old): + os.remove(old) env = dict(os.environ, PYTHONPATH=os.pathsep.join( p for p in (HOST, os.environ.get("PYTHONPATH")) if p)) - r = subprocess.run(nuitka_command(args, out, icons, fw), env=env) - if r.returncode or not os.path.isdir(dist): + r = subprocess.run(nuitka_command(args, out, icons, fw, extra), env=env) + if r.returncode or not os.path.isfile(exe): sys.exit(f"error: Nuitka failed (rc {r.returncode})") - - step("adding what the program needs beside its code") - if not IS_WINDOWS: + if not IS_WINDOWS and os.path.isdir(dist): need = glibc_needed(dist) print(f"runs on Linux with glibc {need} or newer (x86_64)" if need else "glibc needed: unknown (no objdump)", flush=True) - lib = system_libarchive() - shutil.copy2(lib, os.path.join(dist, "libarchive.so.13")) - print(f"libarchive: {lib} (a fallback; the system's comes first)") - shutil.copy2(icons["espdrop.png"], os.path.join(dist, "espdrop.png")) - if IS_WINDOWS: - with open(os.path.join(dist, "espdrop.cmd"), "w", newline="\r\n") as f: - f.write(CONSOLE_CMD) - third_party(dist) if args.smoke: step("starting the built program") - smoke(dist) + smoke(exe) step("packing") - path = package(dist, build) - print(f"{path} ({os.path.getsize(path) / 1e6:.0f} MB)\nthe program: " - f"{os.path.join(dist, NAME + ('.exe' if IS_WINDOWS else ''))}") + path = package(dist, build) if args.folder else package_onefile(exe, build) + platform_name = "windows" if IS_WINDOWS else "linux" + shutil.copy2(os.path.join(res, "THIRD_PARTY.txt"), + os.path.join(build, f"THIRD_PARTY-{platform_name}.txt")) + print(f"{path} ({os.path.getsize(path) / 1e6:.0f} MB)", flush=True) return 0 From 32d96c702d43d8655715d12c53455cad7a942be1 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 1 Nov 2026 09:41:28 +0000 Subject: [PATCH 96/99] Never remove the running build's own unpacked folder The Windows CI start of the single file failed at the second start: Windows handed the program its own path in the 8.3 short form (...\V01~1.0-2), the cleanup compared paths as text, took the running build's folder for an old one and deleted it, and the next extension module could not load. Folders are now compared as the same folder (os.path.samefile); one that cannot be compared is left alone. A test gives our folder under another spelling. 99 host tests pass. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01XzgaN4Bv1m8Ka9iqo7QVUN --- host/espdrop/frozen.py | 11 ++++++++++- host/tests/test_integration.py | 11 +++++++++++ 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/host/espdrop/frozen.py b/host/espdrop/frozen.py index 91221c4..d5bdb17 100644 --- a/host/espdrop/frozen.py +++ b/host/espdrop/frozen.py @@ -72,5 +72,14 @@ def clean_old_unpacked(): return # not our cache folder: leave it alone for name in os.listdir(parent): path = os.path.join(parent, name) - if path != here and os.path.isdir(path) and name.startswith("v"): + if not name.startswith("v") or not os.path.isdir(path): + continue + try: + ours = os.path.samefile(path, here) + except OSError: + continue # cannot tell: leave it + # Compared as the same folder, not as text: Windows may hand us the + # short 8.3 form of our own path (V01~1.0-2), which once deleted the + # running program's files. + if not ours: shutil.rmtree(path, ignore_errors=True) diff --git a/host/tests/test_integration.py b/host/tests/test_integration.py index 72105e7..0ecf54e 100644 --- a/host/tests/test_integration.py +++ b/host/tests/test_integration.py @@ -202,6 +202,17 @@ def test_older_builds_go(self): self.run_in(os.path.join(app, "v0.1.0-20261101")) self.assertEqual(sorted(os.listdir(app)), ["notes", "v0.1.0-20261101"]) + def test_own_folder_under_another_spelling_stays(self): + # Windows may give our path in its 8.3 short form; a symlink stands + # in for that: another spelling of the very folder we run from. + app = os.path.join(self.tmp.name, "cache", "EspDrop", "app") + ours = os.path.join(app, "v0.1.0-20261101093700") + os.makedirs(ours) + short = os.path.join(app, "V01~1.0-2") + os.symlink(ours, short) # what bundle_dir() might say + self.run_in(short) + self.assertTrue(os.path.isdir(ours)) + def test_anything_else_is_left_alone(self): # A folder build, or a cache path we did not choose: nothing is removed. other = os.path.join(self.tmp.name, "Programs", "EspDrop") From 004d459365049ad2b182909dc0110a6bb2b42daa Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 1 Nov 2026 10:40:05 +0000 Subject: [PATCH 97/99] The app in English too, with a language setting - espdrop/i18n.py: the texts stay German in the code; _() gives them in the language chosen under Allgemein -> Sprache (Automatisch, Deutsch, English). Automatisch is German on a German system, English on any other. Texts with values use format placeholders, so English can order them its own way. A new language shows after a restart; "Jetzt neu starten" restarts the app at once. - Every text the window, the dialogs, the firmware messages and the desktop entries (right-click menu text, menu comments) show goes through _(); numbers and build dates follow the language. The right-click entry is rewritten when the language changed. - test_i18n.py: every _() text has an English version with the same placeholders, and no German text (or text that has a translation) is shown without _(); a GUI test runs the window in English. 103 host tests pass. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01XzgaN4Bv1m8Ka9iqo7QVUN --- host/espdrop/flash.py | 63 +++--- host/espdrop/gui.py | 382 +++++++++++++++++++++--------------- host/espdrop/i18n.py | 295 ++++++++++++++++++++++++++++ host/espdrop/integration.py | 25 +-- host/espdrop/ui.py | 20 +- host/tests/test_gui.py | 24 ++- host/tests/test_i18n.py | 93 +++++++++ 7 files changed, 694 insertions(+), 208 deletions(-) create mode 100644 host/espdrop/i18n.py create mode 100644 host/tests/test_i18n.py diff --git a/host/espdrop/flash.py b/host/espdrop/flash.py index fd9f030..6e0d46a 100644 --- a/host/espdrop/flash.py +++ b/host/espdrop/flash.py @@ -24,6 +24,8 @@ import re import struct +from .i18n import _ + IMAGE_MAGIC = 0xE9 APP_DESC_MAGIC = 0xABCD5432 APP_OFFSET = 0x10000 @@ -55,7 +57,7 @@ def chip(self): @property def size(self): - return sum(len(data) for _, data in self.parts) + return sum(len(data) for _offset, data in self.parts) def __repr__(self): return f"" @@ -94,21 +96,24 @@ def load(path): with open(path, "rb") as f: data = f.read() except OSError as e: - raise FirmwareError(f"{path} lässt sich nicht lesen: {e.strerror or e}") from e + raise FirmwareError(_("{path} lässt sich nicht lesen: {error}").format( + path=path, error=e.strerror or e)) from e chip, desc = image_chip(data, APP_OFFSET), app_desc(data, APP_OFFSET) if chip is None or desc is None: if image_chip(data) is not None and app_desc(data) is not None: - raise FirmwareError("Das ist nur der App-Teil einer Firmware. Gebraucht wird das " - f"vollständige Image ({FIRMWARE_NAME}) oder der Build-Ordner.") - raise FirmwareError("Keine ESP-Firmware: es fehlt die App an 0x10000.") + raise FirmwareError(_("Das ist nur der App-Teil einer Firmware. Gebraucht wird das " + "vollständige Image ({name}) oder der Build-Ordner.").format( + name=FIRMWARE_NAME)) + raise FirmwareError(_("Keine ESP-Firmware: es fehlt die App an 0x10000.")) boot = BOOT_OFFSET.get(chip) if boot is None: - raise FirmwareError(f"Firmware für einen anderen Chip (Kennung {chip}).") + raise FirmwareError(_("Firmware für einen anderen Chip (Kennung {chip}).").format( + chip=chip)) if image_chip(data, boot) != chip: - raise FirmwareError(f"Kein vollständiges Image: der Bootloader für den {CHIPS[chip]} " - f"fehlt an {boot:#x}.") + raise FirmwareError(_("Kein vollständiges Image: der Bootloader für den {chip} fehlt " + "an {offset}.").format(chip=CHIPS[chip], offset=f"{boot:#x}")) if data[PART_TABLE_OFFSET:PART_TABLE_OFFSET + 2] != b"\xaa\x50": - raise FirmwareError("Kein vollständiges Image: die Partitionstabelle fehlt an 0x8000.") + raise FirmwareError(_("Kein vollständiges Image: die Partitionstabelle fehlt an 0x8000.")) return Firmware([(0, data)], chip, desc, path) @@ -119,26 +124,31 @@ def _load_build(folder): files = {int(off, 16): name for off, name in args["flash_files"].items()} app_offset = int(args["app"]["offset"], 16) except (OSError, ValueError, KeyError, TypeError) as e: - raise FirmwareError(f"{folder} ist kein ESP-IDF-Build-Ordner ({e}).") from e + raise FirmwareError(_("{folder} ist kein ESP-IDF-Build-Ordner ({error}).").format( + folder=folder, error=e)) from e if _bridge_mode(folder) is False: - raise FirmwareError("Dieser Build ist die Test-Konsole, nicht die Firmware für die App: " - "mit sdkconfig.bridge.defaults bauen (firmware/README.md).") + raise FirmwareError(_("Dieser Build ist die Test-Konsole, nicht die Firmware für die " + "App: mit sdkconfig.bridge.defaults bauen (firmware/README.md).")) parts = [] for offset in sorted(files): try: with open(os.path.join(folder, files[offset]), "rb") as f: parts.append((offset, f.read())) except OSError as e: - raise FirmwareError(f"{files[offset]} fehlt im Build ({e.strerror or e}).") from e + raise FirmwareError(_("{name} fehlt im Build ({error}).").format( + name=files[offset], error=e.strerror or e)) from e app = dict(parts).get(app_offset, b"") chip, desc = image_chip(app), app_desc(app) if chip is None or desc is None: - raise FirmwareError(f"Die App im Build ({files.get(app_offset)}) ist kein ESP-Image.") + raise FirmwareError(_("Die App im Build ({name}) ist kein ESP-Image.").format( + name=files.get(app_offset))) if chip not in CHIPS: - raise FirmwareError(f"Firmware für einen anderen Chip (Kennung {chip}).") + raise FirmwareError(_("Firmware für einen anderen Chip (Kennung {chip}).").format( + chip=chip)) for offset, data in parts: if data[:1] == bytes([IMAGE_MAGIC]) and image_chip(data) != chip: - raise FirmwareError(f"{files[offset]} ist für einen anderen Chip als die App.") + raise FirmwareError(_("{name} ist für einen anderen Chip als die App.").format( + name=files[offset])) return Firmware(parts, chip, desc, folder) @@ -230,8 +240,8 @@ def flash(fw, port, on_progress=None, on_log=None): from esptool.cmds import attach_flash, detect_chip, reset_chip, run_stub, write_flash from esptool.logger import TemplateLogger, log except ImportError as e: - raise FirmwareError("esptool fehlt: tools/opendrop_install.py installiert es " - "(oder pip install esptool).") from e + raise FirmwareError(_("esptool fehlt: tools/opendrop_install.py installiert es " + "(oder pip install esptool).")) from e report = on_progress or (lambda stage, fraction=None: None) parts = sorted(fw.parts) # written in this order @@ -283,8 +293,9 @@ def progress_bar(self, cur_iter, total_iters, prefix="", suffix="", bar_length=3 try: with detect_chip(port) as esp: if esp.IMAGE_CHIP_ID != fw.chip_id: - raise FirmwareError(f"Am Anschluss {port} steckt ein {esp.CHIP_NAME}; " - f"diese Firmware ist für den {fw.chip}.") + raise FirmwareError(_("Am Anschluss {port} steckt ein {found}; diese Firmware " + "ist für den {chip}.").format( + port=port, found=esp.CHIP_NAME, chip=fw.chip)) esp = run_stub(esp) attach_flash(esp) report("write", 0.0) @@ -318,11 +329,11 @@ def explain(error, port): low = text.lower() if any(k in low for k in ("could not open port", "permission denied", "access is denied", "zugriff verweigert", "resource busy", "errno 16")): - hint = (" Unter Linux muss der Benutzer in der Gruppe 'dialout' sein." + hint = (_(" Unter Linux muss der Benutzer in der Gruppe 'dialout' sein.") if os.name == "posix" else "") - return (f"Der Anschluss {port} ist belegt oder gesperrt: hat ihn ein anderes Programm " - f"offen (awdl_bridge.py, ein serieller Monitor)?{hint}") + return _("Der Anschluss {port} ist belegt oder gesperrt: hat ihn ein anderes Programm " + "offen (awdl_bridge.py, ein serieller Monitor)?").format(port=port) + hint if any(k in low for k in ("failed to connect", "no serial data", "wrong boot mode")): - return ("Der ESP ließ sich nicht in den Bootloader schalten. Abstecken, die BOOT-Taste " - "gedrückt halten, wieder anstecken, loslassen und erneut versuchen.") - return f"Flashen fehlgeschlagen: {text}" + return _("Der ESP ließ sich nicht in den Bootloader schalten. Abstecken, die BOOT-Taste " + "gedrückt halten, wieder anstecken, loslassen und erneut versuchen.") + return _("Flashen fehlgeschlagen: {error}").format(error=text) diff --git a/host/espdrop/gui.py b/host/espdrop/gui.py index 00c4ac5..3ef7b1e 100644 --- a/host/espdrop/gui.py +++ b/host/espdrop/gui.py @@ -40,21 +40,23 @@ QLineEdit, QMainWindow, QMenu, QMessageBox, QPlainTextEdit, QScrollArea, QSystemTrayIcon, QToolButton, QVBoxLayout, QWidget) -from . import env, flash, integration, ui +from . import env, flash, i18n, integration, ui +from .i18n import N_, _ from .session import Session from .version import __version__ APP = "EspDrop" # Who may send to us, as AirDrop on the iPhone puts it: label, seconds (0: off, # None: until switched off). -VISIBILITY = [("Aus", 0), ("10 Minuten", 600), ("Für alle", None)] +VISIBILITY = [(N_("Aus"), 0), (N_("10 Minuten"), 600), (N_("Für alle"), None)] -THEMES = [("Automatisch", "auto"), ("Hell", "light"), ("Dunkel", "dark")] -THEME_KEYS = [key for _, key in THEMES] +THEMES = [(N_("Automatisch"), "auto"), (N_("Hell"), "light"), (N_("Dunkel"), "dark")] +THEME_KEYS = [key for _label, key in THEMES] -SEND_STATES = {"new": "Warten …", "asking": "Warten auf Annahme …", "uploading": "Senden …", - "done": "Gesendet", "declined": "Abgelehnt", "failed": "Fehlgeschlagen", - "cancelled": "Abgebrochen"} +SEND_STATES = {"new": N_("Warten …"), "asking": N_("Warten auf Annahme …"), + "uploading": N_("Senden …"), "done": N_("Gesendet"), + "declined": N_("Abgelehnt"), "failed": N_("Fehlgeschlagen"), + "cancelled": N_("Abgebrochen")} TILE_PHASE = {"new": "waiting", "asking": "waiting", "uploading": "sending", "done": "sent", "declined": "declined", "failed": "failed", "cancelled": "cancelled"} PHOTO_EXT = {".jpg", ".jpeg", ".png", ".heic", ".heif", ".gif", ".webp", ".tif", ".tiff", @@ -79,7 +81,7 @@ def human(n): for unit in ("B", "KB", "MB", "GB"): if n < 1000 or unit == "GB": text = f"{n:.0f} {unit}" if unit == "B" else f"{n:.1f} {unit}" - return text.replace(".", ",") + return text.replace(".", ",") if i18n.LANG == "de" else text n /= 1000 @@ -99,14 +101,15 @@ def _kind(path): def what_text(files, urls=()): """'ein Foto', '3 Dateien', 'einen Link' - what a transfer carries.""" if urls and not files: - return "einen Link" if len(urls) == 1 else f"{len(urls)} Links" + return _("einen Link") if len(urls) == 1 else _("{n} Links").format(n=len(urls)) n = len(files) if not n: - return "etwas" + return _("etwas") kinds = {_kind(f) for f in files} - one, many = {"photo": ("ein Foto", "Fotos"), "video": ("ein Video", "Videos")}.get( - kinds.pop() if len(kinds) == 1 else "file", ("eine Datei", "Dateien")) - return one if n == 1 else f"{n} {many}" + one, many = {"photo": (N_("ein Foto"), N_("{n} Fotos")), + "video": (N_("ein Video"), N_("{n} Videos"))}.get( + kinds.pop() if len(kinds) == 1 else "file", (N_("eine Datei"), N_("{n} Dateien"))) + return _(one) if n == 1 else _(many).format(n=n) # ---- one copy per user ------------------------------------------------------- @@ -170,7 +173,7 @@ def _read(self): continue buf.extend(bytes(sock.readAll())) while b"\n" in buf: - line, _, rest = bytes(buf).partition(b"\n") + line, _sep, rest = bytes(buf).partition(b"\n") buf[:] = rest try: msg = json.loads(line.decode("utf-8")) @@ -249,11 +252,14 @@ def __init__(self, parent, ask, reply, timeout): pic.setPixmap(self._preview(ask)) v.addWidget(pic) v.addSpacing(4) - v.addWidget(ui.label(f"„{ask.sender}“ möchte {what_text(ask.files, ask.urls)} teilen.", - "headline", wrap=True, center=True)) + v.addWidget(ui.label(_("„{name}“ möchte {what} teilen.").format( + name=ask.sender, what=what_text(ask.files, ask.urls)), + "headline", wrap=True, center=True)) what = ask.files or ask.urls if what: - names = ", ".join(what[:3]) + (f" und {len(what) - 3} weitere" if len(what) > 3 else "") + names = ", ".join(what[:3]) + if len(what) > 3: + names = _("{names} und {n} weitere").format(names=names, n=len(what) - 3) v.addWidget(ui.label(names, "secondary", wrap=True, center=True)) v.addSpacing(6) self.countdown = ui.ThinProgress() @@ -264,8 +270,8 @@ def __init__(self, parent, ask, reply, timeout): v.addSpacing(2) row = QHBoxLayout() row.setSpacing(10) - self.no = ui.button("Ablehnen") - self.yes = ui.button("Annehmen", "primary") + self.no = ui.button(_("Ablehnen")) + self.yes = ui.button(_("Annehmen"), "primary") for b in (self.no, self.yes): b.setAutoDefault(False) b.setDefault(False) @@ -320,7 +326,7 @@ def _tick(self): self.reject() return self.countdown.set_value(left / self.timeout) - self.left.setText(f"Ohne Antwort abgelehnt in {left:.0f} s") + self.left.setText(_("Ohne Antwort abgelehnt in {secs} s").format(secs=f"{left:.0f}")) def _answer(self, result): self.timer.stop() @@ -367,7 +373,7 @@ def __init__(self, window, files): titles.addWidget(self.subtitle) titles.addStretch(1) head.addLayout(titles, 1) - self.more_btn = ui.button("Hinzufügen …", "link") + self.more_btn = ui.button(_("Hinzufügen …"), "link") self.more_btn.clicked.connect(self._choose_files) head.addWidget(self.more_btn, 0, Qt.AlignmentFlag.AlignVCenter) lay.addLayout(head) @@ -389,7 +395,7 @@ def __init__(self, window, files): lay.addWidget(card, 1) bottom = QHBoxLayout() bottom.addStretch(1) - self.close_btn = ui.button("Abbrechen") + self.close_btn = ui.button(_("Abbrechen")) self.close_btn.clicked.connect(self.reject) bottom.addWidget(self.close_btn) lay.addLayout(bottom) @@ -422,12 +428,12 @@ def add_files(self, files): self.title.setText(text[0].upper() + text[1:]) self.subtitle.setText(human(size)) else: - self.title.setText("Keine Datei gewählt") - self.subtitle.setText("„Hinzufügen …“ wählt Dateien (Ordner gehen noch nicht).") + self.title.setText(_("Keine Datei gewählt")) + self.subtitle.setText(_("„Hinzufügen …“ wählt Dateien (Ordner gehen noch nicht).")) return True def _choose_files(self): - files, _ = QFileDialog.getOpenFileNames(self, "Per AirDrop senden") + files, _filter = QFileDialog.getOpenFileNames(self, _("Per AirDrop senden")) self.add_files(files) # ---- devices -------------------------------------------------------------- @@ -440,14 +446,14 @@ def _update_view(self): def on_device(self, connected): if not connected: - self.status.setText("Verbinde mit dem ESP …") + self.status.setText(_("Verbinde mit dem ESP …")) self._browsing = False self._update_view() return if not self._browsing and self.transfer_id is None: self._browsing = True - self.status.setText("Suche nach Geräten in der Nähe …\nAm iPhone oder iPad: " - "AirDrop „Für alle“, entsperrt.") + self.status.setText(_("Suche nach Geräten in der Nähe …\nAm iPhone oder iPad: " + "AirDrop „Für alle“, entsperrt.")) run_bg(self.w.session.browse, 60, on_error=self.w._log) self._update_view() @@ -458,7 +464,7 @@ def on_receiver(self, r): if self.transfer_id is not None: tile.setEnabled(r.id == self._target) elif self._browsing: - self.status.setText("Zum Senden auf ein Gerät klicken.") + self.status.setText(_("Zum Senden auf ein Gerät klicken.")) self._update_view() def _dropped(self, rid, files): @@ -475,7 +481,7 @@ def _pick(self, rid): try: t = self.w.session.send(rid, self.files) except Exception as e: # noqa: BLE001 - self.status.setText(f"Senden geht nicht: {e}") + self.status.setText(_("Senden geht nicht: {error}").format(error=e)) return self.transfer_id, self._target = t.id, rid self.grid.set_tiles_enabled(False, except_rid=rid) @@ -483,7 +489,7 @@ def _pick(self, rid): if tile: tile.set_phase("waiting") self.more_btn.setEnabled(False) - self.status.setText(f"Warten, bis {t.receiver.label} annimmt …") + self.status.setText(_("Warten, bis {name} annimmt …").format(name=t.receiver.label)) # ---- the transfer --------------------------------------------------------- @@ -496,16 +502,17 @@ def on_send(self, info): if tile: tile.set_phase(TILE_PHASE.get(state, "idle"), info.get("progress") or 0.0) if state == "uploading": - self.status.setText(f"Senden an {t.receiver.label} …") + self.status.setText(_("Senden an {name} …").format(name=t.receiver.label)) elif state == "done": - self.status.setText(f"Gesendet an {t.receiver.label}.") + self.status.setText(_("Gesendet an {name}").format(name=t.receiver.label) + ".") self.transfer_id = None - self.close_btn.setText("Fertig") + self.close_btn.setText(_("Fertig")) QTimer.singleShot(1500, self.accept) elif state in ("declined", "failed", "cancelled"): - text = {"declined": f"{t.receiver.label} hat abgelehnt.", - "cancelled": "Abgebrochen."}.get(state, f"Fehlgeschlagen: {info.get('error')}") - self.status.setText(text + " Zum erneuten Versuch auf ein Gerät klicken.") + text = {"declined": _("{name} hat abgelehnt.").format(name=t.receiver.label), + "cancelled": _("Abgebrochen.")}.get( + state, _("Fehlgeschlagen: {error}").format(error=info.get("error"))) + self.status.setText(text + _(" Zum erneuten Versuch auf ein Gerät klicken.")) self.transfer_id = None self.grid.set_tiles_enabled(True) self.more_btn.setEnabled(True) @@ -527,14 +534,18 @@ def built_text(built): m = re.match(r"([A-Z][a-z]{2})\s+(\d{1,2}) (\d{4})(?: (\d{2}):(\d{2}))?", built or "") if not m or m.group(1) not in MONTHS: return built - text = f"{int(m.group(2))}.{MONTHS.index(m.group(1)) + 1}.{m.group(3)}" - return text + (f", {m.group(4)}:{m.group(5)}" if m.group(4) else "") - - -FLASH_STAGES = {"connect": "Verbinde mit dem ESP …", - "write": "Schreibe die Firmware …", - "verify": "Prüfe, ob alles angekommen ist …", - "restart": "Starte den ESP neu …"} + day, month, year = int(m.group(2)), MONTHS.index(m.group(1)) + 1, m.group(3) + if i18n.LANG == "de": + text = f"{day}.{month}.{year}" + return text + (f", {m.group(4)}:{m.group(5)}" if m.group(4) else "") + text = f"{year}-{month:02d}-{day:02d}" + return text + (f" {m.group(4)}:{m.group(5)}" if m.group(4) else "") + + +FLASH_STAGES = {"connect": N_("Verbinde mit dem ESP …"), + "write": N_("Schreibe die Firmware …"), + "verify": N_("Prüfe, ob alles angekommen ist …"), + "restart": N_("Starte den ESP neu …")} TRANSFER_DONE = ("done", "declined", "failed", "cancelled") @@ -558,27 +569,27 @@ def __init__(self, window, flasher=None, loader=None): self.loader = loader or flash.load self.fw = None self.busy = False - self.setWindowTitle("ESP-Firmware") + self.setWindowTitle(_("ESP-Firmware")) self.setWindowIcon(ui.app_icon()) self.setAttribute(Qt.WidgetAttribute.WA_DeleteOnClose) self.setMinimumWidth(460) lay = QVBoxLayout(self) lay.setContentsMargins(20, 18, 20, 18) lay.setSpacing(10) - title = QLabel("ESP-Firmware") + title = QLabel(_("ESP-Firmware")) title.setFont(ui.font(15, QFont.Weight.DemiBold)) lay.addWidget(title) - lay.addWidget(ui.label("Die App bringt die Firmware für den ESP32-C5 mit und spielt sie " - "über das USB-Kabel auf; Tasten am ESP braucht es dafür nicht.", - "secondary", wrap=True)) + lay.addWidget(ui.label(_("Die App bringt die Firmware für den ESP32-C5 mit und spielt " + "sie über das USB-Kabel auf; Tasten am ESP braucht es dafür " + "nicht."), "secondary", wrap=True)) card = ui.Card() - self.device_sub = card.add_row("Auf dem ESP", None, "") - self.file_btn = ui.button("Andere Datei …", "link") + self.device_sub = card.add_row(_("Auf dem ESP"), None, "") + self.file_btn = ui.button(_("Andere Datei …"), "link") self.file_btn.clicked.connect(self._choose) - self.fw_sub = card.add_row("Zum Installieren", self.file_btn, "") + self.fw_sub = card.add_row(_("Zum Installieren"), self.file_btn, "") self.port_box = QComboBox() self.port_box.setMinimumWidth(130) - self.port_sub = card.add_row("Anschluss", self.port_box, "") + self.port_sub = card.add_row(_("Anschluss"), self.port_box, "") lay.addWidget(card) self.status = ui.label("", "secondary", wrap=True) lay.addWidget(self.status) @@ -588,9 +599,9 @@ def __init__(self, window, flasher=None, loader=None): lay.addStretch(1) row = QHBoxLayout() row.addStretch(1) - self.close_btn = ui.button("Schließen") + self.close_btn = ui.button(_("Schließen")) self.close_btn.clicked.connect(self.close) - self.install_btn = ui.button("Installieren", "primary") + self.install_btn = ui.button(_("Installieren"), "primary") self.install_btn.clicked.connect(self._install) row.addWidget(self.close_btn) row.addWidget(self.install_btn) @@ -609,8 +620,8 @@ def __init__(self, window, flasher=None, loader=None): def _load(self, path): self.fw = None if not path: - self.fw_sub.setText("Keine Firmware dabei: eine Datei wählen (espdrop-esp32c5.bin " - "aus einem Release).") + self.fw_sub.setText(_("Keine Firmware dabei: eine Datei wählen (espdrop-esp32c5.bin " + "aus einem Release).")) return try: self.fw = self.loader(path) @@ -618,14 +629,15 @@ def _load(self, path): self.fw_sub.setText(str(e)) return name = os.path.basename(self.fw.source.rstrip("/\\")) - self.fw_sub.setText(f"EspDrop {self.fw.version} für den {self.fw.chip}, gebaut " - f"{built_text(self.fw.built)}\n{name}") + self.fw_sub.setText(_("EspDrop {version} für den {chip}, gebaut {built}").format( + version=self.fw.version, chip=self.fw.chip, built=built_text(self.fw.built)) + + "\n" + name) self._fit() def _choose(self): - path, _ = QFileDialog.getOpenFileName( - self, "Firmware wählen", os.path.expanduser("~"), - "ESP-Firmware (*.bin flasher_args.json);;Alle Dateien (*)") + path, _filter = QFileDialog.getOpenFileName( + self, _("Firmware wählen"), os.path.expanduser("~"), + _("ESP-Firmware (*.bin flasher_args.json);;Alle Dateien (*)")) if path: self._load(path) self._refresh() @@ -640,8 +652,8 @@ def _fill_ports(self): self.port_box.setCurrentText(self.w.port) self.port_box.setVisible(len(found) > 1) self.port_sub.setText(found[0] if len(found) == 1 else - "Kein ESP gefunden: per USB anstecken." if not found else - "Mehrere gefunden: den ESP wählen.") + _("Kein ESP gefunden: per USB anstecken.") if not found else + _("Mehrere gefunden: den ESP wählen.")) def _refresh(self): self.device_sub.setText(self.w.firmware_text()) @@ -663,14 +675,14 @@ def _install(self): self._refresh() return if self.w.transfer_running(): - self._say("Gerade läuft eine Übertragung; danach installieren.", "bad") + self._say(_("Gerade läuft eine Übertragung; danach installieren."), "bad") return self.busy = True for b in (self.install_btn, self.close_btn, self.file_btn, self.port_box): b.setEnabled(False) self.bar.set_value(None) self.bar.show() - self._say(FLASH_STAGES["connect"]) + self._say(_(FLASH_STAGES["connect"])) self.w.set_flashing(True) session, fw, sig, flasher = self.w.session, self.fw, self.sig, self.flasher @@ -682,7 +694,7 @@ def work(): except flash.FirmwareError as e: error = str(e) except Exception as e: # noqa: BLE001 - shown to the user - error = f"Flashen fehlgeschlagen: {e}" + error = _("Flashen fehlgeschlagen: {error}").format(error=e) finally: session.start() # connect again, new firmware or old sig.finished.emit(error) @@ -691,11 +703,11 @@ def work(): def _on_progress(self, stage, fraction): if stage == "write" and fraction is not None: self.bar.set_value(fraction) - self._say(f"{FLASH_STAGES['write']} {fraction * 100:.0f} %") + self._say(_("Schreibe die Firmware … {percent} %").format(percent=f"{fraction * 100:.0f}")) elif stage in FLASH_STAGES: if stage != "connect": self.bar.set_value(1.0) - self._say(FLASH_STAGES[stage]) + self._say(_(FLASH_STAGES[stage])) def _on_finished(self, error): self.busy = False @@ -705,12 +717,12 @@ def _on_finished(self, error): if error: self.bar.hide() self._say(error, "bad") - self.install_btn.setText("Erneut versuchen") + self.install_btn.setText(_("Erneut versuchen")) else: self.bar.set_value(1.0) - self._say(f"Fertig: EspDrop {self.fw.version} ist auf dem ESP. Er startet neu und " - "verbindet sich gleich wieder.", "good") - self.install_btn.setText("Erneut installieren") + self._say(_("Fertig: EspDrop {version} ist auf dem ESP. Er startet neu und " + "verbindet sich gleich wieder.").format(version=self.fw.version), "good") + self.install_btn.setText(_("Erneut installieren")) self._refresh() def _say(self, text, tone=None): @@ -748,6 +760,8 @@ def __init__(self, port=None, settings=None, session_factory=None, ask_timeout=6 super().__init__() ui.ensure(QApplication.instance()) self.settings = settings or QSettings(APP, APP) + i18n.set_language(self.settings.value("language", "auto")) + self.restart_requested = False # main() starts the app again after quitting ui.set_mode(QApplication.instance(), self._theme_key()) self.setWindowTitle(APP) self.setWindowIcon(ui.app_icon()) @@ -820,11 +834,11 @@ def _build(self): status.setSpacing(6) self.dot = ui.StatusDot() status.addWidget(self.dot, 0, Qt.AlignmentFlag.AlignVCenter) - self.device_label = ui.label("Suche ESP …", "secondary") + self.device_label = ui.label(_("Suche ESP …"), "secondary") self.power_label = ui.label("", "secondary") status.addWidget(self.device_label) status.addWidget(self.power_label) - self.fw_hint = ui.button("Firmware installieren …", "link") + self.fw_hint = ui.button(_("Firmware installieren …"), "link") self.fw_hint.clicked.connect(self.open_firmware_dialog) self.fw_hint.hide() status.addWidget(self.fw_hint) @@ -832,15 +846,15 @@ def _build(self): titles.addLayout(status) titles.addStretch(1) head.addLayout(titles, 1) - self.more_btn = ui.tool_button("more", "Mehr") + self.more_btn = ui.tool_button("more", _("Mehr")) self.more_btn.setPopupMode(QToolButton.ToolButtonPopupMode.InstantPopup) menu = QMenu(self.more_btn) - self.log_action = menu.addAction("Protokoll anzeigen") + self.log_action = menu.addAction(_("Protokoll anzeigen")) self.log_action.setCheckable(True) - menu.addAction("ESP-Firmware …", self.open_firmware_dialog) - menu.addAction("Serieller Port …", self._pick_port) + menu.addAction(_("ESP-Firmware …"), self.open_firmware_dialog) + menu.addAction(_("Serieller Port …"), self._pick_port) menu.addSeparator() - menu.addAction("Beenden", self.quit) + menu.addAction(_("Beenden"), self.quit) self.more_btn.setMenu(menu) head.addWidget(self.more_btn, 0, Qt.AlignmentFlag.AlignTop) col.addLayout(head) @@ -848,14 +862,14 @@ def _build(self): # AirDrop: who may send to us, our name, where files go col.addWidget(ui.section("AirDrop")) card = ui.Card() - self.visibility = ui.Segmented([lbl for lbl, _ in VISIBILITY]) + self.visibility = ui.Segmented([_(lbl) for lbl, _secs in VISIBILITY]) self.visibility.changed.connect(self._visibility_picked) vis = QWidget() # the segments, and below them how long vv = QVBoxLayout(vis) vv.setContentsMargins(0, 0, 0, 0) vv.setSpacing(4) vh = QHBoxLayout() - vh.addWidget(QLabel("Empfangen von")) + vh.addWidget(QLabel(_("Empfangen von"))) vh.addStretch(1) vh.addWidget(self.visibility) vv.addLayout(vh) @@ -867,21 +881,21 @@ def _build(self): self.name_edit.setPlaceholderText(env.device_name()) self.name_edit.setFixedWidth(210) self.name_edit.editingFinished.connect(self._name_changed) - card.add_row("Name", self.name_edit, "So sehen dich iPhone, iPad und Mac.") + card.add_row(_("Name"), self.name_edit, _("So sehen dich iPhone, iPad und Mac.")) folder = QWidget() fh = QHBoxLayout(folder) fh.setContentsMargins(0, 0, 0, 0) fh.setSpacing(8) fv = QVBoxLayout() fv.setSpacing(2) - fv.addWidget(QLabel("Empfangene Dateien")) + fv.addWidget(QLabel(_("Empfangene Dateien"))) self.dir_label = ui.ElidedLabel() self.dir_label.setProperty("role", "caption") fv.addWidget(self.dir_label) fh.addLayout(fv, 1) - change = ui.button("Ändern …", "link") + change = ui.button(_("Ändern …"), "link") change.clicked.connect(self._pick_dir) - show = ui.button("Öffnen", "link") + show = ui.button(_("Öffnen"), "link") show.clicked.connect(lambda: QDesktopServices.openUrl( QUrl.fromLocalFile(self.session.download_dir or os.getcwd()))) fh.addWidget(change) @@ -891,30 +905,30 @@ def _build(self): self.clipboard_switch.set_quiet(self.settings.value("clipboard", False, type=bool)) self.clipboard_switch.toggled.connect( lambda on: self.settings.setValue("clipboard", bool(on))) - card.add_row("In die Zwischenablage kopieren", self.clipboard_switch, - "Empfangenes zusätzlich zum Einfügen bereithalten: Bilder, Dateien " - "und Links.") + card.add_row(_("In die Zwischenablage kopieren"), self.clipboard_switch, + _("Empfangenes zusätzlich zum Einfügen bereithalten: Bilder, Dateien " + "und Links.")) col.addWidget(card) # devices nearby - self.search_btn = ui.button("Suchen") + self.search_btn = ui.button(_("Suchen")) self.search_btn.clicked.connect(self._search) - col.addWidget(ui.section("Geräte in der Nähe", self.search_btn)) + col.addWidget(ui.section(_("Geräte in der Nähe"), self.search_btn)) dcard = ui.Card() body = QWidget() bv = QVBoxLayout(body) bv.setContentsMargins(0, 0, 0, 0) bv.setSpacing(6) self.radar = ui.Radar() - self.radar_text = ui.label("Suche nach Geräten in der Nähe …", "secondary", center=True) - self.devices_note = ui.label("„Suchen“ findet AirDrop-Geräte in der Nähe. Am iPhone " - "oder iPad: AirDrop „Für alle“, entsperrt.", + self.radar_text = ui.label(_("Suche nach Geräten in der Nähe …"), "secondary", center=True) + self.devices_note = ui.label(_("„Suchen“ findet AirDrop-Geräte in der Nähe. Am iPhone " + "oder iPad: AirDrop „Für alle“, entsperrt."), "secondary", wrap=True, center=True) self.devices_note.setContentsMargins(12, 10, 12, 10) self.devices = ui.DeviceGrid() self.devices.clicked.connect(self._tile_clicked) self.devices.dropped.connect(self._send_to) - self.devices_hint = ui.label("Dateien auf ein Gerät ziehen oder es anklicken.", + self.devices_hint = ui.label(_("Dateien auf ein Gerät ziehen oder es anklicken."), "caption", center=True) for w in (self.radar, self.radar_text, self.devices_note, self.devices, self.devices_hint): @@ -923,7 +937,7 @@ def _build(self): col.addWidget(dcard) # transfers - col.addWidget(ui.section("Übertragungen")) + col.addWidget(ui.section(_("Übertragungen"))) tcard = ui.Card() self.transfers = ui.TransferList() tcard.add(self.transfers, margins=(0, 0, 0, 0)) @@ -932,27 +946,41 @@ def _build(self): # general caps = integration.supported() self.autostart_switch = self.send_menu_switch = None - col.addWidget(ui.section("Allgemein")) + col.addWidget(ui.section(_("Allgemein"))) gcard = ui.Card() - self.theme = ui.Segmented([lbl for lbl, _ in THEMES]) + self.theme = ui.Segmented([_(lbl) for lbl, _key in THEMES]) self.theme.set_index(THEME_KEYS.index(self._theme_key()), animate=False) self.theme.changed.connect(self._theme_picked) - gcard.add_row("Erscheinungsbild", self.theme) + gcard.add_row(_("Erscheinungsbild"), self.theme) + self.language = ui.Segmented([_(label) for _code, label in i18n.LANGUAGES]) + self.language.set_index(self._language_index(), animate=False) + self.language.changed.connect(self._language_picked) + self.restart_btn = ui.button(_("Jetzt neu starten"), "link") + self.restart_btn.clicked.connect(self.restart) + self.restart_btn.hide() + lang_box = QWidget() + lb_ = QVBoxLayout(lang_box) + lb_.setContentsMargins(0, 0, 0, 0) + lb_.setSpacing(2) + lb_.addWidget(self.language) + lb_.addWidget(self.restart_btn, 0, Qt.AlignmentFlag.AlignRight) + self.language_sub = gcard.add_row(_("Sprache"), lang_box, "") + self.language_sub.hide() if caps["autostart"]: s = self.autostart_switch = ui.Switch() s.set_quiet(_safe(integration.autostart_enabled, False)) s.toggled.connect(lambda on, s=s: self._integrate(integration.set_autostart, on, s)) - gcard.add_row("Beim Anmelden im Hintergrund starten", s, - "Unsichtbar im Infobereich und für alle sichtbar; nur eingehende " - "Dateien melden sich.") + gcard.add_row(_("Beim Anmelden im Hintergrund starten"), s, + _("Unsichtbar im Infobereich und für alle sichtbar; nur eingehende " + "Dateien melden sich.")) if caps["send"]: s = self.send_menu_switch = ui.Switch() s.set_quiet(_safe(integration.send_menu_enabled, False)) s.toggled.connect(lambda on, s=s: self._integrate(integration.set_send_menu, on, s)) - gcard.add_row(caps["send"], s, caps.get("send_hint")) - self.fw_btn = ui.button("Installieren …") + gcard.add_row(_(caps["send"]), s, _(caps.get("send_hint") or "")) + self.fw_btn = ui.button(_("Installieren …")) self.fw_btn.clicked.connect(self.open_firmware_dialog) - self.fw_sub = gcard.add_row("ESP-Firmware", self.fw_btn, "") + self.fw_sub = gcard.add_row(_("ESP-Firmware"), self.fw_btn, "") col.addWidget(gcard) # the log, off unless asked for @@ -967,7 +995,7 @@ def _build(self): self.log_box = QWidget() lb = QVBoxLayout(self.log_box) lb.setContentsMargins(0, 0, 0, 0) - lb.addWidget(ui.section("Protokoll")) + lb.addWidget(ui.section(_("Protokoll"))) lb.addWidget(self.log) self.log_box.hide() self.log_action.toggled.connect(self.log_box.setVisible) @@ -986,13 +1014,13 @@ def _build(self): def _make_tray(self): tray = QSystemTrayIcon(ui.app_icon(), self) menu = QMenu() - menu.addAction("EspDrop öffnen", self._show) - menu.addAction("Dateien senden …", lambda: self.open_send_dialog([])) - self.tray_receive = menu.addAction("Für alle sichtbar (10 Minuten)", + menu.addAction(_("EspDrop öffnen"), self._show) + menu.addAction(_("Dateien senden …"), lambda: self.open_send_dialog([])) + self.tray_receive = menu.addAction(_("Für alle sichtbar (10 Minuten)"), lambda: self.visibility.select( 0 if self.session.receiving else 1)) menu.addSeparator() - menu.addAction("Beenden", self.quit) + menu.addAction(_("Beenden"), self.quit) tray.setContextMenu(menu) tray.activated.connect(lambda reason: self._show() if reason == QSystemTrayIcon.ActivationReason.Trigger else None) @@ -1024,7 +1052,7 @@ def _integrate(self, fn, on, switch): fn(on) except Exception as e: # noqa: BLE001 switch.set_quiet(not on) - QMessageBox.warning(self, APP, f"Das ging nicht: {e}") + QMessageBox.warning(self, APP, _("Das ging nicht: {error}").format(error=e)) # ---- other starts and the share menu ------------------------------------- @@ -1071,7 +1099,7 @@ def closeEvent(self, e): if self.tray and not self._quitting: e.ignore() self.hide() - self._notify("Läuft im Hintergrund weiter (Symbol im Infobereich).", None, 3000) + self._notify(_("Läuft im Hintergrund weiter (Symbol im Infobereich)."), None, 3000) return if self._flashing: # no tray: wait for the firmware e.ignore() @@ -1081,7 +1109,7 @@ def closeEvent(self, e): def quit(self): if self._flashing: # half a firmware: finish first - self._notify("Die Firmware wird gerade installiert; danach beenden.", None, 4000) + self._notify(_("Die Firmware wird gerade installiert; danach beenden."), None, 4000) return self._quitting = True self._shutdown() @@ -1105,7 +1133,7 @@ def _on_event(self, kind, info): self._silent = 0 self.firmware = (info.get("info") or {}).get("firmware") self.dot.set_state("green") - self.device_label.setText("ESP verbunden") + self.device_label.setText(_("ESP verbunden")) mac = (info.get("info") or {}).get("mac", "") self.device_label.setToolTip(f"ESP {mac}" if mac else "") self._set_device_ui(True) # the session restarts receiving itself @@ -1115,8 +1143,8 @@ def _on_event(self, kind, info): self.dot.set_state("red") # An ESP that does not answer twice in a row (not just booting): # other or no firmware, or one too old for this app. - self.device_label.setText("ESP antwortet nicht" if self._silent >= 2 - else "Kein ESP") + self.device_label.setText(_("ESP antwortet nicht") if self._silent >= 2 + else _("Kein ESP")) self.device_label.setToolTip(info.get("error") or "") self._set_device_ui(False) elif state == "stopped": @@ -1124,9 +1152,9 @@ def _on_event(self, kind, info): self._set_device_ui(False) if self._flashing: # the dialog has the port for esptool self.dot.set_state("orange") - self.device_label.setText("Firmware wird installiert …") + self.device_label.setText(_("Firmware wird installiert …")) else: - self.device_label.setText("Beendet") + self.device_label.setText(_("Beendet")) self._update_firmware_ui() if state in ("connected", "lost", "stopped"): for d in list(self._send_dialogs): @@ -1136,7 +1164,7 @@ def _on_event(self, kind, info): self.power_label.setText(f"· {text}" if text else "") elif kind == "browse": self._browsing = info["on"] - self.search_btn.setText("Suche läuft …" if info["on"] else "Suchen") + self.search_btn.setText(_("Suche läuft …") if info["on"] else _("Suchen")) self.search_btn.setEnabled(not info["on"] and self._connected) self._update_devices_ui() elif kind == "receiver": @@ -1163,10 +1191,10 @@ def _send_notice(self, info): return t, state = info["transfer"], info["state"] if state == "done": - self._notify(f"Gesendet an {t.receiver.label}") + self._notify(_("Gesendet an {name}").format(name=t.receiver.label)) elif state in ("declined", "failed"): - self._notify(f"Nicht gesendet an {t.receiver.label}: " - f"{'abgelehnt' if state == 'declined' else info.get('error')}") + self._notify(_("Nicht gesendet an {name}").format(name=t.receiver.label) + ": " + + (_("abgelehnt") if state == "declined" else str(info.get("error")))) # ---- the ESP's firmware ------------------------------------------------------ @@ -1186,17 +1214,20 @@ def bundled_firmware(self): def firmware_text(self): """What the ESP runs, in words.""" if self._flashing: - return "Wird gerade installiert …" + return _("Wird gerade installiert …") if self._connected: fw = self.firmware if not fw: - return "EspDrop, eine ältere Firmware ohne Versionsangabe" + return _("EspDrop, eine ältere Firmware ohne Versionsangabe") chip = {"esp32c5": "ESP32-C5", "esp32c3": "ESP32-C3"}.get(fw.get("chip"), fw.get("chip")) - return f"EspDrop {fw['version']}" + (f" auf dem {chip}" if chip else "") + if chip: + return _("EspDrop {version} auf dem {chip}").format(version=fw["version"], + chip=chip) + return _("EspDrop {version}").format(version=fw["version"]) if self._silent >= 2: - return "Ein ESP steckt, antwortet aber nicht: keine oder eine andere Firmware" - return "Kein ESP verbunden" + return _("Ein ESP steckt, antwortet aber nicht: keine oder eine andere Firmware") + return _("Kein ESP verbunden") def _update_firmware_ui(self): bundled = self.bundled_firmware() @@ -1204,9 +1235,9 @@ def _update_firmware_ui(self): and flash.is_newer(bundled.version, (self.firmware or {}).get("version"))) text = self.firmware_text() if newer: - text += f" · neu in der App: {bundled.version}" + text += _(" · neu in der App: {version}").format(version=bundled.version) self.fw_sub.setText(text) - self.fw_btn.setText("Aktualisieren …" if newer else "Installieren …") + self.fw_btn.setText(_("Aktualisieren …") if newer else _("Installieren …")) self.fw_hint.setVisible(self._silent >= 2 and not self._flashing) def open_firmware_dialog(self): @@ -1254,16 +1285,39 @@ def _tick(self): until = s.receive_until if until: left = max(0, until - time.monotonic()) - text = f"Für alle sichtbar – noch {int(left // 60)}:{int(left % 60):02d}" + text = _("Für alle sichtbar – noch {left}").format( + left=f"{int(left // 60)}:{int(left % 60):02d}") else: - text = "Für alle sichtbar, bis du es ausschaltest." + text = _("Für alle sichtbar, bis du es ausschaltest.") else: - text = "Niemand kann dir gerade etwas senden." + text = _("Niemand kann dir gerade etwas senden.") if self.receive_left.text() != text: self.receive_left.setText(text) # ---- appearance and the clipboard ------------------------------------------ + def _language_index(self): + codes = [code for code, _label in i18n.LANGUAGES] + choice = self.settings.value("language", "auto") + return codes.index(choice) if choice in codes else 0 + + def _language_picked(self, index): + code = i18n.LANGUAGES[index][0] + self.settings.setValue("language", code) + # Every text is set when the window is built: the new language shows + # after a restart, which the button does at once. + now = i18n.LANG + wanted = code if code in ("de", "en") else i18n.system_language() + self.language_sub.setText(_("Gilt nach einem Neustart der App.")) + self.language_sub.setVisible(wanted != now) + self.restart_btn.setVisible(wanted != now) + + def restart(self): + """Quit, and have main() start the app again (the new language).""" + if not self._flashing: # quit() says why it waits + self.restart_requested = True + self.quit() + def _theme_key(self): key = self.settings.value("theme", "auto") return key if key in THEME_KEYS else "auto" @@ -1312,15 +1366,16 @@ def _receiving_ui(self, on, until): if self.visibility.index() != index: self.visibility.set_index(index) if self.tray: - self.tray_receive.setText("Nicht mehr sichtbar" if on - else "Für alle sichtbar (10 Minuten)") + self.tray_receive.setText(_("Nicht mehr sichtbar") if on + else _("Für alle sichtbar (10 Minuten)")) self._tick() def _on_ask(self, ask, reply): # Hidden (autostart, tray): only the question comes up, on its own; # the window stays where it is. hidden = not self.isVisible() - self._notify(f"{ask.sender} möchte {what_text(ask.files, ask.urls)} teilen") + self._notify(_("{name} möchte {what} teilen").format( + name=ask.sender, what=what_text(ask.files, ask.urls))) dlg = AskDialog(None if hidden else self, ask, reply, self.bridge.ask_timeout) if hidden: dlg.setWindowIcon(ui.app_icon()) @@ -1341,12 +1396,12 @@ def _receive_row(self, info): return if ask and ask.urls and not ask.files: # a link: no upload follows copied = self._to_clipboard(urls=ask.urls) - self._notify(f"Link von {ask.sender}" + (" – in der Zwischenablage" - if copied else "")) + self._notify(_("Link von {name}").format(name=ask.sender) + + (_(" – in der Zwischenablage") if copied else "")) self._recv_n += 1 row = ui.TransferRow("receive", ", ".join(ask.files or ask.urls) if ask else "?", ask.sender if ask else "?") - row.set_state("Wird empfangen …", running=True) + row.set_state(_("Wird empfangen …"), running=True) row.open.connect(lambda r=row: self._open_files(r.files)) self.transfers.add(row) self._rows[("recv", self._recv_n)] = row @@ -1365,19 +1420,19 @@ def _receive_row(self, info): if info.get("ok"): names = ", ".join(os.path.basename(f) for f in files) row.set_title(names) - row.set_state(f"Empfangen · {human(info.get('size', 0))}", files=files, - tone="done") + row.set_state(_("Empfangen · {size}").format(size=human(info.get("size", 0))), + files=files, tone="done") copied = self._to_clipboard(files=files) - self._notify(f"Empfangen: {names}" + (" – in der Zwischenablage" - if copied else " – klicken zum Öffnen"), - files) + self._notify(_("Empfangen: {names}").format(names=names) + + (_(" – in der Zwischenablage") if copied + else _(" – klicken zum Öffnen")), files) else: - row.set_state("Unvollständig", + row.set_state(_("Unvollständig"), files=files + ([info["raw"]] if info.get("raw") else []), tone="bad") def _pick_dir(self): - d = QFileDialog.getExistingDirectory(self, "Ordner für empfangene Dateien", + d = QFileDialog.getExistingDirectory(self, _("Ordner für empfangene Dateien"), self.session.download_dir or os.getcwd()) if d: self.session.set_download_dir(d) @@ -1409,20 +1464,20 @@ def _search(self): def _tile_clicked(self, rid): tile = self.devices.tiles.get(rid) - files, _ = QFileDialog.getOpenFileNames( - self, f"An {tile.name if tile else 'das Gerät'} senden") + files, _filter = QFileDialog.getOpenFileNames( + self, _("An {name}").format(name=tile.name if tile else _("das Gerät"))) if files: self._send_to(rid, files) def _send_to(self, rid, files): files = [f for f in files if os.path.isfile(f)] if not files: - QMessageBox.information(self, APP, "Ordner lassen sich noch nicht senden, nur Dateien.") + QMessageBox.information(self, APP, _("Ordner lassen sich noch nicht senden, nur Dateien.")) return try: t = self.session.send(rid, files) except Exception as e: # noqa: BLE001 - QMessageBox.warning(self, APP, f"Senden geht nicht: {e}") + QMessageBox.warning(self, APP, _("Senden geht nicht: {error}").format(error=e)) return self._send_row({"transfer": t, "state": t.state, "progress": 0.0, "error": None}) @@ -1438,7 +1493,7 @@ def _send_row(self, info): self.transfers.add(row) self._rows[key] = row state = info["state"] - text = SEND_STATES.get(state, state) + text = _(SEND_STATES.get(state, state)) if state == "failed" and info.get("error"): text += f": {info['error']}" running = state in ("new", "asking", "uploading") @@ -1454,13 +1509,14 @@ def _send_row(self, info): def _pick_port(self): from .transport import candidates - ports = ["automatisch"] + candidates() - current = self.port or "automatisch" - port, ok = QInputDialog.getItem(self, APP, "Serieller Port des ESP:", ports, + auto = _("automatisch") + ports = [auto] + candidates() + current = self.port or auto + port, ok = QInputDialog.getItem(self, APP, _("Serieller Port des ESP:"), ports, ports.index(current) if current in ports else 0, True) if not ok: return - self.port = None if port == "automatisch" else port + self.port = None if port == auto else port self.settings.setValue("port", self.port or "") receiving, until = self.session.receiving, self.session.receive_until secs = None if not until else max(1, int(until - time.monotonic())) @@ -1481,13 +1537,14 @@ def power_text(reply): if len(words) < 3 or words[0] != "power": return "" if words[1] == "on": - return "Funk immer an" + return _("Funk immer an") listen = next((w[7:] for w in words if w.startswith("listen=")), "off") - why = {"(hold)": "für die App", "(wake)": "zum Senden", "(busy)": "Übertragung", - "(advert)": "Gerät in der Nähe"}.get(words[3] if len(words) > 3 else "", "") + why = {"(hold)": N_("für die App"), "(wake)": N_("zum Senden"), "(busy)": N_("Übertragung"), + "(advert)": N_("Gerät in der Nähe")}.get(words[3] if len(words) > 3 else "", "") if words[2] == "asleep": - return "Funk schläft (empfangsbereit)" if listen != "off" else "Funk aus (spart Strom)" - return f"Funk an ({why})" if why else "Funk an" + return (_("Funk schläft (empfangsbereit)") if listen != "off" + else _("Funk aus (spart Strom)")) + return _("Funk an ({why})").format(why=_(why)) if why else _("Funk an") def default_download_dir(): @@ -1547,6 +1604,7 @@ def main(port=None, background=False, send=None): return 0 server = InstanceServer() settings = QSettings(APP, APP) + i18n.set_language(settings.value("language", "auto")) # before any text is made _desktop_setup(settings) w = MainWindow(port=port, settings=settings, background=background) server.message.connect(w.handle_message) @@ -1556,4 +1614,8 @@ def main(port=None, background=False, send=None): w.show() rc = app.exec() server.close() + if w.restart_requested: # the same app again, the window shown + import subprocess + subprocess.Popen(integration.launch_command(), close_fds=True, + creationflags=getattr(subprocess, "DETACHED_PROCESS", 0)) return rc diff --git a/host/espdrop/i18n.py b/host/espdrop/i18n.py new file mode 100644 index 0000000..5d17bf0 --- /dev/null +++ b/host/espdrop/i18n.py @@ -0,0 +1,295 @@ +# EspDrop - the app in German or English. +# +# The texts in the code are German; _() gives them in the language chosen +# (Allgemein -> Sprache: Automatisch, Deutsch, English). "Automatisch" is the +# system's language: German for a German system, English for any other. +# Texts with values use str.format placeholders, so the English sentence may +# put them elsewhere: _("Gesendet an {name}").format(name=...). +# +# The English texts are in EN below; test_i18n.py checks that every _("...") +# in the app has one, and that no German text is shown without _(). +# +# SPDX-License-Identifier: GPL-3.0-or-later +# Copyright (C) 2026 EspDrop contributors + +import locale +import os + +LANGUAGES = [("auto", "Automatisch"), ("de", "Deutsch"), ("en", "English")] +LANG = "de" # in use now: "de" or "en" + + +def system_language(): + """'de' on a German system, else 'en' (ESPDROP_LANG overrides).""" + forced = os.environ.get("ESPDROP_LANG", "").lower() + if forced in ("de", "en"): + return forced + candidates = [os.environ.get(k, "") for k in ("LC_ALL", "LC_MESSAGES", "LANG", "LANGUAGE")] + try: + from PySide6.QtCore import QLocale + candidates.insert(0, QLocale.system().name()) # Windows: the UI language + except ImportError: + pass + try: + candidates.append(locale.getlocale()[0] or "") + except ValueError: + pass + for c in candidates: + if c: + return "de" if c.lower().startswith("de") else "en" + return "en" + + +def set_language(choice): + """choice: 'auto', 'de' or 'en'; returns the language now in use.""" + global LANG + LANG = choice if choice in ("de", "en") else system_language() + return LANG + + +def N_(text): + """Marks a text for translation where it is defined (tables); _() at use.""" + return text + + +def _(text): + """text (German, as in the code) in the language in use.""" + if LANG == "en": + return EN.get(text, text) + return text + + +EN = { + # ---- the window ------------------------------------------------------- + "AirDrop": "AirDrop", + "Mehr": "More", + "Protokoll anzeigen": "Show log", + "Protokoll": "Log", + "ESP-Firmware …": "ESP firmware …", + "Serieller Port …": "Serial port …", + "Serieller Port des ESP:": "The ESP's serial port:", + "automatisch": "automatic", + "Beenden": "Quit", + "Suche ESP …": "Looking for the ESP …", + "ESP verbunden": "ESP connected", + "ESP {mac}": "ESP {mac}", + "Kein ESP": "No ESP", + "ESP antwortet nicht": "ESP does not answer", + "Firmware installieren …": "Install firmware …", + "Firmware wird installiert …": "Installing firmware …", + "Beendet": "Stopped", + "Empfangen von": "Receiving from", + "Aus": "Off", + "10 Minuten": "10 minutes", + "Für alle": "Everyone", + "Niemand kann dir gerade etwas senden.": "Nobody can send you anything right now.", + "Für alle sichtbar – noch {left}": "Visible to everyone – {left} left", + "abgelehnt": "declined", + "Für alle sichtbar, bis du es ausschaltest.": "Visible to everyone until you turn it off.", + "Name": "Name", + "So sehen dich iPhone, iPad und Mac.": "How iPhone, iPad and Mac see you.", + "Empfangene Dateien": "Received files", + "Ändern …": "Change …", + "Öffnen": "Open", + "Ordner für empfangene Dateien": "Folder for received files", + "In die Zwischenablage kopieren": "Copy to the clipboard", + "Empfangenes zusätzlich zum Einfügen bereithalten: Bilder, Dateien und Links.": + "Also keep what arrives ready to paste: pictures, files and links.", + "Geräte in der Nähe": "Nearby devices", + "Suchen": "Search", + "Suche läuft …": "Searching …", + "Suche nach Geräten in der Nähe …": "Looking for nearby devices …", + "„Suchen“ findet AirDrop-Geräte in der Nähe. Am iPhone oder iPad: AirDrop „Für alle“, " + "entsperrt.": + "“Search” finds AirDrop devices nearby. On the iPhone or iPad: AirDrop “Everyone”, " + "unlocked.", + "Dateien auf ein Gerät ziehen oder es anklicken.": "Drop files on a device or click it.", + "Übertragungen": "Transfers", + "Allgemein": "General", + "Erscheinungsbild": "Appearance", + "Automatisch": "Automatic", + "Hell": "Light", + "Dunkel": "Dark", + "Sprache": "Language", + "Gilt nach einem Neustart der App.": "Takes effect when the app restarts.", + "Jetzt neu starten": "Restart now", + "Beim Anmelden im Hintergrund starten": "Start in the background at login", + "Unsichtbar im Infobereich und für alle sichtbar; nur eingehende Dateien melden sich.": + "Hidden in the notification area and visible to everyone; only incoming files " + "show up.", + "Im Rechtsklickmenü des Explorers anbieten": "Offer in Explorer's right-click menu", + "„Senden an → EspDrop (AirDrop)“ und „Mit EspDrop senden“": + "“Send to → EspDrop (AirDrop)” and “Send with EspDrop”", + "Im Dateimanager anbieten": "Offer in the file manager", + "„Öffnen mit → AirDrop (EspDrop)“": "“Open with → AirDrop (EspDrop)”", + "ESP-Firmware": "ESP firmware", + "Installieren …": "Install …", + "Aktualisieren …": "Update …", + "EspDrop öffnen": "Open EspDrop", + "Dateien senden …": "Send files …", + "Für alle sichtbar (10 Minuten)": "Visible to everyone (10 minutes)", + "Nicht mehr sichtbar": "No longer visible", + "Das ging nicht: {error}": "That did not work: {error}", + "Läuft im Hintergrund weiter (Symbol im Infobereich).": + "Keeps running in the background (icon in the notification area).", + "Die Firmware wird gerade installiert; danach beenden.": + "The firmware is being installed; quit afterwards.", + "Gesendet an {name}": "Sent to {name}", + "Nicht gesendet an {name}": "Not sent to {name}", + "{name} möchte {what} teilen": "{name} wants to share {what}", + "Link von {name}": "Link from {name}", + " – in der Zwischenablage": " – on the clipboard", + " – klicken zum Öffnen": " – click to open", + "Wird empfangen …": "Receiving …", + "Empfangen · {size}": "Received · {size}", + "Empfangen: {names}": "Received: {names}", + "Unvollständig": "Incomplete", + "Ordner lassen sich noch nicht senden, nur Dateien.": + "Folders cannot be sent yet, only files.", + "Senden geht nicht: {error}": "Cannot send: {error}", + "An {name}": "To {name}", + "das Gerät": "the device", + # transfer states + "Warten …": "Waiting …", + "Warten auf Annahme …": "Waiting for accept …", + "Senden …": "Sending …", + "Gesendet": "Sent", + "Abgelehnt": "Declined", + "Fehlgeschlagen": "Failed", + "Abgebrochen": "Cancelled", + # what is shared + "einen Link": "a link", + "{n} Links": "{n} links", + "ein Foto": "a photo", + "{n} Fotos": "{n} photos", + "ein Video": "a video", + "{n} Videos": "{n} videos", + "eine Datei": "a file", + "{n} Dateien": "{n} files", + "etwas": "something", + # the radio + "Funk immer an": "Radio always on", + "Funk aus (spart Strom)": "Radio off (saves power)", + "Funk schläft (empfangsbereit)": "Radio asleep (ready to receive)", + "Funk an": "Radio on", + "Funk an ({why})": "Radio on ({why})", + "für die App": "for the app", + "zum Senden": "for sending", + "Übertragung": "transfer", + "Gerät in der Nähe": "device nearby", + # ---- the accept card ---------------------------------------------------- + "„{name}“ möchte {what} teilen.": "“{name}” wants to share {what}.", + "{names} und {n} weitere": "{names} and {n} more", + "Ablehnen": "Decline", + "Annehmen": "Accept", + "Ohne Antwort abgelehnt in {secs} s": "Declined without an answer in {secs} s", + # ---- the AirDrop sheet ---------------------------------------------------- + "Hinzufügen …": "Add …", + "Abbrechen": "Cancel", + "Keine Datei gewählt": "No file chosen", + "„Hinzufügen …“ wählt Dateien (Ordner gehen noch nicht).": + "“Add …” chooses files (folders do not work yet).", + "Per AirDrop senden": "Send with AirDrop", + "Verbinde mit dem ESP …": "Connecting to the ESP …", + "Suche nach Geräten in der Nähe …\nAm iPhone oder iPad: AirDrop „Für alle“, entsperrt.": + "Looking for nearby devices …\nOn the iPhone or iPad: AirDrop “Everyone”, unlocked.", + "Zum Senden auf ein Gerät klicken.": "Click a device to send.", + "Warten, bis {name} annimmt …": "Waiting for {name} to accept …", + "Senden an {name} …": "Sending to {name} …", + "Fertig": "Done", + "{name} hat abgelehnt.": "{name} declined.", + "Abgebrochen.": "Cancelled.", + "Fehlgeschlagen: {error}": "Failed: {error}", + " Zum erneuten Versuch auf ein Gerät klicken.": " Click a device to try again.", + "nicht in der Nähe": "not nearby", + " (Name noch unbekannt)": " (name not known yet)", + "Noch keine Übertragungen.": "No transfers yet.", + "Senden … {percent} %": "Sending … {percent} %", + "Im Ordner zeigen": "Show in folder", + # ---- the firmware dialog --------------------------------------------------- + "Die App bringt die Firmware für den ESP32-C5 mit und spielt sie über das USB-Kabel " + "auf; Tasten am ESP braucht es dafür nicht.": + "The app comes with the firmware for the ESP32-C5 and puts it on over the USB " + "cable; no buttons on the ESP are needed.", + "Auf dem ESP": "On the ESP", + "Andere Datei …": "Other file …", + "Zum Installieren": "To install", + "Anschluss": "Port", + "Schließen": "Close", + "Installieren": "Install", + "Keine Firmware dabei: eine Datei wählen (espdrop-esp32c5.bin aus einem Release).": + "No firmware included: choose a file (espdrop-esp32c5.bin from a release).", + "EspDrop {version} für den {chip}, gebaut {built}": + "EspDrop {version} for the {chip}, built {built}", + "Firmware wählen": "Choose firmware", + "ESP-Firmware (*.bin flasher_args.json);;Alle Dateien (*)": + "ESP firmware (*.bin flasher_args.json);;All files (*)", + "Kein ESP gefunden: per USB anstecken.": "No ESP found: plug it in over USB.", + "Mehrere gefunden: den ESP wählen.": "Several found: choose the ESP.", + "Gerade läuft eine Übertragung; danach installieren.": + "A transfer is running; install afterwards.", + "Schreibe die Firmware …": "Writing the firmware …", + "Schreibe die Firmware … {percent} %": "Writing the firmware … {percent} %", + "Prüfe, ob alles angekommen ist …": "Checking that everything arrived …", + "Starte den ESP neu …": "Restarting the ESP …", + "Flashen fehlgeschlagen: {error}": "Flashing failed: {error}", + "Erneut versuchen": "Try again", + "Fertig: EspDrop {version} ist auf dem ESP. Er startet neu und verbindet sich gleich " + "wieder.": + "Done: EspDrop {version} is on the ESP. It restarts and reconnects in a moment.", + "Erneut installieren": "Install again", + "Wird gerade installiert …": "Being installed …", + "EspDrop, eine ältere Firmware ohne Versionsangabe": + "EspDrop, an older firmware without a version", + "EspDrop {version} auf dem {chip}": "EspDrop {version} on the {chip}", + "EspDrop {version}": "EspDrop {version}", + "Ein ESP steckt, antwortet aber nicht: keine oder eine andere Firmware": + "An ESP is plugged in but does not answer: no firmware, or another one", + "Kein ESP verbunden": "No ESP connected", + " · neu in der App: {version}": " · new in the app: {version}", + # flash.py + "{path} lässt sich nicht lesen: {error}": "{path} cannot be read: {error}", + "Das ist nur der App-Teil einer Firmware. Gebraucht wird das vollständige Image " + "({name}) oder der Build-Ordner.": + "This is only the app part of a firmware. Needed is the complete image ({name}) " + "or the build folder.", + "Keine ESP-Firmware: es fehlt die App an 0x10000.": + "Not ESP firmware: there is no app at 0x10000.", + "Firmware für einen anderen Chip (Kennung {chip}).": + "Firmware for another chip (id {chip}).", + "Kein vollständiges Image: der Bootloader für den {chip} fehlt an {offset}.": + "Not a complete image: the bootloader for the {chip} is missing at {offset}.", + "Kein vollständiges Image: die Partitionstabelle fehlt an 0x8000.": + "Not a complete image: the partition table is missing at 0x8000.", + "{folder} ist kein ESP-IDF-Build-Ordner ({error}).": + "{folder} is not an ESP-IDF build folder ({error}).", + "Dieser Build ist die Test-Konsole, nicht die Firmware für die App: mit " + "sdkconfig.bridge.defaults bauen (firmware/README.md).": + "This build is the test console, not the firmware for the app: build with " + "sdkconfig.bridge.defaults (firmware/README.md).", + "{name} fehlt im Build ({error}).": "{name} is missing in the build ({error}).", + "Die App im Build ({name}) ist kein ESP-Image.": "The app in the build ({name}) is not an ESP image.", + "{name} ist für einen anderen Chip als die App.": "{name} is for another chip than the app.", + "esptool fehlt: tools/opendrop_install.py installiert es (oder pip install esptool).": + "esptool is missing: tools/opendrop_install.py installs it (or pip install esptool).", + "Am Anschluss {port} steckt ein {found}; diese Firmware ist für den {chip}.": + "Port {port} has an {found}; this firmware is for the {chip}.", + "Der Anschluss {port} ist belegt oder gesperrt: hat ihn ein anderes Programm offen " + "(awdl_bridge.py, ein serieller Monitor)?": + "Port {port} is busy or locked: does another program have it open (awdl_bridge.py, " + "a serial monitor)?", + " Unter Linux muss der Benutzer in der Gruppe 'dialout' sein.": + " On Linux the user must be in the group 'dialout'.", + "Der ESP ließ sich nicht in den Bootloader schalten. Abstecken, die BOOT-Taste " + "gedrückt halten, wieder anstecken, loslassen und erneut versuchen.": + "The ESP could not be switched into its bootloader. Unplug it, hold the BOOT " + "button, plug it in again, let go and try again.", + # ---- desktop entries (integration.py) -------------------------------------- + "Mit EspDrop senden": "Send with EspDrop", + "Per AirDrop senden (EspDrop)": "Send with AirDrop (EspDrop)", + "AirDrop über den ESP32 – empfangsbereit im Hintergrund": + "AirDrop over the ESP32 – ready to receive in the background", + "Dateien per AirDrop an ein iPhone/iPad senden": "Send files to an iPhone/iPad with AirDrop", + "AirDrop mit iPhone, iPad und Mac über den ESP32-C5": + "AirDrop with iPhone, iPad and Mac over the ESP32-C5", +} diff --git a/host/espdrop/integration.py b/host/espdrop/integration.py index 5c0b3bb..3ee3791 100644 --- a/host/espdrop/integration.py +++ b/host/espdrop/integration.py @@ -26,6 +26,8 @@ import subprocess import sys +from .i18n import N_, _ + IS_WINDOWS = os.name == "nt" try: import winreg # Windows only @@ -37,7 +39,7 @@ RUN_KEY = r"Software\Microsoft\Windows\CurrentVersion\Run" MENU_KEY = r"Software\Classes\*\shell\EspDrop" SENDTO_NAME = "EspDrop (AirDrop).lnk" -MENU_TEXT = "Mit EspDrop senden" +MENU_TEXT = N_("Mit EspDrop senden") # in the language the app runs in def launch_command(*args): @@ -71,11 +73,11 @@ def supported(): "send_hint": what the label switches on}.""" if IS_WINDOWS: return {"autostart": winreg is not None, - "send": "Im Rechtsklickmenü des Explorers anbieten", - "send_hint": "„Senden an → EspDrop (AirDrop)“ und „Mit EspDrop senden“"} + "send": N_("Im Rechtsklickmenü des Explorers anbieten"), + "send_hint": N_("„Senden an → EspDrop (AirDrop)“ und „Mit EspDrop senden“")} if sys.platform.startswith("linux"): - return {"autostart": True, "send": "Im Dateimanager anbieten", - "send_hint": "„Öffnen mit → AirDrop (EspDrop)“"} + return {"autostart": True, "send": N_("Im Dateimanager anbieten"), + "send_hint": N_("„Öffnen mit → AirDrop (EspDrop)“")} return {"autostart": False, "send": None, "send_hint": None} @@ -100,7 +102,7 @@ def set_autostart(on): if on: _write_desktop(path, { "Type": "Application", "Name": APP, - "Comment": "AirDrop über den ESP32 – empfangsbereit im Hintergrund", + "Comment": _("AirDrop über den ESP32 – empfangsbereit im Hintergrund"), "Exec": _exec_line(cmd), "Icon": ICON or "document-send", "Terminal": "false", "X-GNOME-Autostart-enabled": "true"}) elif os.path.exists(path): @@ -123,7 +125,7 @@ def set_send_menu(on): if on: changed = _write_desktop(path, { "Type": "Application", "Name": "AirDrop (EspDrop)", "Icon": ICON or "document-send", - "Comment": "Dateien per AirDrop an ein iPhone/iPad senden", + "Comment": _("Dateien per AirDrop an ein iPhone/iPad senden"), "Exec": _exec_line(launch_command("--send")) + " %F", "Terminal": "false", "NoDisplay": "true", # Common types; "Open with other application" lists the app for any. @@ -148,7 +150,7 @@ def _set_windows_send(on): if on: # The right-click entry first: plain registry values, it works even # where PowerShell below is blocked (the error still reaches the caller). - _reg_set(MENU_KEY, "", MENU_TEXT) + _reg_set(MENU_KEY, "", _(MENU_TEXT)) _reg_set(MENU_KEY, "Icon", ICON or cmd[0]) _reg_set(MENU_KEY + r"\command", "", subprocess.list2cmdline(cmd) + ' "%1"') # Explorer appends the selected files to the shortcut's arguments. @@ -157,12 +159,12 @@ def _set_windows_send(on): env = dict(os.environ, ESPDROP_LNK=lnk, ESPDROP_EXE=cmd[0], ESPDROP_ARGS=subprocess.list2cmdline(cmd[1:]), ESPDROP_DIR=os.path.dirname(cmd[1]) if len(cmd) > 1 else "", - ESPDROP_ICON=ICON or "") + ESPDROP_ICON=ICON or "", ESPDROP_DESC=_("Per AirDrop senden (EspDrop)")) script = ("$s = (New-Object -ComObject WScript.Shell).CreateShortcut($env:ESPDROP_LNK);" "$s.TargetPath = $env:ESPDROP_EXE; $s.Arguments = $env:ESPDROP_ARGS;" "$s.WorkingDirectory = $env:ESPDROP_DIR;" "if ($env:ESPDROP_ICON) { $s.IconLocation = $env:ESPDROP_ICON + ',0' };" - "$s.Description = 'Per AirDrop senden (EspDrop)'; $s.Save()") + "$s.Description = $env:ESPDROP_DESC; $s.Save()") os.makedirs(os.path.dirname(lnk), exist_ok=True) subprocess.run(["powershell", "-NoProfile", "-NonInteractive", "-Command", script], env=env, check=True, capture_output=True, @@ -186,7 +188,7 @@ def set_launcher(): path = _xdg_launcher() if _write_desktop(path, { "Type": "Application", "Name": APP, "GenericName": "AirDrop", - "Comment": "AirDrop mit iPhone, iPad und Mac über den ESP32-C5", + "Comment": _("AirDrop mit iPhone, iPad und Mac über den ESP32-C5"), "Exec": _exec_line(launch_command()), "Icon": ICON or "document-send", "Terminal": "false", "Categories": "Network;FileTransfer;", "Keywords": "AirDrop;iPhone;iPad;Mac;teilen;senden;", @@ -210,6 +212,7 @@ def refresh(): if IS_WINDOWS: want = subprocess.list2cmdline(launch_command("--send")) + ' "%1"' if (_reg_get(MENU_KEY + r"\command", "") != want + or _reg_get(MENU_KEY, "") != _(MENU_TEXT) # another language or _reg_get(MENU_KEY, "Icon") != (ICON or launch_command()[0]) or not os.path.isfile(_sendto_path())): set_send_menu(True) diff --git a/host/espdrop/ui.py b/host/espdrop/ui.py index a5dcafd..da5c012 100644 --- a/host/espdrop/ui.py +++ b/host/espdrop/ui.py @@ -15,6 +15,8 @@ import os import re +from .i18n import N_, _ + from PySide6.QtCore import (QEasingCurve, QPoint, QPointF, QRect, QRectF, QSize, Qt, QTimer, QVariantAnimation, Signal) from PySide6.QtGui import (QColor, QFont, QFontMetrics, QGuiApplication, QIcon, QImageReader, @@ -1122,8 +1124,8 @@ class DeviceTile(QWidget): clicked = Signal(str) dropped = Signal(str, list) W, H, AV = 104, 124, 58 - NOTES = {"waiting": "Warten …", "sent": "Gesendet", "declined": "Abgelehnt", - "failed": "Fehlgeschlagen", "cancelled": "Abgebrochen"} + NOTES = {"waiting": N_("Warten …"), "sent": N_("Gesendet"), "declined": N_("Abgelehnt"), + "failed": N_("Fehlgeschlagen"), "cancelled": N_("Abgebrochen")} def __init__(self, rid, parent=None): super().__init__(parent) @@ -1149,7 +1151,7 @@ def __init__(self, rid, parent=None): def set_info(self, name, model, present, named): self.name, self.model, self.present, self.named = name, model, present, named - self.setToolTip(name if named else f"{name} (Name noch unbekannt)") + self.setToolTip(name if named else name + _(" (Name noch unbekannt)")) self.setAccessibleName(name) self.update() @@ -1168,11 +1170,11 @@ def set_phase(self, phase, progress=0.0): def caption(self): if self.phase == "sending": - return f"Senden … {int(self.progress * 100)} %" + return _("Senden … {percent} %").format(percent=int(self.progress * 100)) if self.phase in self.NOTES: - return self.NOTES[self.phase] + return _(self.NOTES[self.phase]) if not self.present: - return "nicht in der Nähe" + return _("nicht in der Nähe") return pretty_model(self.model) def _step(self): @@ -1346,9 +1348,9 @@ def __init__(self, direction, title, device, parent=None): v.addWidget(self.sub) v.addWidget(self.bar) h.addLayout(v, 1) - self.cancel_btn = tool_button("x", "Abbrechen") + self.cancel_btn = tool_button("x", _("Abbrechen")) self.cancel_btn.clicked.connect(self.cancel) - self.open_btn = tool_button("search", "Im Ordner zeigen") + self.open_btn = tool_button("search", _("Im Ordner zeigen")) self.open_btn.clicked.connect(self.open) h.addWidget(self.cancel_btn) h.addWidget(self.open_btn) @@ -1403,7 +1405,7 @@ def __init__(self, keep=30, parent=None): self.box = QVBoxLayout(self) self.box.setContentsMargins(0, 0, 0, 0) self.box.setSpacing(0) - self.placeholder = label("Noch keine Übertragungen.", "secondary", center=True) + self.placeholder = label(_("Noch keine Übertragungen."), "secondary", center=True) self.placeholder.setContentsMargins(0, 16, 0, 16) self.box.addWidget(self.placeholder) diff --git a/host/tests/test_gui.py b/host/tests/test_gui.py index ff50c77..d1d359f 100644 --- a/host/tests/test_gui.py +++ b/host/tests/test_gui.py @@ -59,6 +59,7 @@ class GuiTest(unittest.TestCase): @classmethod def setUpClass(cls): cls.app = QApplication.instance() or QApplication([]) + os.environ["ESPDROP_LANG"] = "de" # the texts below are the German ones def setUp(self): from espdrop import gui @@ -84,7 +85,7 @@ def pump(self, cond, secs=15.0): time.sleep(0.02) return cond() - def window(self, fake, background=False): + def window(self, fake, background=False, wait_text="ESP verbunden"): from espdrop.session import Session def factory(w): @@ -95,7 +96,7 @@ def factory(w): ask_timeout=20.0, tray=False, background=background) if not background: self.w.show() - self.assertTrue(self.pump(lambda: self.w.device_label.text().startswith("ESP verbunden")), + self.assertTrue(self.pump(lambda: self.w.device_label.text().startswith(wait_text)), self.w.device_label.text()) return self.w @@ -446,6 +447,25 @@ def flasher(fw, port, on_progress, on_log): self.assertTrue(d.close_btn.isEnabled()) d.close() + def test_english(self): + from espdrop import i18n + self.settings.setValue("language", "en") + try: + w = self.window(DuplexBridgeDevice(), wait_text="ESP connected") + self.assertEqual(w.search_btn.text(), "Search") + self.assertEqual(w.visibility.labels, ["Off", "10 minutes", "Everyone"]) + self.assertIn("Nobody can send you anything", w.receive_left.text()) + self.assertEqual(self.gui.what_text(["a.jpg", "b.heic"]), "2 photos") + self.assertEqual(self.gui.human(2_400_000), "2.4 MB") + # back to German: shown after a restart, which the button offers + w.language.select(1) + self.assertEqual(self.settings.value("language"), "de") + self.assertFalse(w.restart_btn.isHidden()) + w.language.select(2) + self.assertTrue(w.restart_btn.isHidden()) + finally: + i18n.set_language("de") + def test_built_text(self): b = self.gui.built_text self.assertEqual(b("Oct 31 2026 12:34:56"), "31.10.2026, 12:34") diff --git a/host/tests/test_i18n.py b/host/tests/test_i18n.py new file mode 100644 index 0000000..0a29e17 --- /dev/null +++ b/host/tests/test_i18n.py @@ -0,0 +1,93 @@ +# EspDrop tests - the app in German and English (i18n.py): every text the app +# shows goes through _() and has an English version; German and English both +# render the main window. +# +# ~/opendrop-venv/bin/python -m unittest discover -s host/tests +# +# SPDX-License-Identifier: GPL-3.0-or-later +# Copyright (C) 2026 EspDrop contributors + +import ast +import os +import re +import sys +import unittest + +HERE = os.path.dirname(os.path.abspath(__file__)) +sys.path.insert(0, os.path.join(HERE, "..")) + +from espdrop import i18n # noqa: E402 + +PKG = os.path.join(HERE, "..", "espdrop") +# The modules whose texts reach the user (the command line is English). +MODULES = ["gui.py", "ui.py", "flash.py", "integration.py"] +GERMAN = re.compile(r"[äöüÄÖÜß„“]|\b(der|die|das|und|nicht|ein|eine|mit|für|ist|wird|" + r"Datei|Dateien|Gerät|Geräte|Senden|Empfangen|Suchen|Abbrechen|Fertig|" + r"Kein|Keine|Beenden|Allgemein|Installieren|Aktualisieren|Erneut|Warten|" + r"Gesendet|Abgelehnt|Funk|Hell|Dunkel|Automatisch|Mehr|Protokoll|Aus|" + r"Minuten|Anschluss|Sprache)\b") +# German-looking strings that are not shown (file names, test data, keys). +NOT_SHOWN = {"prüfung-ä.txt"} +# Words that are the same in both languages (keys of EN for completeness). +SAME_IN_BOTH = {"AirDrop", "Name", "ESP {mac}"} + + +def strings(path): + """(text, line, how) for every string constant: how is "_", "N_" or None.""" + with open(path, encoding="utf-8") as f: + tree = ast.parse(f.read()) + marked = {} + docs = set() + for node in ast.walk(tree): + if (isinstance(node, ast.Call) and isinstance(node.func, ast.Name) + and node.func.id in ("_", "N_") and node.args): + arg = node.args[0] + if isinstance(arg, ast.Constant): # _(TABLE[key]): the key is not a text + marked[id(arg)] = node.func.id + if (isinstance(node, (ast.FunctionDef, ast.ClassDef, ast.Module)) and node.body + and isinstance(node.body[0], ast.Expr) + and isinstance(node.body[0].value, ast.Constant)): + docs.add(id(node.body[0].value)) + for node in ast.walk(tree): + if isinstance(node, ast.Constant) and isinstance(node.value, str) and id(node) not in docs: + yield node.value, node.lineno, marked.get(id(node)) + + +class TranslationTest(unittest.TestCase): + def test_every_shown_text_is_translated(self): + missing, untranslated = [], [] + for name in MODULES: + for text, line, how in strings(os.path.join(PKG, name)): + if how: + if text not in i18n.EN: + missing.append(f"{name}:{line}: {text!r}") + elif (GERMAN.search(text) or text in i18n.EN) and text not in NOT_SHOWN \ + and text not in SAME_IN_BOTH: + untranslated.append(f"{name}:{line}: {text!r}") + self.assertEqual(untranslated, [], "German text shown without _():\n" + "\n".join(untranslated)) + self.assertEqual(missing, [], "_() text without an English version") + + def test_placeholders_match(self): + for de, en in i18n.EN.items(): + self.assertEqual(sorted(re.findall(r"\{(\w+)\}", de)), + sorted(re.findall(r"\{(\w+)\}", en)), de) + + def test_language_choice(self): + old = i18n.LANG + try: + self.assertEqual(i18n.set_language("en"), "en") + self.assertEqual(i18n._("Abbrechen"), "Cancel") + self.assertEqual(i18n._("{n} Fotos").format(n=3), "3 photos") + self.assertEqual(i18n.set_language("de"), "de") + self.assertEqual(i18n._("Abbrechen"), "Abbrechen") + from unittest import mock + with mock.patch.dict(os.environ, {"ESPDROP_LANG": "en"}): + self.assertEqual(i18n.set_language("auto"), "en") + with mock.patch.dict(os.environ, {"ESPDROP_LANG": "de"}): + self.assertEqual(i18n.set_language("auto"), "de") + finally: + i18n.LANG = old + + +if __name__ == "__main__": + unittest.main() From 0c83375d9f1ae332e979261d395eee889bd8a26d Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 1 Nov 2026 10:45:46 +0000 Subject: [PATCH 98/99] The program installs itself; the repository README - espdrop/install.py: the single-file program, started from anywhere else (the download), copies itself to %LOCALAPPDATA%\Programs\EspDrop (with a Start menu entry and an entry in "Installed apps" whose Uninstall runs `EspDrop uninstall`) or ~/.local/bin/espdrop, starts from there and removes the download when it was in the Downloads folder. A running copy (an older version) is asked to quit first, so starting a newer download updates. Not for the folder build, test runs or ESPDROP_PORTABLE=1. - Uninstall from the app's menu, from "Installed apps" or with `EspDrop uninstall`: program, desktop entries, settings and unpacked copies go; received files stay. On Windows the running file is deleted by a detached cmd once the app has quit. - The compiled program on Windows no longer sets its own app id, so its Start menu entry and taskbar pins belong to the running window. - README.md: what EspDrop is, what one needs, install, use, building, credits and license. docs/RELEASE.md in English (with a German paragraph) for the release page. 109 host tests pass. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01XzgaN4Bv1m8Ka9iqo7QVUN --- README.md | 85 ++++++++++++++- docs/RELEASE.md | 73 +++++-------- docs/SPIKES.md | 32 +++--- host/README.md | 10 ++ host/espdrop/cli.py | 9 +- host/espdrop/frozen.py | 6 ++ host/espdrop/gui.py | 123 ++++++++++++++++++++-- host/espdrop/i18n.py | 14 +++ host/espdrop/install.py | 184 +++++++++++++++++++++++++++++++++ host/espdrop/integration.py | 78 +++++++++++++- host/tests/test_gui.py | 9 ++ host/tests/test_install.py | 154 +++++++++++++++++++++++++++ host/tests/test_integration.py | 1 + 13 files changed, 706 insertions(+), 72 deletions(-) create mode 100644 host/espdrop/install.py create mode 100644 host/tests/test_install.py diff --git a/README.md b/README.md index 7ada3cb..c463c16 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,85 @@ # EspDrop -A AirDrop implementation for Windows and Linux using an ESP32 as a cheap Wifi Adapter + +**AirDrop for Windows and Linux**, with an ESP32-C5 board on USB as the +radio. Send files from your PC to an iPhone, iPad or Mac, and receive from +them, the same way Apple devices do it among themselves: no app on the iPhone, +no cloud, no cable to the phone. + +A PC's own Wi-Fi card cannot speak AWDL, the Wi-Fi link AirDrop runs on, and +Bluetooth alone is not enough either. EspDrop moves that part onto a small, +cheap ESP32-C5 board: its firmware runs AWDL and the Bluetooth signal that +wakes Apple devices, plus a small IPv6 stack; the desktop app does AirDrop +itself on top (based on OpenDrop) and talks to the board over its USB port. + +## What you need + +- An **ESP32-C5** board with USB, e.g. the Waveshare ESP32-C5-Zero (the one + EspDrop is tested with). The app puts the firmware on it, over the same USB + cable, no buttons or tools needed. +- A PC with **Windows 10 or 11** (64 bit) or **Linux** on a 64-bit PC (glibc + 2.35 or newer: Ubuntu 22.04+, Debian 12+, Fedora 36+, Linux Mint 21+, Arch, + …). No administrator rights, driver or firewall rule. +- On the iPhone/iPad: AirDrop set to **Everyone**, unlocked. + +## Install + +1. Download the program for your system from + [Releases](https://github.com/AntiWireless/EspDrop/releases): + `EspDrop-…-windows-x64.exe` or `EspDrop-…-linux-x86_64`. +2. Start it (Linux: `chmod +x` it first). It installs itself – Windows into + `%LOCALAPPDATA%\Programs\EspDrop` with a Start menu entry and an entry under + *Installed apps*, Linux into `~/.local/bin` with an entry in the + applications menu – and removes the download from the Downloads folder. + Windows asks once because the program is not signed: *More info* → *Run + anyway*. +3. Plug in the ESP32-C5. Under *General → ESP firmware* the app offers to + install its firmware; that takes about a minute. + +Starting a newer download later updates the installed program. *Uninstall* +in the app's *…* menu (or under *Installed apps* on Windows) removes it +again; received files stay. + +On Linux the user needs access to the board's serial port: `sudo usermod +-aG dialout $USER` (Arch: `uucp`), then log out and in again. + +## Use + +- **Receive:** set *Receiving from* to *10 minutes* or *Everyone*; on the + iPhone, share something and pick your PC. Every transfer asks first; files + land in Downloads (optionally also on the clipboard). +- **Send:** *Search*, then drop files on a device or click it – or right-click + files: *Send with EspDrop* in Explorer, *Open with → AirDrop (EspDrop)* in a + Linux file manager. +- The app starts with your login and waits quietly in the notification area; + the ESP's radio sleeps while nothing needs it and wakes when an Apple device + nearby wants to share. +- English or German (*General → Language*), light or dark. + +Folders cannot be sent yet, only files; Apple's *Contacts Only* mode is not +supported (it needs an Apple ID). + +## Build it yourself + +| part | where | how | +|---|---|---| +| firmware (ESP-IDF 6) | [`firmware/`](firmware/README.md) | `idf.py` builds, `tools/firmware_image.py` makes the image | +| desktop app (Python, PySide6, patched OpenDrop) | [`host/`](host/README.md) | `tools/opendrop_install.py`, then `tools/espdrop.py gui` | +| the single-file programs | [`tools/build_app.py`](tools/build_app.py) | Nuitka (Python → C) | + +GitHub Actions ([`.github/workflows/build.yml`](.github/workflows/build.yml)) +builds the firmware, both programs and runs the tests on every push; a tag +`v*` drafts a release. The project's notes, experiments and test plans (in +German) are in [`docs/`](docs/). + +## Credits and license + +EspDrop builds on the reverse engineering of AWDL and AirDrop by the +[Open Wireless Link](https://owlink.org) project of TU Darmstadt's Secure +Mobile Networking Lab: [OWL](https://github.com/seemoo-lab/owl) (AWDL) and +[OpenDrop](https://github.com/seemoo-lab/opendrop) (AirDrop), and on the +patches in [jedbillyb/airdrop-mt7921](https://github.com/jedbillyb/airdrop-mt7921) +that make OpenDrop work with current iOS. + +GPL-3.0-or-later, see [LICENSE](LICENSE). EspDrop is an interoperability +project and is not affiliated with or endorsed by Apple. AirDrop, iPhone, +iPad and Mac are trademarks of Apple Inc. diff --git a/docs/RELEASE.md b/docs/RELEASE.md index 8d958de..ff33a90 100644 --- a/docs/RELEASE.md +++ b/docs/RELEASE.md @@ -1,58 +1,33 @@ -AirDrop zwischen iPhone, iPad, Mac und einem Windows- oder Linux-PC, mit einem -ESP32-C5 als AirDrop-Funkmodul am USB-Anschluss. +AirDrop between iPhone, iPad, Mac and a Windows or Linux PC, with an +ESP32-C5 board on USB as the AirDrop radio. See the +[README](https://github.com/AntiWireless/EspDrop#readme) for what you need. -## Herunterladen +## Downloads -| Datei | Für | +| file | for | |---|---| -| `EspDrop-…-windows-x64.exe` | Windows 10 und 11, 64 Bit | -| `EspDrop-…-linux-x86_64` | Linux, 64-Bit-PC (siehe unten) | -| `espdrop-esp32c5.bin` | die Firmware für den ESP32-C5 einzeln; sie steckt auch im Programm | -| `THIRD_PARTY-….txt` | was im Programm steckt, mit den Lizenzen | +| `EspDrop-…-windows-x64.exe` | Windows 10 and 11, 64 bit | +| `EspDrop-…-linux-x86_64` | Linux on a 64-bit PC with glibc 2.35 or newer (Ubuntu 22.04+, Debian 12+, Fedora 36+, Linux Mint 21+, Arch, …) | +| `espdrop-esp32c5.bin` | the ESP32-C5 firmware on its own (the program has it inside) | +| `THIRD_PARTY-….txt` | what the programs contain, with their licenses | -Das Programm ist eine einzige Datei und braucht weder Python noch eine -Installation. Beim ersten Start entpackt es sich einmal (einige Sekunden), -danach startet es sofort. +## Install -## Windows +Start the program (Linux: `chmod +x` first). It installs itself – Windows: +`%LOCALAPPDATA%\Programs\EspDrop`, Start menu, *Installed apps*; Linux: +`~/.local/bin/espdrop` and the applications menu – and removes the download +from the Downloads folder. Windows asks once because the program is not +signed: *More info* → *Run anyway*. Starting a newer version later updates it. -1. `EspDrop-…-windows-x64.exe` an einen festen Ort legen, z. B. - `%LOCALAPPDATA%\Programs\EspDrop`, und doppelklicken. Autostart und das - Rechtsklickmenü zeigen auf diesen Ort; nicht im Download-Ordner lassen. -2. Windows fragt einmal, weil das Programm nicht signiert ist: „Weitere - Informationen“ → „Trotzdem ausführen“. +Plug in the ESP32-C5; *General → ESP firmware* installs the firmware over the +USB cable. On Linux the user needs the group `dialout` (Arch: `uucp`). -## Linux +The app is in English and German (*General → Language*). -Läuft auf 64-Bit-PCs (x86_64, kein ARM/Raspberry Pi) mit glibc 2.35 oder -neuer (`ldd --version` zeigt sie): Ubuntu 22.04 LTS und neuer samt Kubuntu, -Xubuntu, Linux Mint 21+, Pop!_OS 22.04+, Zorin OS 17+, elementary OS 7+; -Debian 12 und neuer; Fedora 36 und neuer; Arch, Manjaro, EndeavourOS, openSUSE -Tumbleweed. Nicht: Ubuntu 20.04, Linux Mint 20, Debian 11, RHEL/Alma/Rocky 9, -openSUSE Leap 15. X11 und Wayland gehen beide. +--- -```sh -chmod +x EspDrop-*-linux-x86_64 # Downloads verlieren das Ausführrecht -./EspDrop-*-linux-x86_64 -``` - -Für den ESP braucht der Benutzer die Gruppe `dialout` (Arch: `uucp`): -`sudo usermod -aG dialout $USER`, danach ab- und anmelden. Das Symbol im -Infobereich braucht einen Tray (KDE, Xfce, Cinnamon, Ubuntus GNOME; reines GNOME -nur mit der Erweiterung „AppIndicator“), sonst bleibt das Fenster offen. - -## Der ESP32-C5 - -Den ESP32-C5 per USB anstecken und die App starten. Hat er noch nicht die -passende Firmware, bietet die App unter Allgemein → „ESP-Firmware“ das -Installieren an (über dasselbe USB-Kabel, ohne Tasten am ESP). - -## Benutzen - -- **Empfangen:** „Empfangen von: 10 Minuten“ oder „Für alle“; am iPhone teilen - und den PC wählen. Dateien landen in Downloads. -- **Senden:** „Suchen“, Datei auf das Gerät ziehen, oder Rechtsklick auf eine - Datei → „Mit EspDrop senden“. Am iPhone: AirDrop „Für alle“, entsperrt. - -Quellcode und ausführliche Doku: dieses Repository (`host/README.md`, -`docs/SPIKES.md`). EspDrop steht unter der GPL-3.0. +**Deutsch:** AirDrop zwischen iPhone, iPad, Mac und Windows- oder Linux-PC +über einen ESP32-C5 am USB-Anschluss. Programm herunterladen und starten: es +installiert sich selbst (Startmenü bzw. Anwendungsmenü) und entfernt den +Download. Den ESP anstecken, unter *Allgemein → ESP-Firmware* die Firmware +aufspielen. Die Sprache stellt man unter *Allgemein → Sprache* ein. diff --git a/docs/SPIKES.md b/docs/SPIKES.md index dd251b7..ed665e2 100644 --- a/docs/SPIKES.md +++ b/docs/SPIKES.md @@ -731,15 +731,23 @@ Unter Linux bringt die App jetzt auch einen Eintrag im Anwendungsmenü mit (`~/.local/share/applications/espdrop.desktop`, mit Symbol); die Fenster sind ihm zugeordnet, so zeigen Dock und Taskleiste auch unter Wayland das Symbol. +Sprache und Entfernen (beide Systeme): Allgemein → „Sprache“ auf „English“, +„Jetzt neu starten“: die App kommt englisch wieder. „…“ → „Deinstallieren …“ +(bzw. unter Windows Installierte Apps → EspDrop → Deinstallieren) entfernt +Programm, Einträge und Einstellungen; empfangene Dateien bleiben. + Test unter Windows: -1. `EspDrop-0.1.0-windows-x64.exe` (eine einzige Datei) an einen festen Ort - legen, z. B. `%LOCALAPPDATA%\Programs\EspDrop`, und starten; der erste Start - entpackt sie einmal in den Cache (einige Sekunden). (SmartScreen: - „Weitere Informationen“ → „Trotzdem ausführen“, das Programm ist nicht - signiert). Vorher die laufende App aus dem Checkout beenden. -2. Das Fenster erscheint mit Symbol in der Taskleiste; Autostart und - Rechtsklickmenü zeigen jetzt auf `EspDrop.exe` (Rechtsklick auf eine Datei → - „Mit EspDrop senden“ öffnet das AirDrop-Blatt). +1. `EspDrop-0.1.0-windows-x64.exe` aus dem Download-Ordner starten + (SmartScreen: „Weitere Informationen“ → „Trotzdem ausführen“, das Programm + ist nicht signiert). Es installiert sich nach + `%LOCALAPPDATA%\Programs\EspDrop\EspDrop.exe`, startet von dort (der erste + Start entpackt es einmal in den Cache, einige Sekunden) und löscht die Datei + im Download-Ordner. Eine laufende ältere EspDrop-App wird dabei beendet und + ersetzt. +2. Startmenü: „EspDrop“ mit Symbol. Einstellungen → Apps → Installierte Apps: + „EspDrop“ mit Version. Autostart und Rechtsklickmenü zeigen auf die + installierte `EspDrop.exe` (Rechtsklick auf eine Datei → „Mit EspDrop + senden“ öffnet das AirDrop-Blatt). 3. Allgemein → „ESP-Firmware“ → „Aktualisieren …“: die Firmware aus dem Programm aufspielen (Abschnitt oben), danach senden und empfangen. 4. In einer Eingabeaufforderung im Ordner: @@ -759,11 +767,11 @@ X11-Bibliotheken) bringt das Programm mit. Test unter Linux: 1. `chmod +x EspDrop-0.1.0-linux-x86_64` (Downloads verlieren das - Ausführrecht), dann starten; der erste Start entpackt sich einmal nach - `~/.cache/EspDrop/app`. Zugriff auf `/dev/ttyACM0` über die Gruppe `dialout` - wie bisher. + Ausführrecht), dann starten: es installiert sich nach `~/.local/bin/espdrop` + und löscht den Download. Zugriff auf `/dev/ttyACM0` über die Gruppe + `dialout` wie bisher. 2. Im Anwendungsmenü steht danach „EspDrop“ mit Symbol; Autostart und - „Öffnen mit → AirDrop (EspDrop)“ zeigen auf das Programm. + „Öffnen mit → AirDrop (EspDrop)“ zeigen auf `~/.local/bin/espdrop`. 3. Firmware aktualisieren, senden, empfangen wie unter Windows. ## Danach diff --git a/host/README.md b/host/README.md index dd4bd67..e906f83 100644 --- a/host/README.md +++ b/host/README.md @@ -55,6 +55,16 @@ Linux), the icon is drawn in code. with the sleep mode, docs/SPIKES.md); the line under the title says which. - Closing the window keeps it in the tray; "Beenden" (the "…" button or the tray) ends it. Unplugging the ESP is fine: it reconnects when it comes back. +- **Allgemein → "Sprache": Automatisch / Deutsch / English** (`espdrop/i18n.py`: + the texts are German in the code, `_()` gives them in the language chosen; + Automatisch follows the system; a change shows after "Jetzt neu starten"). +- **Installed like a program** (`espdrop/install.py`): the single-file build, + started from anywhere else, copies itself to `%LOCALAPPDATA%\Programs\EspDrop` + (Start menu, "Installed apps" with Uninstall) or `~/.local/bin/espdrop`, + starts from there, and removes the download from Downloads; a running older + copy quits first (that is how one updates). "…" → "Deinstallieren …" or + `EspDrop uninstall` removes it all; received files stay. Not for the folder + build, test runs (`ESPDROP_NO_DESKTOP`) or `ESPDROP_PORTABLE=1`. - **Allgemein → "Erscheinungsbild": Automatisch / Hell / Dunkel.** Automatisch follows the system; the choice also sets the title bar on Windows (Qt 6.8+). - **Allgemein → "Beim Anmelden im Hintergrund starten"** (Windows: diff --git a/host/espdrop/cli.py b/host/espdrop/cli.py index 99b68c9..dd42a48 100644 --- a/host/espdrop/cli.py +++ b/host/espdrop/cli.py @@ -391,7 +391,10 @@ def cmd_gui(args, transport=None): except ImportError as e: raise ImportError(f"the GUI needs PySide6 ({e}): pip install PySide6 into the venv, " f"or run tools/opendrop_install.py again") from e - return gui.main(port=args.port, background=args.background, send=args.send) + if args.action == "uninstall": + return gui.uninstall_main() + return gui.main(port=args.port, background=args.background, send=args.send, + installed_from=args.installed_from) def main(argv=None, transport=None): @@ -447,6 +450,8 @@ def main(argv=None, transport=None): help="tray only, receiving until switched off (what autostart runs)") g.add_argument("--send", nargs="*", metavar="FILE", help="ask whom to send these files to (Explorer's \"Senden an\")") + g.add_argument("--installed-from", help=argparse.SUPPRESS) # install.py: the download + sub.add_parser("uninstall", help="remove the installed program (asks first)") if argv is None: argv = sys.argv[1:] from .frozen import clean_old_unpacked, compiled @@ -465,7 +470,7 @@ def main(argv=None, transport=None): logging.getLogger("opendrop").setLevel(logging.WARNING) handlers = {"find": cmd_find, "send": cmd_send, "receive": cmd_receive, "status": cmd_status, "cmd": cmd_cmd, "check": cmd_check, - "flash": cmd_flash, "gui": cmd_gui} + "flash": cmd_flash, "gui": cmd_gui, "uninstall": cmd_gui} try: return handlers[args.action](args, transport) except ImportError as e: # OpenDrop or libarchive (env.py says what to do) diff --git a/host/espdrop/frozen.py b/host/espdrop/frozen.py index d5bdb17..a8fa81a 100644 --- a/host/espdrop/frozen.py +++ b/host/espdrop/frozen.py @@ -19,6 +19,12 @@ def compiled(): return "__compiled__" in globals() or bool(getattr(sys, "frozen", False)) +def onefile(): + """True in the single-file build (it unpacks itself; tools/build_app.py).""" + info = globals().get("__compiled__") + return bool(info is not None and getattr(info, "onefile", False)) + + def program(): """The program file to start again (autostart, the file manager): the onefile binary itself rather than its unpacked copy. None from Python.""" diff --git a/host/espdrop/gui.py b/host/espdrop/gui.py index 3ef7b1e..e31620b 100644 --- a/host/espdrop/gui.py +++ b/host/espdrop/gui.py @@ -40,7 +40,7 @@ QLineEdit, QMainWindow, QMenu, QMessageBox, QPlainTextEdit, QScrollArea, QSystemTrayIcon, QToolButton, QVBoxLayout, QWidget) -from . import env, flash, i18n, integration, ui +from . import env, flash, frozen, i18n, install, integration, ui from .i18n import N_, _ from .session import Session from .version import __version__ @@ -123,6 +123,25 @@ def instance_name(): return f"espdrop-{user}" +def instance_running(name=None): + """A copy of the app runs for this user.""" + sock = QLocalSocket() + sock.connectToServer(name or instance_name()) + ok = sock.waitForConnected(300) + sock.abort() + return ok + + +def quit_running(wait=20.0, name=None): + """Ask a running copy to quit and wait for it to go; False if it stays.""" + if not send_to_running({"quit": True}, name=name): + return True + end = time.monotonic() + wait + while instance_running(name) and time.monotonic() < end: + time.sleep(0.25) + return not instance_running(name) + + def send_to_running(message, name=None, timeout_ms=1000): """Hand `message` ({"files": [...], "show": bool}) to the running copy. False when there is none (then this process becomes it).""" @@ -853,6 +872,8 @@ def _build(self): self.log_action.setCheckable(True) menu.addAction(_("ESP-Firmware …"), self.open_firmware_dialog) menu.addAction(_("Serieller Port …"), self._pick_port) + if install.installed(): + menu.addAction(_("Deinstallieren …"), self.uninstall) menu.addSeparator() menu.addAction(_("Beenden"), self.quit) self.more_btn.setMenu(menu) @@ -1058,6 +1079,9 @@ def _integrate(self, fn, on, switch): def handle_message(self, msg): """From a later start (InstanceServer): files to send, or show.""" + if msg.get("quit"): # a newer copy installs itself + self.quit() + return files = msg.get("files") or [] if files or msg.get("send"): self.open_send_dialog(files) @@ -1312,6 +1336,23 @@ def _language_picked(self, index): self.language_sub.setVisible(wanted != now) self.restart_btn.setVisible(wanted != now) + def installed(self, download): + """Just installed from `download`: say so, and remove the download + once it has quit (only from the Downloads folder).""" + where = (_("EspDrop ist installiert und im Startmenü.") if os.name == "nt" + else _("EspDrop ist installiert und im Anwendungsmenü.")) + self._notify(where, None, 6000) + self._log(where) + run_bg(install.remove_download, download, default_download_dir(), on_error=self._log) + + def uninstall(self): + if not confirm_uninstall(self): + return + errors = install.uninstall(frozen.program()) + self.settings.clear() + uninstalled_message(self, errors) + self.quit() + def restart(self): """Quit, and have main() start the app again (the new language).""" if not self._flashing: # quit() says why it waits @@ -1560,8 +1601,9 @@ def default_download_dir(): def _set_app_id(): """Windows groups windows by app id; without our own the taskbar shows them - as pythonw.exe with Python's icon instead of ours.""" - if os.name == "nt": + as pythonw.exe with Python's icon instead of ours. The compiled program is + its own app (and its start menu entry and pins belong to it as it is).""" + if os.name == "nt" and not frozen.compiled(): try: import ctypes ctypes.windll.shell32.SetCurrentProcessExplicitAppUserModelID("EspDrop.EspDrop") @@ -1586,8 +1628,69 @@ def _desktop_setup(settings): run_bg(integration.refresh) -def main(port=None, background=False, send=None): - """send: None (normal start) or the files of --send (may be empty).""" +def install_and_restart(background, send): + """The downloaded single file: install it (an older copy that runs quits + first) and start the installed one. Returns the exit code, or None to go + on from here when installing failed.""" + src = frozen.program() + if not quit_running(): + QMessageBox.warning(None, APP, _("Eine laufende EspDrop-App ließ sich nicht beenden. " + "Bitte beenden und erneut starten.")) + return 1 + try: + dst = install.install(src) + except OSError as e: + QMessageBox.warning(None, APP, _("EspDrop ließ sich nicht installieren ({error}); es " + "läuft jetzt von hier.").format(error=e)) + return None + args = ["gui"] + (["--background"] if background else []) + if send is not None: + args += ["--send", *send] + install.start(dst, args + ["--installed-from", src]) + return 0 + + +def uninstall_main(): + """`EspDrop uninstall` ("Installed apps" -> Uninstall): ask, then remove.""" + app = QApplication.instance() or QApplication(sys.argv) + app.setWindowIcon(ui.app_icon()) + ui.apply(app) + settings = QSettings(APP, APP) + i18n.set_language(settings.value("language", "auto")) + if not confirm_uninstall(None): + return 1 + if not quit_running(): + QMessageBox.warning(None, APP, _("Eine laufende EspDrop-App ließ sich nicht beenden. " + "Bitte beenden und erneut starten.")) + return 1 + errors = install.uninstall(frozen.program()) + settings.clear() + uninstalled_message(None, errors) + return 0 + + +def confirm_uninstall(parent): + box = QMessageBox(parent) + box.setWindowTitle(APP) + box.setIcon(QMessageBox.Icon.Question) + box.setText(_("EspDrop von diesem PC entfernen? Empfangene Dateien bleiben erhalten.")) + remove = box.addButton(_("Entfernen"), QMessageBox.ButtonRole.DestructiveRole) + box.addButton(_("Abbrechen"), QMessageBox.ButtonRole.RejectRole) + box.exec() + return box.clickedButton() is remove + + +def uninstalled_message(parent, errors): + text = _("EspDrop ist entfernt.") + if errors: + text += "\n\n" + _("Einiges ließ sich nicht entfernen: {errors}").format( + errors="; ".join(errors)) + QMessageBox.information(parent, APP, text) + + +def main(port=None, background=False, send=None, installed_from=None): + """send: None (normal start) or the files of --send (may be empty). + installed_from: the download this copy was installed from (it goes).""" _set_app_id() app = QApplication.instance() or QApplication(sys.argv) app.setApplicationName(APP) @@ -1597,17 +1700,23 @@ def main(port=None, background=False, send=None): app.setQuitOnLastWindowClosed(False) app.setWindowIcon(ui.app_icon()) ui.apply(app) + settings = QSettings(APP, APP) + i18n.set_language(settings.value("language", "auto")) # before any text is made + if install.wanted(): # the download: install, start from there + code = install_and_restart(background, send) + if code is not None: + return code files = [os.path.abspath(f) for f in (send or [])] # Already running (autostart, an earlier start)? Hand over and leave. if send_to_running({"files": files, "send": send is not None, "show": send is None and not background}): return 0 server = InstanceServer() - settings = QSettings(APP, APP) - i18n.set_language(settings.value("language", "auto")) # before any text is made _desktop_setup(settings) w = MainWindow(port=port, settings=settings, background=background) server.message.connect(w.handle_message) + if installed_from: + w.installed(installed_from) if send is not None: w.open_send_dialog(files) elif not background or not w.tray: # no tray to live in: show the window diff --git a/host/espdrop/i18n.py b/host/espdrop/i18n.py index 5d17bf0..75194b7 100644 --- a/host/espdrop/i18n.py +++ b/host/espdrop/i18n.py @@ -284,6 +284,20 @@ def _(text): "gedrückt halten, wieder anstecken, loslassen und erneut versuchen.": "The ESP could not be switched into its bootloader. Unplug it, hold the BOOT " "button, plug it in again, let go and try again.", + # ---- installing (install.py) --------------------------------------------- + "Eine laufende EspDrop-App ließ sich nicht beenden. Bitte beenden und erneut starten.": + "A running EspDrop app could not be closed. Please quit it and start again.", + "EspDrop ließ sich nicht installieren ({error}); es läuft jetzt von hier.": + "EspDrop could not be installed ({error}); it runs from here now.", + "EspDrop ist installiert und im Startmenü.": "EspDrop is installed and in the Start menu.", + "EspDrop ist installiert und im Anwendungsmenü.": + "EspDrop is installed and in the applications menu.", + "Deinstallieren …": "Uninstall …", + "EspDrop von diesem PC entfernen? Empfangene Dateien bleiben erhalten.": + "Remove EspDrop from this PC? Received files are kept.", + "Entfernen": "Remove", + "EspDrop ist entfernt.": "EspDrop has been removed.", + "Einiges ließ sich nicht entfernen: {errors}": "Some things could not be removed: {errors}", # ---- desktop entries (integration.py) -------------------------------------- "Mit EspDrop senden": "Send with EspDrop", "Per AirDrop senden (EspDrop)": "Send with AirDrop (EspDrop)", diff --git a/host/espdrop/install.py b/host/espdrop/install.py new file mode 100644 index 0000000..8f24584 --- /dev/null +++ b/host/espdrop/install.py @@ -0,0 +1,184 @@ +# EspDrop - the single-file program installs itself, like a real program. +# +# Started from anywhere else (the download), it copies itself to +# Windows %LOCALAPPDATA%\Programs\EspDrop\EspDrop.exe (per user, no admin) +# Linux ~/.local/bin/espdrop +# and starts from there. On Windows it also gets a start menu entry and an +# entry in "Installed apps" whose "Uninstall" removes it again. A copy that is +# running already (an older version) is asked to quit first, so starting a new +# download is how one updates. The downloaded file is removed afterwards when +# it was in the Downloads folder. +# +# Only the single-file build does this (the folder build stays where it is), +# never a test run (ESPDROP_NO_DESKTOP) or a copy started with +# ESPDROP_PORTABLE=1. +# +# Uninstalling (the app's menu, "Installed apps", `EspDrop uninstall`) removes +# the program, its entries, its settings and its unpacked copies; received +# files stay. +# +# SPDX-License-Identifier: GPL-3.0-or-later +# Copyright (C) 2026 EspDrop contributors + +import hashlib +import os +import shutil +import subprocess +import sys +import time + +from . import frozen, integration +from .version import __version__ + +IS_WINDOWS = os.name == "nt" +APP = "EspDrop" + + +def install_path(): + """Where the program lives once installed.""" + if IS_WINDOWS: + base = os.environ.get("LOCALAPPDATA") or os.path.expanduser(r"~\AppData\Local") + return os.path.join(base, "Programs", APP, APP + ".exe") + return os.path.join(os.path.expanduser("~"), ".local", "bin", "espdrop") + + +def _same(a, b): + try: + return os.path.samefile(a, b) + except OSError: + return False + + +def installed(): + """This program is the installed one.""" + p = frozen.program() + return bool(p) and _same(p, install_path()) + + +def wanted(): + """Whether this start should install the program first.""" + if not frozen.onefile(): + return False + if os.environ.get("ESPDROP_NO_DESKTOP") or os.environ.get("ESPDROP_PORTABLE"): + return False + return not installed() + + +def _digest(path): + h = hashlib.sha256() + with open(path, "rb") as f: + for block in iter(lambda: f.read(1 << 20), b""): + h.update(block) + return h.digest() + + +def install(src, wait=20.0): + """Copy the program src to its place (replacing an older one, which may + take a moment to let go of its file after quitting) and register it. + Returns the installed path; raises OSError.""" + dst = install_path() + os.makedirs(os.path.dirname(dst), exist_ok=True) + if not (os.path.exists(dst) and _digest(dst) == _digest(src)): + tmp = dst + ".new" + shutil.copy2(src, tmp) + end = time.monotonic() + wait + while True: + try: + os.replace(tmp, dst) + break + except PermissionError: # Windows: the old one still runs + if time.monotonic() > end: + os.remove(tmp) + raise + time.sleep(0.5) + if not IS_WINDOWS: + os.chmod(dst, 0o755) + integration.set_program(dst, __version__) + return dst + + +def in_downloads(path, downloads): + """path lies directly in the Downloads folder.""" + return bool(downloads) and _same(os.path.dirname(os.path.abspath(path)), downloads) + + +def remove_download(path, downloads, tries=20, pause=0.5): + """Remove the downloaded program once it has quit (Windows keeps the file + locked until then). Only from the Downloads folder; True when gone.""" + if not in_downloads(path, downloads): + return False + for _attempt in range(tries): + try: + os.remove(path) + return True + except FileNotFoundError: + return True + except OSError: + time.sleep(pause) + return False + + +def unpack_folder(): + """The folder the single file unpacks its builds into, or None.""" + here = frozen.bundle_dir() + if not here: + return None + parent = os.path.dirname(here) + if (os.path.basename(os.path.dirname(parent)), os.path.basename(parent)) == \ + frozen.UNPACK_PARENT: + return parent + return None + + +def uninstall(exe=None): + """Remove the program, its desktop entries and its unpacked copies. + The settings are the caller's (QSettings). Returns what failed.""" + errors = integration.remove_all() + data = integration.data_dir() + shutil.rmtree(data, ignore_errors=True) + exe = exe or install_path() + unpacked = unpack_folder() + if IS_WINDOWS: + # A running program cannot remove its own file: a detached cmd does it + # once we have quit. + steps = [f'del /f /q "{exe}"', f'rmdir "{os.path.dirname(exe)}"'] + if unpacked: + steps.append(f'rmdir /s /q "{unpacked}"') + subprocess.Popen( + 'cmd /c "ping -n 4 127.0.0.1 >nul & ' + " & ".join(steps) + '"', + creationflags=getattr(subprocess, "DETACHED_PROCESS", 0) + | getattr(subprocess, "CREATE_NO_WINDOW", 0), close_fds=True) + else: + try: + if os.path.exists(exe): + os.remove(exe) # a running file can go on Linux + except OSError as e: + errors.append(str(e)) + if unpacked: + shutil.rmtree(unpacked, ignore_errors=True) + return errors + + +def start(path, args): + """Start the installed program with these arguments, detached.""" + subprocess.Popen([path, *args], close_fds=True, + creationflags=getattr(subprocess, "DETACHED_PROCESS", 0) + if IS_WINDOWS else 0, + start_new_session=not IS_WINDOWS) + + +def argv_without(argv, flag, with_value=True): + """argv without `flag` (and its value).""" + out, skip = [], False + for a in argv: + if skip: + skip = False + elif a == flag: + skip = with_value + else: + out.append(a) + return out + + +if __name__ == "__main__": # pragma: no cover - a manual check + print(install_path(), wanted(), installed(), file=sys.stderr) diff --git a/host/espdrop/integration.py b/host/espdrop/integration.py index 3ee3791..8488390 100644 --- a/host/espdrop/integration.py +++ b/host/espdrop/integration.py @@ -196,6 +196,79 @@ def set_launcher(): _refresh_desktop_db(os.path.dirname(path)) +# ---- the installed program (install.py) ----------------------------------------- + +UNINSTALL_KEY = r"Software\Microsoft\Windows\CurrentVersion\Uninstall\EspDrop" +START_MENU_NAME = "EspDrop.lnk" + + +def _start_menu_path(): + appdata = os.environ.get("APPDATA") or os.path.expanduser(r"~\AppData\Roaming") + return os.path.join(appdata, "Microsoft", "Windows", "Start Menu", "Programs", + START_MENU_NAME) + + +def set_program(exe, version, on=True): + """Windows: the start menu entry and the entry in "Installed apps" + (Apps & features), whose "Uninstall" runs `exe uninstall`. Per user, no + admin rights.""" + if not IS_WINDOWS: + return + lnk = _start_menu_path() + if not on: + if os.path.exists(lnk): + os.remove(lnk) + _reg_delete_tree(UNINSTALL_KEY) + return + _reg_set(UNINSTALL_KEY, "DisplayName", APP) + _reg_set(UNINSTALL_KEY, "DisplayVersion", version) + _reg_set(UNINSTALL_KEY, "Publisher", "EspDrop contributors") + _reg_set(UNINSTALL_KEY, "DisplayIcon", exe) + _reg_set(UNINSTALL_KEY, "InstallLocation", os.path.dirname(exe)) + _reg_set(UNINSTALL_KEY, "UninstallString", subprocess.list2cmdline([exe, "uninstall"])) + _reg_set(UNINSTALL_KEY, "URLInfoAbout", "https://github.com/AntiWireless/EspDrop") + _reg_set(UNINSTALL_KEY, "NoModify", 1) + _reg_set(UNINSTALL_KEY, "NoRepair", 1) + try: + _reg_set(UNINSTALL_KEY, "EstimatedSize", os.path.getsize(exe) // 1024) + except OSError: + pass + env = dict(os.environ, ESPDROP_LNK=lnk, ESPDROP_EXE=exe, + ESPDROP_DIR=os.path.dirname(exe), + ESPDROP_DESC=_("AirDrop mit iPhone, iPad und Mac über den ESP32-C5")) + script = ("$s = (New-Object -ComObject WScript.Shell).CreateShortcut($env:ESPDROP_LNK);" + "$s.TargetPath = $env:ESPDROP_EXE; $s.WorkingDirectory = $env:ESPDROP_DIR;" + "$s.IconLocation = $env:ESPDROP_EXE + ',0';" + "$s.Description = $env:ESPDROP_DESC; $s.Save()") + os.makedirs(os.path.dirname(lnk), exist_ok=True) + subprocess.run(["powershell", "-NoProfile", "-NonInteractive", "-Command", script], + env=env, check=True, capture_output=True, + creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0)) + + +def remove_all(): + """Every entry this app made on the desktop: autostart, the file manager + entry, the application menu, the start menu and "Installed apps". + Returns what failed.""" + errors = [] + for fn in (lambda: set_autostart(False), lambda: set_send_menu(False), + _remove_launcher, lambda: set_program(None, None, on=False)): + try: + fn() + except Exception as e: # noqa: BLE001 - go on with the rest + errors.append(str(e)) + return errors + + +def _remove_launcher(): + if IS_WINDOWS: + return + path = _xdg_launcher() + if os.path.exists(path): + os.remove(path) + _refresh_desktop_db(os.path.dirname(path)) + + def refresh(): """Keep enabled entries pointing at the app as it runs now: a checkout or venv that moved, a new icon. Rewrites only what differs (on Windows the @@ -250,7 +323,10 @@ def _reg_get(key, name): def _reg_set(key, name, value): with winreg.CreateKey(winreg.HKEY_CURRENT_USER, key) as k: - winreg.SetValueEx(k, name, 0, winreg.REG_SZ, value) + if isinstance(value, int): + winreg.SetValueEx(k, name, 0, winreg.REG_DWORD, value) + else: + winreg.SetValueEx(k, name, 0, winreg.REG_SZ, value) def _reg_delete_value(key, name): diff --git a/host/tests/test_gui.py b/host/tests/test_gui.py index d1d359f..9ef970c 100644 --- a/host/tests/test_gui.py +++ b/host/tests/test_gui.py @@ -14,6 +14,7 @@ import threading import time import unittest +from unittest import mock os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") HERE = os.path.dirname(os.path.abspath(__file__)) @@ -447,6 +448,14 @@ def flasher(fw, port, on_progress, on_log): self.assertTrue(d.close_btn.isEnabled()) d.close() + def test_a_newer_copy_asks_to_quit(self): + w = self.window(DuplexBridgeDevice()) + with mock.patch.object(self.gui.QApplication, "quit") as quit_app: + w.handle_message({"quit": True}) + self.assertTrue(w._quitting) + quit_app.assert_called_once() + self.w = None # already shut down + def test_english(self): from espdrop import i18n self.settings.setValue("language", "en") diff --git a/host/tests/test_install.py b/host/tests/test_install.py new file mode 100644 index 0000000..eed1534 --- /dev/null +++ b/host/tests/test_install.py @@ -0,0 +1,154 @@ +# EspDrop tests - the single-file program installs itself (install.py): the +# download is copied to the program's place, registered, started from there, +# removed from Downloads; uninstalling takes it all away again. +# +# ~/opendrop-venv/bin/python -m unittest discover -s host/tests +# +# SPDX-License-Identifier: GPL-3.0-or-later +# Copyright (C) 2026 EspDrop contributors + +import os +import subprocess +import sys +import tempfile +import unittest +from unittest import mock + +HERE = os.path.dirname(os.path.abspath(__file__)) +sys.path.insert(0, os.path.join(HERE, "..")) +sys.path.insert(0, HERE) + +from espdrop import install, integration # noqa: E402 +from test_integration import FakeWinreg # noqa: E402 + + +class InstallCase(unittest.TestCase): + def setUp(self): + self.tmp = tempfile.TemporaryDirectory() + self.addCleanup(self.tmp.cleanup) + self.home = os.path.join(self.tmp.name, "home") + self.downloads = os.path.join(self.home, "Downloads") + os.makedirs(self.downloads) + self.download = os.path.join(self.downloads, "EspDrop-0.1.0-linux-x86_64") + with open(self.download, "wb") as f: + f.write(b"\x7fELF the program" * 100) + + def onefile(self, program): + return [mock.patch("espdrop.frozen.onefile", lambda: True), + mock.patch("espdrop.frozen.program", lambda: program)] + + def started(self, patches): + for p in patches: + p.start() + self.addCleanup(p.stop) + + +class LinuxInstallTest(InstallCase): + def setUp(self): + super().setUp() + self.started([mock.patch.object(install, "IS_WINDOWS", False), + mock.patch.object(integration, "IS_WINDOWS", False), + mock.patch.dict(os.environ, { + "HOME": self.home, + "XDG_CONFIG_HOME": os.path.join(self.home, ".config"), + "XDG_DATA_HOME": os.path.join(self.home, ".local", "share")}), + mock.patch("os.path.expanduser", + lambda p: p.replace("~", self.home, 1))]) + + def test_install_and_remove_the_download(self): + dst = install.install_path() + self.assertEqual(dst, os.path.join(self.home, ".local", "bin", "espdrop")) + self.started(self.onefile(self.download)) + with mock.patch.dict(os.environ, {"ESPDROP_NO_DESKTOP": ""}): + self.assertTrue(install.wanted()) + self.assertEqual(install.install(self.download), dst) + self.assertTrue(os.access(dst, os.X_OK)) + with open(dst, "rb") as f, open(self.download, "rb") as g: + self.assertEqual(f.read(), g.read()) + # the installed copy: no second install + with mock.patch("espdrop.frozen.program", lambda: dst): + self.assertTrue(install.installed()) + self.assertFalse(install.wanted()) + # the download goes - from Downloads only + elsewhere = os.path.join(self.tmp.name, "stick", "EspDrop") + os.makedirs(os.path.dirname(elsewhere)) + open(elsewhere, "wb").close() + self.assertFalse(install.remove_download(elsewhere, self.downloads)) + self.assertTrue(os.path.exists(elsewhere)) + self.assertTrue(install.remove_download(self.download, self.downloads, pause=0)) + self.assertFalse(os.path.exists(self.download)) + + def test_not_for_tests_portable_or_folder_builds(self): + self.started(self.onefile(self.download)) + for env in ({"ESPDROP_NO_DESKTOP": "1"}, {"ESPDROP_PORTABLE": "1"}): + with mock.patch.dict(os.environ, env): + self.assertFalse(install.wanted()) + with mock.patch("espdrop.frozen.onefile", lambda: False): + self.assertFalse(install.wanted()) + + def test_update_replaces_and_uninstall_removes(self): + dst = install.install(self.download) + newer = os.path.join(self.downloads, "EspDrop-0.2.0-linux-x86_64") + with open(newer, "wb") as f: + f.write(b"\x7fELF newer") + install.install(newer) + with open(dst, "rb") as f: + self.assertEqual(f.read(), b"\x7fELF newer") + with mock.patch("espdrop.frozen.program", lambda: dst): + integration.ICON = None + integration.set_launcher() + integration.set_autostart(True) + launcher = os.path.join(self.home, ".local", "share", "applications", "espdrop.desktop") + self.assertTrue(os.path.exists(launcher)) + self.assertEqual(install.uninstall(dst), []) + self.assertFalse(os.path.exists(dst)) + self.assertFalse(os.path.exists(launcher)) + self.assertFalse(integration.autostart_enabled()) + + def test_argv_without(self): + self.assertEqual(install.argv_without(["gui", "--installed-from", "/x", "--background"], + "--installed-from"), ["gui", "--background"]) + + +class WindowsInstallTest(InstallCase): + def setUp(self): + super().setUp() + self.reg = FakeWinreg() + self.runs = [] + + def run(argv, **kw): + self.runs.append((argv, kw)) + return subprocess.CompletedProcess(argv, 0, b"", b"") + self.started([mock.patch.object(install, "IS_WINDOWS", True), + mock.patch.object(integration, "IS_WINDOWS", True), + mock.patch.object(integration, "winreg", self.reg), + mock.patch.object(integration.subprocess, "run", run), + mock.patch.dict(os.environ, { + "LOCALAPPDATA": os.path.join(self.home, "AppData", "Local"), + "APPDATA": os.path.join(self.home, "AppData", "Roaming")})]) + + def test_program_in_installed_apps_and_start_menu(self): + dst = install.install(self.download) + self.assertEqual(dst, os.path.join(self.home, "AppData", "Local", "Programs", "EspDrop", + "EspDrop.exe")) + entry = self.reg.keys[integration.UNINSTALL_KEY] + self.assertEqual(entry["DisplayName"], "EspDrop") + self.assertEqual(entry["UninstallString"], subprocess.list2cmdline([dst, "uninstall"])) + self.assertEqual((entry["NoModify"], entry["NoRepair"]), (1, 1)) + (argv, kw), = self.runs # the start menu shortcut + self.assertTrue(kw["env"]["ESPDROP_LNK"].endswith( + os.path.join("Start Menu", "Programs", "EspDrop.lnk"))) + self.assertEqual(kw["env"]["ESPDROP_EXE"], dst) + # Uninstall: the entries go now, the running file after we quit. + spawned = [] + with mock.patch.object(install.subprocess, "Popen", + lambda cmd, **kw: spawned.append(cmd)): + self.assertEqual(install.uninstall(dst), []) + self.assertNotIn(integration.UNINSTALL_KEY, self.reg.keys) + (cmd,), = [spawned] + self.assertIn(f'del /f /q "{dst}"', cmd) + self.assertIn("ping -n 4", cmd) + + +if __name__ == "__main__": + unittest.main() diff --git a/host/tests/test_integration.py b/host/tests/test_integration.py index 0ecf54e..779298e 100644 --- a/host/tests/test_integration.py +++ b/host/tests/test_integration.py @@ -24,6 +24,7 @@ class FakeWinreg: """The bits of winreg integration.py uses, on a dict: key -> {name: value}.""" HKEY_CURRENT_USER = "HKCU" REG_SZ = 1 + REG_DWORD = 4 KEY_SET_VALUE = 2 def __init__(self): From a4459f83c0054249ed32579a16d41d00d4265881 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 1 Nov 2026 11:20:20 +0000 Subject: [PATCH 99/99] Windows: no WinError 6 when the app starts other programs Double-clicked, the program has no console and its standard handles are not valid ones; a child process that inherits them fails at once with "WinError 6: The handle is invalid". Installing hit that (the PowerShell call for the Start menu entry, starting the installed copy), and so would the language restart and uninstalling. Every child now gets DEVNULL for stdin, stdout and stderr instead. The CI smoke test missed it: it starts the program with valid handles and without installing. --smoke-install (CI only, it installs for real on Windows) now starts the single file from Downloads the way Explorer does (os.startfile: no console, no handles) and checks that it installs itself, removes the download and is in the Start menu and "Installed apps"; on Linux in a throwaway home folder. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01XzgaN4Bv1m8Ka9iqo7QVUN --- .github/workflows/build.yml | 2 +- host/espdrop/gui.py | 3 +- host/espdrop/install.py | 5 +-- host/espdrop/integration.py | 12 +++++-- tools/build_app.py | 72 +++++++++++++++++++++++++++++++++++++ 5 files changed, 88 insertions(+), 6 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index a0e7ae6..14f48fc 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -111,7 +111,7 @@ jobs: shell: bash run: | if [ "$RUNNER_OS" = Linux ]; then export PATH="$RUNNER_TEMP/venv/bin:$PATH"; fi - "$PY" tools/build_app.py --firmware build/firmware/espdrop-esp32c5.bin --smoke + "$PY" tools/build_app.py --firmware build/firmware/espdrop-esp32c5.bin --smoke --smoke-install - uses: actions/upload-artifact@v4 with: name: EspDrop-${{ matrix.name }} diff --git a/host/espdrop/gui.py b/host/espdrop/gui.py index e31620b..f9e1f24 100644 --- a/host/espdrop/gui.py +++ b/host/espdrop/gui.py @@ -1726,5 +1726,6 @@ def main(port=None, background=False, send=None, installed_from=None): if w.restart_requested: # the same app again, the window shown import subprocess subprocess.Popen(integration.launch_command(), close_fds=True, - creationflags=getattr(subprocess, "DETACHED_PROCESS", 0)) + creationflags=getattr(subprocess, "DETACHED_PROCESS", 0), + **integration.NO_STDIO) return rc diff --git a/host/espdrop/install.py b/host/espdrop/install.py index 8f24584..595db2d 100644 --- a/host/espdrop/install.py +++ b/host/espdrop/install.py @@ -147,7 +147,8 @@ def uninstall(exe=None): subprocess.Popen( 'cmd /c "ping -n 4 127.0.0.1 >nul & ' + " & ".join(steps) + '"', creationflags=getattr(subprocess, "DETACHED_PROCESS", 0) - | getattr(subprocess, "CREATE_NO_WINDOW", 0), close_fds=True) + | getattr(subprocess, "CREATE_NO_WINDOW", 0), close_fds=True, + **integration.NO_STDIO) else: try: if os.path.exists(exe): @@ -164,7 +165,7 @@ def start(path, args): subprocess.Popen([path, *args], close_fds=True, creationflags=getattr(subprocess, "DETACHED_PROCESS", 0) if IS_WINDOWS else 0, - start_new_session=not IS_WINDOWS) + start_new_session=not IS_WINDOWS, **integration.NO_STDIO) def argv_without(argv, flag, with_value=True): diff --git a/host/espdrop/integration.py b/host/espdrop/integration.py index 8488390..d353a60 100644 --- a/host/espdrop/integration.py +++ b/host/espdrop/integration.py @@ -42,6 +42,13 @@ MENU_TEXT = N_("Mit EspDrop senden") # in the language the app runs in +# The window has no console when started by a double-click (Windows): its +# standard handles are not valid ones, and a child that would inherit them +# fails with WinError 6 "The handle is invalid". Every child gets these. +NO_STDIO = {"stdin": subprocess.DEVNULL, "stdout": subprocess.DEVNULL, + "stderr": subprocess.DEVNULL} + + def launch_command(*args): """argv that starts the app with these arguments, as it runs now: the venv's pythonw on Windows (no console window), tools/espdrop.py of this @@ -167,7 +174,7 @@ def _set_windows_send(on): "$s.Description = $env:ESPDROP_DESC; $s.Save()") os.makedirs(os.path.dirname(lnk), exist_ok=True) subprocess.run(["powershell", "-NoProfile", "-NonInteractive", "-Command", script], - env=env, check=True, capture_output=True, + env=env, check=True, capture_output=True, stdin=subprocess.DEVNULL, creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0)) else: if os.path.exists(lnk): @@ -242,7 +249,7 @@ def set_program(exe, version, on=True): "$s.Description = $env:ESPDROP_DESC; $s.Save()") os.makedirs(os.path.dirname(lnk), exist_ok=True) subprocess.run(["powershell", "-NoProfile", "-NonInteractive", "-Command", script], - env=env, check=True, capture_output=True, + env=env, check=True, capture_output=True, stdin=subprocess.DEVNULL, creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0)) @@ -405,6 +412,7 @@ def _refresh_desktop_db(directory): """File managers find "Open with" apps through mimeinfo.cache.""" try: subprocess.run(["update-desktop-database", directory], capture_output=True, + stdin=subprocess.DEVNULL, timeout=10) except (OSError, subprocess.SubprocessError): pass diff --git a/tools/build_app.py b/tools/build_app.py index ca5a83f..ff38b0d 100644 --- a/tools/build_app.py +++ b/tools/build_app.py @@ -321,6 +321,72 @@ def same(p): print("smoke: --version, check and the window are fine", flush=True) +def smoke_install(exe): + """Start the single file as a double-click would, from Downloads, and see + it install itself, start from there and remove the download. Windows: for + real in this user's profile (CI only: --smoke-install); Linux: in a + throwaway home folder.""" + import tempfile + env = {k: v for k, v in os.environ.items() + if k not in ("ESPDROP_NO_DESKTOP", "ESPDROP_PORTABLE", "QT_QPA_PLATFORM")} + env["ESPDROP_INSTANCE"] = f"espdrop-install-{os.getpid()}" + if IS_WINDOWS: + downloads = os.path.join(os.environ["USERPROFILE"], "Downloads") + installed = os.path.join(os.environ["LOCALAPPDATA"], "Programs", NAME, NAME + ".exe") + else: + home = tempfile.mkdtemp(prefix="espdrop-home-") + for k in ("XDG_CONFIG_HOME", "XDG_DATA_HOME", "XDG_CACHE_HOME"): + env.pop(k, None) + env.update(HOME=home, QT_QPA_PLATFORM="offscreen") + downloads = os.path.join(home, "Downloads") + installed = os.path.join(home, ".local", "bin", "espdrop") + os.makedirs(downloads, exist_ok=True) + download = os.path.join(downloads, os.path.basename(exe) + ("" if IS_WINDOWS else "-dl")) + if IS_WINDOWS and not download.endswith(".exe"): + download += ".exe" + shutil.copy2(exe, download) + if IS_WINDOWS: + # Explorer's way: no console, no valid standard handles (WinError 6 + # once came from passing those on to the programs it starts). + saved = dict(os.environ) + os.environ.clear() + os.environ.update(env) + try: + os.startfile(download) + finally: + os.environ.clear() + os.environ.update(saved) + else: + subprocess.Popen([download], env=env, stdin=subprocess.DEVNULL, + stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, + start_new_session=True) + end = time.monotonic() + 120 + while time.monotonic() < end and (os.path.exists(download) or not os.path.exists(installed)): + time.sleep(1) + try: + if not os.path.exists(installed): + sys.exit(f"smoke: not installed to {installed}") + if os.path.exists(download): + sys.exit(f"smoke: the download {download} is still there") + if IS_WINDOWS: + import winreg + with winreg.OpenKey(winreg.HKEY_CURRENT_USER, + r"Software\Microsoft\Windows\CurrentVersion\Uninstall\EspDrop") as k: + uninstall = winreg.QueryValueEx(k, "UninstallString")[0] + lnk = os.path.join(os.environ["APPDATA"], "Microsoft", "Windows", "Start Menu", + "Programs", "EspDrop.lnk") + if not os.path.exists(lnk): + sys.exit("smoke: no start menu entry") + print(f"smoke: installed {installed}; Installed apps: {uninstall}", flush=True) + else: + print(f"smoke: installed {installed}", flush=True) + finally: + if IS_WINDOWS: + subprocess.run(["taskkill", "/F", "/IM", NAME + ".exe"], capture_output=True) + else: + subprocess.run(["pkill", "-f", installed], capture_output=True) + + def main(argv=None): ap = argparse.ArgumentParser(description=__doc__.split("\n\n")[0]) ap.add_argument("--firmware", help="firmware image to ship (espdrop-esp32c5.bin)") @@ -328,6 +394,9 @@ def main(argv=None): ap.add_argument("--zig", action="store_true", help="Windows: compile with Zig, not MSVC") ap.add_argument("--jobs", type=int, help="parallel C compilations (default: CPUs)") ap.add_argument("--smoke", action="store_true", help="start the built program to check it") + ap.add_argument("--smoke-install", action="store_true", + help="also start the single file as a double-click would and check " + "that it installs itself (Windows: for real, for CI machines)") ap.add_argument("--folder", action="store_true", help="a program folder (zip/tar.gz) instead of one file") ap.add_argument("--out", default=os.path.join(ROOT, "build"), help="build folder") @@ -377,6 +446,9 @@ def main(argv=None): if args.smoke: step("starting the built program") smoke(exe) + if args.smoke_install and not args.folder: + step("installing it as a double-click does") + smoke_install(exe) step("packing") path = package(dist, build) if args.folder else package_onefile(exe, build) platform_name = "windows" if IS_WINDOWS else "linux"