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/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 0000000..14f48fc --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,141 @@ +# 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' + # 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 \ + libxcb-cursor0 libxcb-icccm4 libxcb-image0 libxcb-keysyms1 libxcb-render-util0 \ + libxcb-shape0 libxcb-xkb1 libxkbcommon-x11-0 libxcb-util1 xvfb + - 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 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 + 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 --smoke-install + - uses: actions/upload-artifact@v4 + with: + name: EspDrop-${{ matrix.name }} + path: | + build/EspDrop-* + build/THIRD_PARTY-*.txt + 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/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 # published by hand, after a look + name: EspDrop ${{ github.ref_name }} + body_path: docs/RELEASE.md + files: dist/* diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..1f254e0 --- /dev/null +++ b/.gitignore @@ -0,0 +1,44 @@ +# --- 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 + +# 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/ +firmware/**/build_*/ 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/M4_ENTWURF.md b/docs/M4_ENTWURF.md new file mode 100644 index 0000000..f0dc63b --- /dev/null +++ b/docs/M4_ENTWURF.md @@ -0,0 +1,313 @@ +# 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.9+, + pyserial und OpenDrops Abhängigkeiten; heikel ist `libarchive` (braucht eine + 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 + +- **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/RELEASE.md b/docs/RELEASE.md new file mode 100644 index 0000000..ff33a90 --- /dev/null +++ b/docs/RELEASE.md @@ -0,0 +1,33 @@ +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. + +## Downloads + +| file | for | +|---|---| +| `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 | + +## Install + +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. + +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`). + +The app is in English and German (*General → Language*). + +--- + +**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 new file mode 100644 index 0000000..ed665e2 --- /dev/null +++ b/docs/SPIKES.md @@ -0,0 +1,781 @@ +# 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). 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 + +# 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` | 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..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 | +| `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 | + +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`, 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. + +**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. + +## 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`: + `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, + `peers` und `stats`: Das iPhone muss auftauchen, `action` steigen. +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 Bandwechsel und eventuelle Fehler. +7. **USB-Durchsatz** wie Spike 5. +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) + +- **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. +- **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 + (≈ 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 %. +- **Nachmessung (2026-10-23, mit Bluetooth in der Firmware):** eine 38-MB-Datei + 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) + +Empfangen ist damit fertig. Für die Gegenrichtung muss das iPhone oder iPad als +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 +`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=`. + +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 (`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 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 + Menü meldet sich das Gerät nie als Empfänger). Bei der Abfrage „Annehmen“ + 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 +`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 +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.) +- `~/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. +- 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. 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). + 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 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=`. +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 +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, 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 +~/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). + +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 +``` + +## 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. 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 +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 +`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 +``` + +## 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. + +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 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 +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, + 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. + +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. + +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. + +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 +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 +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 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. +- **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=… + 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`. + +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, `adverts=` wie oft ein +AirDrop-Signal in der Nähe den ESP geweckt hat. + +## 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 („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)“. + +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: unter dem Titel „ESP verbunden · Funk aus (spart Strom)“. +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 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“ (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“ (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 …“. + +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). + +### 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, +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. + +## 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). + +## 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. + +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` 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: + `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 +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. `chmod +x EspDrop-0.1.0-linux-x86_64` (Downloads verlieren das + 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 `~/.local/bin/espdrop`. +3. Firmware aktualisieren, senden, empfangen wie unter Windows. + +## 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/docs/UEBERBLICK.md b/docs/UEBERBLICK.md new file mode 100644 index 0000000..244a29a --- /dev/null +++ b/docs/UEBERBLICK.md @@ -0,0 +1,244 @@ +# 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 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`. + 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**. 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) 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. +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). + +### 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 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 +iPhone, und gehört daher nicht zum Projektumfang. diff --git a/firmware/README.md b/firmware/README.md new file mode 100644 index 0000000..486adc5 --- /dev/null +++ b/firmware/README.md @@ -0,0 +1,68 @@ +# EspDrop firmware + +ESP-IDF firmware for the ESP32 that acts as the AWDL radio for EspDrop. + +## Layout + +``` +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 + 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 +# 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: 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. + +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 +(`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 + +```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..f63b94c --- /dev/null +++ b/firmware/spikes/CMakeLists.txt @@ -0,0 +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/components/awdl/CMakeLists.txt b/firmware/spikes/components/awdl/CMakeLists.txt new file mode 100644 index 0000000..ae1ae9e --- /dev/null +++ b/firmware/spikes/components/awdl/CMakeLists.txt @@ -0,0 +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_power.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/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/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 new file mode 100644 index 0000000..96c6a90 --- /dev/null +++ b/firmware/spikes/components/awdl/awdl_frame.c @@ -0,0 +1,641 @@ +/* + * 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. + * 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); +} + +/* 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; + + 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; + } +} + +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) { + 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, 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++) { + 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; + } + } + 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 */ +/* ------------------------------------------------------------------------ */ + +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, + 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); + + 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, cap, 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, cap, 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, cap, 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, cap, 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, cap, 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, cap, 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, cap, 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, cap, 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, cap, 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, uint16_t sync_tx_down_tu, uint16_t sync_aw_counter) { + 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, 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); + 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; +} + +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)); + + 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 = 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; + 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_SYNC_PARAMS: { + /* sync-params TLV value: tx_down_counter at offset 1, + * 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: { + /* 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). 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: { + /* v1 (no counters): distancetop@3, top_master_addr@5, + * 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; + } + off += 3 + l; + } + return AWDL_OK; +} + +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 + * 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; + 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 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 */ +} + +/* 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, 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 + off + 3, AWDL_OUI, 3) != 0) return false; + + uint16_t pid = 0, head = 0, et = 0; + wire_get_be16(buf, end, off + 6, &pid); + if (pid != AWDL_LLC_PID) return false; + + size_t adata = off + 8; + wire_get_le16(buf, end, adata + 0, &head); + if (head != AWDL_DATA_HEAD) return false; + 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 new file mode 100644 index 0000000..b655b4b --- /dev/null +++ b/firmware/spikes/components/awdl/awdl_node.c @@ -0,0 +1,173 @@ +/* + * 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; + } +} + +/* Re-derive the channel sequence we advertise. When we track a master that has + * 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); + 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; + } + } + 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, + 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 (!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); + 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)) { + /* 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; + } + } +} + +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); +} + +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; + 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); + 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, + 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); +} + +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] == n->self.master_chan; /* a slot on our channel */ +} + +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); + 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 + ? 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/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_power.c b/firmware/spikes/components/awdl/awdl_power.c new file mode 100644 index 0000000..b5478cb --- /dev/null +++ b/firmware/spikes/components/awdl/awdl_power.c @@ -0,0 +1,76 @@ +/* + * 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->listen = false; + p->hold_until = p->wake_until = p->busy_until = p->advert_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; +} + +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) { + 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; + 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; +} + +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"; + case AWDL_AWAKE_ADVERT: return "advert"; + default: return "none"; + } +} 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_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/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/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 new file mode 100644 index 0000000..caf28ca --- /dev/null +++ b/firmware/spikes/components/awdl/include/awdl_frame.h @@ -0,0 +1,302 @@ +/* + * 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); + +/* 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 (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). + * + * 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. + */ +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) */ +/* ------------------------------------------------------------------------ */ + +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) */ + 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; + +/* ------------------------------------------------------------------------ */ +/* 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 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, 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 + * + 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). + * 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); + +/* + * 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); + +/* + * 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); + +/* + * 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); + +#ifdef __cplusplus +} +#endif + +#endif /* ESPDROP_AWDL_FRAME_H */ 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..e65b115 --- /dev/null +++ b/firmware/spikes/components/awdl/include/awdl_node.h @@ -0,0 +1,127 @@ +/* + * 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 + +/* 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 */ + awdl_election_t election; /* our election state (who we sync to) */ + awdl_peer_table_t peers; /* known peers */ + + /* 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 */ + + /* 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; + 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). */ +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); + +/* + * 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); + +/* 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 + * 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); + +/* + * True if, at now_us, the EAW slot we are in is one our advertised channel + * 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); + +typedef struct { + bool have_master; + uint8_t master_addr[6]; + uint32_t height; + 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 */ +} 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/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_power.h b/firmware/spikes/components/awdl/include/awdl_power.h new file mode 100644 index 0000000..35cc4ae --- /dev/null +++ b/firmware/spikes/components/awdl/include/awdl_power.h @@ -0,0 +1,91 @@ +/* + * 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_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; + +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); +/* 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); + +/* 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/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/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..c2e4738 --- /dev/null +++ b/firmware/spikes/components/awdl/test/host/Makefile @@ -0,0 +1,37 @@ +# 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 + +CC ?= cc +CFLAGS ?= -std=c11 -Wall -Wextra -Werror -O2 -g +COMP = ../.. +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_power.c +HEADERS = $(wildcard $(COMP)/include/*.h) + +BINS = test_awdl_frame test_awdl_cobs test_awdl_power + +.PHONY: all run clean +all: run + +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 $@ + +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_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/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..338a895 --- /dev/null +++ b/firmware/spikes/components/awdl/test/host/test_awdl_frame.c @@ -0,0 +1,902 @@ +/* + * 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 "awdl_sync.h" +#include "awdl_peers.h" +#include "awdl_node.h" +#include "awdl_dedup.h" + +#include +#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, 64, 0); + 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, 64, 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"); +} + +/* 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"); +} + +/* 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"); +} + +/* 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)); + 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"); +} + +/* 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; + 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 */, AWDL_EAW_TU, 0 /* tx_down, aw_counter */); + 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"); +} + +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, 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++) + 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"); +} + +/* 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[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; + 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_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}; + + 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); + /* 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. */ + 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"); +} + +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); + + /* 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]; + 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, 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(&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(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); + /* 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, 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++) { + 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) { + /* 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); + } +} + +/* 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(); + test_data_roundtrip(); + test_data_qos_and_flags(); + test_dedup(); + 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(); + test_builder_respects_cap(); + test_truncated_v2_no_clobber(); + 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(); + test_node_seq_verbatim(); + + if (g_fail == 0) printf("\nALL TESTS PASSED\n"); + else printf("\n%d CHECK(S) FAILED\n", g_fail); + return g_fail ? 1 : 0; +} 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..a758c7c --- /dev/null +++ b/firmware/spikes/components/awdl/test/host/test_awdl_power.c @@ -0,0 +1,102 @@ +/* + * 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); +} + +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; + } + printf("test_awdl_power: all passed\n"); + return EXIT_SUCCESS; +} diff --git a/firmware/spikes/main/CMakeLists.txt b/firmware/spikes/main/CMakeLists.txt new file mode 100644 index 0000000..40f3c4d --- /dev/null +++ b/firmware/spikes/main/CMakeLists.txt @@ -0,0 +1,33 @@ +# 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). +# +# 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" +# 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() +# 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" "ble_wake.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 + esp_app_format bt lwip ${ESPDROP_USJ_COMPONENT} ${ESPDROP_USJ_LL_COMPONENT} +) diff --git a/firmware/spikes/main/Kconfig.projbuild b/firmware/spikes/main/Kconfig.projbuild new file mode 100644 index 0000000..6e79aaf --- /dev/null +++ b/firmware/spikes/main/Kconfig.projbuild @@ -0,0 +1,41 @@ +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/...). + +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 173 + 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 new file mode 100644 index 0000000..1fa0641 --- /dev/null +++ b/firmware/spikes/main/app_main.c @@ -0,0 +1,140 @@ +/* + * 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 + * 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/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 + */ +#include + +#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" + +/* 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 "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 + * 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_notify_peer(added, mac); +} + +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 + * port. bridge_start() then silences ESP_LOG and takes over the USB endpoint, + * so steady-state traffic is clean COBS. */ + uint8_t chan = boot_channel(); + int err = radio_init(chan); + 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, CONFIG_ESPDROP_AWDL_NAME, chan) != 0) { + ESP_LOGE("espdrop", "awdl_app_start failed"); + 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; + } + + /* 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); + 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); + + /* 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 */ + +#include "esp_console.h" +#include "awdl_app.h" + +void console_register_commands(void); + +void app_main(void) { + ESP_LOGI("espdrop", "EspDrop AWDL (console mode) starting"); + + uint8_t chan = boot_channel(); + int err = radio_init(chan); + 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, CONFIG_ESPDROP_AWDL_NAME, chan) != 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(); + 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'"); +} + +#endif /* CONFIG_ESPDROP_BRIDGE_MODE */ diff --git a/firmware/spikes/main/awdl_app.c b/firmware/spikes/main/awdl_app.c new file mode 100644 index 0000000..08ba5c6 --- /dev/null +++ b/firmware/spikes/main/awdl_app.c @@ -0,0 +1,412 @@ +/* + * 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. 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 */ +#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. */ +/* 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 + * 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 { + awdl_peer_view_t pv; + int8_t rssi; + uint8_t channel; + 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 / 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 */ +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. */ +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}; +/* 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; +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; + +/* 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; + +/* 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) { + (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 */ + if (xQueueSend(s_rx_q, &ev, 0) == pdTRUE && s_task) + xTaskNotifyGive(s_task); +} + +/* + * 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); + 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; +} + +/* 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(); + 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 (;;) { + /* 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; + (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); + } + + now = esp_timer_get_time(); + /* 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 && !s_asleep && now >= next_psf && send_frame(AWDL_SUBTYPE_PSF, false)) + next_psf = now + PSF_INTERVAL_US; + 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]; + 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; + } + + /* 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_asleep && 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) == 0) { + if (td.dst[0] & 0x01) s_tx_sent_mc++; + else s_tx_sent_uc++; + } else { + s_tx_fail++; + } + } + } + xSemaphoreGive(s_lock); + } + } +} + +int awdl_app_tx_ethernet(const uint8_t *eth, size_t len, void *ctx) { + (void)ctx; + /* 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 || !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); + memcpy(td.dst, eth, 6); + td.len = (uint16_t)plen; + if (plen) memcpy(td.ip, eth + 14, plen); + 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; +} + +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) { + s_peer_cb_ctx = ctx; + s_peer_cb = cb; + 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; + + 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); + 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) + return -1; + + radio_set_action_handler(on_action, NULL); + + 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, s_name, chan, (uint64_t)esp_timer_get_time()); + awdl_node_set_seq_mode(&s_node, s_seq_mode); + 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; +} + +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_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 */ +} + +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 new file mode 100644 index 0000000..d62a6f6 --- /dev/null +++ b/firmware/spikes/main/awdl_app.h @@ -0,0 +1,117 @@ +/* + * 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 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); + +/* 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); + +/* 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 + * 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); + +/* + * 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 + * 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); + +/* + * 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); + +/* 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); + +/* 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 + +#endif /* ESPDROP_AWDL_APP_H */ diff --git a/firmware/spikes/main/awdl_netif.c b/firmware/spikes/main/awdl_netif.c new file mode 100644 index 0000000..45892e9 --- /dev/null +++ b/firmware/spikes/main/awdl_netif.c @@ -0,0 +1,389 @@ +/* + * 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)) { + /* 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 + 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 && netif_is_up(&s_nif); + 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; } + +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, + 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..427709c --- /dev/null +++ b/firmware/spikes/main/awdl_netif.h @@ -0,0 +1,57 @@ +/* + * 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); + +/* 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, + 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/ble_wake.c b/firmware/spikes/main/ble_wake.c new file mode 100644 index 0000000..bed3713 --- /dev/null +++ b/firmware/spikes/main/ble_wake.c @@ -0,0 +1,237 @@ +/* + * 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 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 + +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) { + (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; +} + +#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; } +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; } +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 new file mode 100644 index 0000000..090a54e --- /dev/null +++ b/firmware/spikes/main/ble_wake.h @@ -0,0 +1,73 @@ +/* + * 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); 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 + * 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); + +/* + * 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 new file mode 100644 index 0000000..04bfb0e --- /dev/null +++ b/firmware/spikes/main/bridge.c @@ -0,0 +1,852 @@ +/* + * 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 + +#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 */ +#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" +#include "freertos/semphr.h" + +#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 "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 +#define BRIDGE_MAX_MSG (1 + BRIDGE_MAX_PAYLOAD) /* type + payload */ +#define USB_RX_BUF 1024 +/* 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; 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, 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. + * 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; + +/* 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; +/* 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 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; + +/* 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_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). */ +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. */ +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)); +} + +/* 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; + +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 + * 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; + l2q_release(); /* its memory is lwIP's now ... */ + l2q_alloc(BRIDGE_IPQ_DEPTH); /* ... but for a small ordered queue */ + sockproxy_reset(0); + } else { + 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(0); + 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] = 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 */ + | 0x02 /* IP mode */ + | ((ble_wake_ready() && ble_scan_supported()) ? 0x04 : 0); /* listen (BLE scan) */ + 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; } + 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); + 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) + s_rx_drops++; /* host not keeping up: drop rather than stall Wi-Fi */ + xSemaphoreGive(s_l2q_lock); +} + +/* 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) + 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; +} + +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) { + 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 + * 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 (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(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(); + } + } + } +} + +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) { + bridge_send(BRIDGE_MSG_CMD_REPLY, s, strlen(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; + 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[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); + 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) { + snprintf(out, sizeof(out), + "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_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); + } else if (strcmp(cmd, "radio") == 0) { + radio_stats_t st; + radio_get_stats(&st); + 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_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 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", + 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)? */ + 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, "chan") == 0 || strncmp(cmd, "chan ", 5) == 0) { + /* `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(arg); + int err = (ch >= 1 && ch <= 255 && radio_channel_supported((uint8_t)ch)) + ? 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 + 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, "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(); + power_wake(0); + 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) 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 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 (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 (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) { + /* `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)) + 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 + * 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)? */ + 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, "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 + * 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"); + } 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); + } +} + +/* 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; + power_host_seen(); /* the host is there; its requests stay valid */ + + switch (type) { + case BRIDGE_MSG_ECHO: + bridge_send(BRIDGE_MSG_ECHO_REPLY, payload, plen); + break; + case BRIDGE_MSG_FRAME: + /* 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; + /* 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); + /* 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) | + (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_LISTEN: + 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). */ + 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); + 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) + 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 */ + vSemaphoreDelete(s_tx_lock); + s_tx_lock = NULL; + return e; + } + + s_l2q_lock = xSemaphoreCreateMutex(); + s_l2q_freed = xSemaphoreCreateBinary(); + 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); + 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; + } + + /* 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); + + /* 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; + 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 + * (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 + * dropped (queue fills) but the control link stays up, so do not fail. */ + 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"); + + bridge_log("bridge up"); + 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 */ + /* 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; +} + +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..da29728 --- /dev/null +++ b/firmware/spikes/main/bridge.h @@ -0,0 +1,117 @@ +/* + * 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 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 + * 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 +#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 */ + 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 */ + 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 + * 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. */ +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); + +/* + * 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); + +/* + * 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))); + +/* + * 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 + +#endif /* ESPDROP_BRIDGE_H */ diff --git a/firmware/spikes/main/console.c b/firmware/spikes/main/console.c new file mode 100644 index 0000000..2b95f18 --- /dev/null +++ b/firmware/spikes/main/console.c @@ -0,0 +1,402 @@ +/* + * EspDrop - serial console for the AWDL hardware spikes (ESP32-C3 and -C5). + * + * 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 (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 + * peers drain and print discovered-peer events + * stats print rx/tx counters + * reset zero the counters + * 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 + * Copyright (C) 2026 EspDrop contributors + */ +#include +#include +#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" +#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, CONFIG_ESPDROP_AWDL_NAME, 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; +} + +#if SOC_WIFI_SUPPORT_5G +#define CHAN_RANGE "1..13 | 36..48 | 149..173" +#else +#define CHAN_RANGE "1..13" +#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], + 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(); + awdl_app_set_self(mac, radio_get_channel()); /* rebuild node (resets sync) */ + printf("MAC set\n"); + return 0; +} + +static int cmd_chan(int argc, char **argv) { + if (argc != 2) { printf("usage: chan <" CHAN_RANGE ">\n"); return 1; } + int ch = atoi(argv[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: %s\n", esp_err_to_name(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; +} + +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; + + /* 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; +} + +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; + 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; +} + +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("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, + 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("\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; +} + +static int cmd_reset(int argc, char **argv) { + (void)argc; (void)argv; + radio_reset_stats(); + printf("stats reset\n"); + 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 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; + if (n < 1) n = 1; + 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 % nch]; + int64_t t0 = esp_timer_get_time(); + 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++; + 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; + } + 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 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)); + 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; } + } + 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; +} + +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 chip, MAC, channel, country, band mode, TX power", cmd_info); + reg("mac", "mac - override station MAC", cmd_mac); + 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); + 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("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/power.c b/firmware/spikes/main/power.c new file mode 100644 index 0000000..06e251e --- /dev/null +++ b/firmware/spikes/main/power.c @@ -0,0 +1,177 @@ +/* + * 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 "ble_wake.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 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(); } + +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; + 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); + bool want_scan = awdl_power_want_scan(&s_pol, t); + portEXIT_CRITICAL(&s_mux); + apply(why); + apply_scan(want_scan); + } +} + +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_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); + 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 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_adverts, + (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..b8c42fa --- /dev/null +++ b/firmware/spikes/main/power.h @@ -0,0 +1,43 @@ +/* + * 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_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); + +/* 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 new file mode 100644 index 0000000..f84699b --- /dev/null +++ b/firmware/spikes/main/radio.c @@ -0,0 +1,433 @@ +/* + * 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_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 */ +#include "nvs_flash.h" +#include "sdkconfig.h" +#include "soc/soc_caps.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 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; +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 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); + (*field)++; + portEXIT_CRITICAL(&s_stats_mux); +} + +/* ---- 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; + /* 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); + 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); + } +} + +/* 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 (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; + + 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; + } + + /* 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. 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; +#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); + /* 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++; + 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); + if (r != AWDL_OK) { stat_inc(&s_stats.rx_parse_err); return; } + stat_inc(&s_stats.rx_action); + + /* 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 */ + /* 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 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)delivered; + portEXIT_CRITICAL(&s_stats_mux); + } + if (r < 0) stat_inc(&s_stats.rx_data_perr); + } +} + +/* ------------------------------------------------------------------------ */ + +bool radio_channel_supported(uint8_t ch) { + 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 <= 173 && ch % 4 == 1) return true; /* SRD band, <= 5875 MHz */ +#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(); + 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; + + /* 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 */ + + 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; + + if ((err = apply_rf(channel)) != ESP_OK) return err; + + 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; + 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; + err = apply_rf(s_channel); + if (was_promisc) radio_promisc(true); + return mac_err != ESP_OK ? mac_err : err; +} + +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) { + 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) { + 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); + 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 */ + 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; + } + 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_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)); + portEXIT_CRITICAL(&s_stats_mux); +} + +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 new file mode 100644 index 0000000..42ed3c1 --- /dev/null +++ b/firmware/spikes/main/radio.h @@ -0,0 +1,171 @@ +/* + * 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" +#include "awdl_dedup.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 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 + * (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 */ + 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 */ + /* 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; + /* 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; + /* 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; + 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) */ + 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); + +/* + * 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); + +/* + * 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 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..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); + +/* 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 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); + +/* + * 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 + * 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); + +/* 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); + +/* 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 + +#endif /* ESPDROP_RADIO_H */ diff --git a/firmware/spikes/main/sockproxy.c b/firmware/spikes/main/sockproxy.c new file mode 100644 index 0000000..980a882 --- /dev/null +++ b/firmware/spikes/main/sockproxy.c @@ -0,0 +1,607 @@ +/* + * 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. + * + * 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 + */ +#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/tcp.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 */ +#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]; + +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 */ +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; + +/* 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 { + uint8_t type; + size_t len; + 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 }; + 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 */ +} + +/* ---- 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 udp_close_slot(udp_slot_t *s) { + if (s->pcb) udp_remove(s->pcb); + s->pcb = NULL; + s->port = 0; +} + +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 data[UDP_MAX_DATA]; + if (!IP_IS_V6(addr) || p->tot_len > UDP_MAX_DATA) { + s_udp_err++; + pbuf_free(p); + return; + } + 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); + /* 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 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 = 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 (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]; + 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 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 = 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; + 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) { + 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, rd16(d + 18)); + pbuf_free(p); + if (e == ERR_OK) { + s_udp_tx++; + } else { + s_udp_err++; + send_result(BRIDGE_MSG_UDP_SEND, port, SOCKPROXY_EFAIL); + } +} + +/* ---- 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]; + 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; + } + 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] = 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++; +} + +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_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); + 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: 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 = udp_slot_for(rd16(j->data)); + 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; + case BRIDGE_MSG_TCP_CLOSE: tcp_close_job(j->data, j->len); 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_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_tcp_err++; + send_result(type, 0, SOCKPROXY_ENOMEM); + } +} + +static SemaphoreHandle_t s_reset_done; +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; + bridge_set_post_space_cb(on_queue_space); + xSemaphoreGive(s_reset_done); +} + +void sockproxy_reset(uint32_t host_credit) { + if (!s_reset_done && !(s_reset_done = xSemaphoreCreateBinary())) return; + xSemaphoreTake(s_reset_done, 0); + s_reset_credit = host_credit; + if (tcpip_callback(reset_job, NULL) == ERR_OK) + 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++) + 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 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 new file mode 100644 index 0000000..6c9bd89 --- /dev/null +++ b/firmware/spikes/main/sockproxy.h @@ -0,0 +1,56 @@ +/* + * 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 (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; messages to the host go out in + * order through bridge_post. + * + * 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 */ +}; + +#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) 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); + +/* 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); + +#ifdef __cplusplus +} +#endif 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 diff --git a/firmware/spikes/sdkconfig.bridge.defaults b/firmware/spikes/sdkconfig.bridge.defaults new file mode 100644 index 0000000..84b1e22 --- /dev/null +++ b/firmware/spikes/sdkconfig.bridge.defaults @@ -0,0 +1,50 @@ +# 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. + +# 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. 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 +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 +# 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 +# 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 + +# 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/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/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/host/README.md b/host/README.md new file mode 100644 index 0000000..e906f83 --- /dev/null +++ b/host/README.md @@ -0,0 +1,248 @@ +# 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/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/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/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; +`--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. + +## 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. It is drawn in the look of +Apple's own apps (`espdrop/ui.py`: rounded cards, switches, segmented +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: 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. +- **Ü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 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: + `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. +- **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` +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, +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). 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 +~/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). + +## 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 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 +~/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 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 +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 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*` (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. + +**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 + +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 +.\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, cryptography (the TLS +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`. + +**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/__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/airdrop.py b/host/espdrop/airdrop.py new file mode 100644 index 0000000..13b6751 --- /dev/null +++ b/host/espdrop/airdrop.py @@ -0,0 +1,297 @@ +# 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 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 + +from .mdns import Browser # noqa: E402 + +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 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()] + + +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() + + 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: + # Short waits: a long one cannot be interrupted with Ctrl+C on Windows. + 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() + 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") + 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(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, + job.size / 1e6) + job._mark = job.bytes_up() # until /Upload sets it exactly + state("uploading") + t = time.monotonic() + 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)", 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: + _tls.job = None + _close(client) diff --git a/host/espdrop/cli.py b/host/espdrop/cli.py new file mode 100644 index 0000000..dd42a48 --- /dev/null +++ b/host/espdrop/cli.py @@ -0,0 +1,491 @@ +# 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 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) +# 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). +# +# SPDX-License-Identifier: GPL-3.0-or-later +# Copyright (C) 2026 EspDrop contributors + +import argparse +import logging +import os +import sys +import threading +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 _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) + 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) + + 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) + + 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) ...", 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 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 + finally: + if woke: + try: + dev.cmd("wake off") + except DeviceError: + pass + if not dev.error: + _hold(dev, 0) + 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_receivers, 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) + _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}") + 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) + + t0 = time.monotonic() + print(f"looking for {args.receiver or 'receivers'} " + 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: + 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 + 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 + finally: + if woke: + try: + dev.cmd("wake off") + except DeviceError: + pass + if not dev.error: + _hold(dev, 0) + 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): + 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, on_ask=on_ask, on_event=on_event, + announce_interval=2.5) + listen = False + try: + 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: # 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: + print() + return 0 + finally: + rx.stop() + if not dev.error: + if listen: + try: + dev.cmd("power listen off") + except DeviceError: + pass + _hold(dev, 0) + dev.close() + + +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 + 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 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) + 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 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 + 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): + # 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") + 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__}") + 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") + 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=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=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)") + 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") + 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) + 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)") + 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 + 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. + 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, + "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) + 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) + 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/device.py b/host/espdrop/device.py new file mode 100644 index 0000000..bc2121d --- /dev/null +++ b/host/espdrop/device.py @@ -0,0 +1,193 @@ +# 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 +from .proxy import Manager + + +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._udp_lock = threading.Lock() + 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", + 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: + 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): + self.tcp.dispatch(t, p) + 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). + + 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) + 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: + 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, 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): + """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): + """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/env.py b/host/espdrop/env.py new file mode 100644 index 0000000..abbdb91 --- /dev/null +++ b/host/espdrop/env.py @@ -0,0 +1,492 @@ +# 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: 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 + +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 + + +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).""" + 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")) + 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))] + 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 + 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": + fix_archive_writer(opendrop.util.AbsArchiveWrite) + fix_ask_paths(opendrop.client.AirDropClient) + + +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, 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) + 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"): + 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 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 bundle_dir + base = bundle_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 + 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}") + + from .frozen import compiled, program + v = sys.version_info + 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 + 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: + 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") + 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)") + 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) + except ImportError as e: + line(False, "libarchive", str(e)) + return problems # OpenDrop cannot load without it + try: + load_opendrop() + import opendrop + import opendrop.server + 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'} ({where})") + 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) + 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/flash.py b/host/espdrop/flash.py new file mode 100644 index 0000000..6e0d46a --- /dev/null +++ b/host/espdrop/flash.py @@ -0,0 +1,339 @@ +# 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 + +from .i18n import _ + +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 _offset, 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(_("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) + 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 _("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 _("Flashen fehlgeschlagen: {error}").format(error=text) diff --git a/host/espdrop/frozen.py b/host/espdrop/frozen.py new file mode 100644 index 0000000..a8fa81a --- /dev/null +++ b/host/espdrop/frozen.py @@ -0,0 +1,91 @@ +# 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). 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 + +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 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.""" + if "__compiled__" in globals(): + info = globals()["__compiled__"] + 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 + + +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 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/espdrop/gui.py b/host/espdrop/gui.py new file mode 100644 index 0000000..f9e1f24 --- /dev/null +++ b/host/espdrop/gui.py @@ -0,0 +1,1731 @@ +# 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... the AirDrop sheet (the file manager's right-click menu) +# +# 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. 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 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 +# 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 getpass +import json +import os +import re +import sys +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 +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 env, flash, frozen, i18n, install, 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 = [(N_("Aus"), 0), (N_("10 Minuten"), 600), (N_("Für alle"), None)] + +THEMES = [(N_("Automatisch"), "auto"), (N_("Hell"), "light"), (N_("Dunkel"), "dark")] +THEME_KEYS = [key for _label, key in THEMES] + +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", + ".bmp", ".dng"} +VIDEO_EXT = {".mov", ".mp4", ".m4v", ".avi", ".hevc"} + +app_icon = ui.app_icon + + +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": + text = f"{n:.0f} {unit}" if unit == "B" else f"{n:.1f} {unit}" + return text.replace(".", ",") if i18n.LANG == "de" else text + n /= 1000 + + +def _safe(fn, default): + """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 _("{n} Links").format(n=len(urls)) + n = len(files) + if not n: + return _("etwas") + kinds = {_kind(f) for f in files} + 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 ------------------------------------------------------- + +def instance_name(): + """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}" + + +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).""" + 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.LocalSocketState.UnconnectedState: + sock.waitForDisconnected(timeout_ms) + return True + + +class InstanceServer(QObject): + """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): + # 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): + self._prune() + while self.server.hasPendingConnections(): + sock = self.server.nextPendingConnection() + 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, _sep, rest = bytes(buf).partition(b"\n") + buf[:] = rest + try: + msg = json.loads(line.decode("utf-8")) + except ValueError: + continue + if isinstance(msg, dict): + self.message.emit(msg) + + 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): + """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"] + + +# ---- asking before a transfer ------------------------------------------------- + +class AskDialog(QDialog): + """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.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(_("„{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]) + 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() + 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) + 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(_("Ohne Antwort abgelehnt in {secs} s").format(secs=f"{left:.0f}")) + + def _answer(self, result): + self.timer.stop() + self.reply["ok"] = result == QDialog.DialogCode.Accepted + self.reply["done"].set() + + +# ---- the AirDrop sheet ---------------------------------------------------------- + +class SendDialog(QDialog): + """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("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) + 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) + 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 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) + self.preview.setPixmap(ui.files_preview(self.files, 64)) + if self.files: + size = sum(os.path.getsize(f) for f in self.files) + 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.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, _filter = 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 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() + + def on_receiver(self, r): + 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: + self._choose_files() + if not self.files: + return + try: + t = self.w.session.send(rid, self.files) + except Exception as e: # noqa: BLE001 + 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) + tile = self.grid.tiles.get(rid) + if tile: + tile.set_phase("waiting") + self.more_btn.setEnabled(False) + self.status.setText(_("Warten, bis {name} annimmt …").format(name=t.receiver.label)) + + # ---- the transfer --------------------------------------------------------- + + def on_send(self, info): + t = info["transfer"] + 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.status.setText(_("Senden an {name} …").format(name=t.receiver.label)) + elif state == "done": + self.status.setText(_("Gesendet an {name}").format(name=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": _("{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) + + def done(self, result): + 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 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 + 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") + + +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(_("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, _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() + + 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 = _("Flashen fehlgeschlagen: {error}").format(error=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(_("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])) + + 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(_("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): + 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): + 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) + 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()) + 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) + 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) -> TransferRow + self._recv_n = 0 + self._recv_row = None # the row of the transfer being received + self._quitting = False + 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.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() + self._update_dir() + self.clock = QTimer(self) + self.clock.setInterval(1000) + self.clock.timeout.connect(self._tick) + self.clock.start() + if background: + # 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 --------------------------------------------------------------- + + def _default_session(self): + d = self.settings.value("download_dir", "") or default_download_dir() + 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) + + def _build(self): + page = QWidget() + page.setObjectName("page") + col = QVBoxLayout(page) + col.setContentsMargins(24, 22, 24, 24) + col.setSpacing(4) + + # the app, and whether the ESP is there + head = QHBoxLayout() + 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) + 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) + 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(_("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) + 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, _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.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)) + # 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.")) + 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()))) + 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 + 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)) + 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."), + "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 + 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 + col.addWidget(ui.section(_("Allgemein"))) + gcard = ui.Card() + 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) + 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.")) + 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") 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, "") + col.addWidget(gcard) + + # the log, off unless asked for + self.log = QPlainTextEdit() + self.log.setReadOnly(True) + self.log.setMaximumBlockCount(500) + 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(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)"), + 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.ActivationReason.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.MessageIcon.Information, msecs) + + def _notification_clicked(self): + 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)) + + 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 + switch.set_quiet(not on) + QMessageBox.warning(self, APP, _("Das ging nicht: {error}").format(error=e)) + + # ---- other starts and the share menu ------------------------------------- + + 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) + elif msg.get("show"): + self._show() + + def open_send_dialog(self, files): + """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() + 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): + self.showNormal() + self.raise_() + self.activateWindow() + + 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) + 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() + + 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() + + # ---- session events ---------------------------------------------------------- + + def _on_event(self, kind, info): + if kind == "device": + 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", "") + self.device_label.setToolTip(f"ESP {mac}" if mac else "") + 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") + # 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._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": + text = power_text(info.get("text", "")) + 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.setEnabled(not info["on"] and self._connected) + self._update_devices_ui() + elif kind == "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(r) + elif kind == "receiving": + self._receiving_ui(info["on"], info.get("until")) + 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 sheet close: say how it went.""" + if self.isVisible() or self._send_dialogs: + return + t, state = info["transfer"], info["state"] + if state == "done": + self._notify(_("Gesendet an {name}").format(name=t.receiver.label)) + elif state in ("declined", "failed"): + self._notify(_("Nicht gesendet an {name}").format(name=t.receiver.label) + ": " + + (_("abgelehnt") if state == "declined" else str(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")) + 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") + + 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 += _(" · 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_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}") + + def _set_device_ui(self, 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): + 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): + 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()) + 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.") + else: + 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 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 + 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" + + 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): + _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(_("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(_("{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()) + 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): + 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 + if ask and ask.urls and not ask.files: # a link: no upload follows + copied = self._to_clipboard(urls=ask.urls) + 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.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: + 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": + row = self._recv_row + self._recv_row = None + if row is None: + return + files = info.get("files") or [] + if info.get("ok"): + names = ", ".join(os.path.basename(f) for f in files) + row.set_title(names) + row.set_state(_("Empfangen · {size}").format(size=human(info.get("size", 0))), + files=files, tone="done") + copied = self._to_clipboard(files=files) + self._notify(_("Empfangen: {names}").format(names=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 []), + tone="bad") + + 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(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() + self.settings.setValue("name", self.name_edit.text().strip()) + if name == self.session.name: + return + 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 _tile_clicked(self, rid): + tile = self.devices.tiles.get(rid) + 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.")) + return + try: + t = self.session.send(rid, files) + except Exception as e: # noqa: BLE001 + 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}) + + def _send_row(self, info): + t = info["transfer"] + key = ("send", t.id) + 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"] + 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 .transport import candidates + 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 == 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())) + 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") + listen = next((w[7:] for w in words if w.startswith("listen=")), "off") + 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 _("Funk an ({why})").format(why=_(why)) if why else _("Funk an") + + +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 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. 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") + 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. + 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: + 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 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) + 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) + 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() + _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 + 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), + **integration.NO_STDIO) + return rc diff --git a/host/espdrop/i18n.py b/host/espdrop/i18n.py new file mode 100644 index 0000000..75194b7 --- /dev/null +++ b/host/espdrop/i18n.py @@ -0,0 +1,309 @@ +# 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.", + # ---- 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)", + "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/install.py b/host/espdrop/install.py new file mode 100644 index 0000000..595db2d --- /dev/null +++ b/host/espdrop/install.py @@ -0,0 +1,185 @@ +# 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, + **integration.NO_STDIO) + 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, **integration.NO_STDIO) + + +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 new file mode 100644 index 0000000..d353a60 --- /dev/null +++ b/host/espdrop/integration.py @@ -0,0 +1,418 @@ +# 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 +# - 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 +# (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 + +from .i18n import N_, _ + +IS_WINDOWS = os.name == "nt" +try: + import winreg # Windows only +except ImportError: + 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" +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 + 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") + 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 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}.""" + if IS_WINDOWS: + return {"autostart": winreg is not None, + "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": N_("Im Dateimanager anbieten"), + "send_hint": N_("„Öffnen mit → AirDrop (EspDrop)“")} + return {"autostart": False, "send": None, "send_hint": 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), "Icon": ICON or "document-send", "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: + 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", + # Common types; "Open with other application" lists the app for any. + "MimeType": ";".join(LINUX_MIME) + ";"}) + elif os.path.exists(path): + os.remove(path) + changed = True + else: + changed = False + if changed: + _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: + # 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_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 = $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, stdin=subprocess.DEVNULL, + creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0)) + 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) + + +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)) + + +# ---- 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, stdin=subprocess.DEVNULL, + 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 + "Senden an" shortcut costs a PowerShell start).""" + set_launcher() + 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, "") != _(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) + 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 = [] + 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: + 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): + 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: + 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): + 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_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") + + +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): + """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(text) + os.replace(tmp, path) + return True + + +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/host/espdrop/link.py b/host/espdrop/link.py new file mode 100644 index 0000000..bbb1a4e --- /dev/null +++ b/host/espdrop/link.py @@ -0,0 +1,228 @@ +# 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_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 +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 +CAP_SCAN = 0x04 # `power listen`: wake AWDL on AirDrop adverts + +# 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(" bytes: + return frame(MSG_TCP_LISTEN, struct.pack(" 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(" 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, self._on_udp) + 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 + + +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, self._on_udp) + except Exception: + pass diff --git a/host/espdrop/proxy.py b/host/espdrop/proxy.py new file mode 100644 index 0000000..a620519 --- /dev/null +++ b/host/espdrop/proxy.py @@ -0,0 +1,261 @@ +# 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() + + 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): + """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+) + 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. + 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 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) + + 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 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: + 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/espdrop/receive.py b/host/espdrop/receive.py new file mode 100644 index 0000000..f820022 --- /dev/null +++ b/host/espdrop/receive.py @@ -0,0 +1,337 @@ +# 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. 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 shutil +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, 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): + 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 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) + + +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 + + +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 + 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). + + 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, 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 + 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): + 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_names = [n for n in new if 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) + 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=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.abort() + 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, + interval=self.announce_interval, + 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)", + 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..91acc48 --- /dev/null +++ b/host/espdrop/session.py @@ -0,0 +1,532 @@ +# 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 (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= +# "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__) + +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.""" + + 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._receive_until = None # end of a receive window (monotonic) + 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 + 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: + 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() + + 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, 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) + 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.""" + with self._lock: + self.name = name + if self._receiver: + self._stop_receiver() + self._start_receiver() + + def set_download_dir(self, path): + """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) + 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: + 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._power() + with self._lock: + if self._browser: + 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() + self._power() + + @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 + self._power() + 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) + info["firmware"] = self._firmware(dev) + except Exception: + dev.close() + raise + except Exception as e: # noqa: BLE001 - not there yet: try again + # 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) + 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 = self._listening = None # re-send on the new device + self._power() + 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") + + @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): + if dev.error: + return + 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): + """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 + # 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, announce_interval=2.5, + dest_dir=self.download_dir) + 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() + self._power() + + 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 _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): + """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 + 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(text) + except Exception as e: # noqa: BLE001 + self._emit("log", text=f"{text}: {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/espdrop/transport.py b/host/espdrop/transport.py new file mode 100644 index 0000000..69e4476 --- /dev/null +++ b/host/espdrop/transport.py @@ -0,0 +1,132 @@ +# 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 + 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 + + def read(self, timeout: float) -> bytes: + 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: + if timeout != self._wt: + self.ser.write_timeout = self._wt = 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/espdrop/ui.py b/host/espdrop/ui.py new file mode 100644 index 0000000..da5c012 --- /dev/null +++ b/host/espdrop/ui.py @@ -0,0 +1,1436 @@ +# 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 .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, + 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", "red_text": "#D70015", + "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", "red_text": "#FF6961", + "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): + c = QColor(T[key]) + if alpha is not None: + c.setAlpha(alpha) + return c + + +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" + 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.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 = { + 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="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']}; }} +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; }} +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; + 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 + + +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(" '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.setProperty("glyph", glyph) # refresh() redraws it in a new theme + 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": N_("Warten …"), "sent": N_("Gesendet"), "declined": N_("Abgelehnt"), + "failed": N_("Fehlgeschlagen"), "cancelled": N_("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 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 _("Senden … {percent} %").format(percent=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/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/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/host/tests/apple_mdns.py b/host/tests/apple_mdns.py new file mode 100644 index 0000000..4931786 --- /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, 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." + 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(port)), + (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(port)), + (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..b2d58a0 --- /dev/null +++ b/host/tests/fake_device.py @@ -0,0 +1,353 @@ +# 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 +from espdrop.version import __version__ + +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"), 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 = [] + 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, 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(" 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(" 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(" "./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.""" + + 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 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" + "\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() + 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 setUp(self): + try: + import serial # noqa: F401 + except ImportError: + self.skipTest("pyserial not installed") + + 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 open(self): + self.at_open = (self.port, self.dtr, self.rts) + + def read(self, n): + return b"" + + def write(self, data): + return len(data) + + 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") + 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_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("