diff --git a/.github/workflows/ci-windows.yml b/.github/workflows/ci-windows.yml new file mode 100644 index 000000000..b0bbab43f --- /dev/null +++ b/.github/workflows/ci-windows.yml @@ -0,0 +1,116 @@ +name: Windows CI (frontend) + +# Builds the native Windows frontend: the Rust daemon (librepodsd) and the WinUI 3 +# app (librepods-winui, which carries its own tray). The two kernel drivers +# (AAP L2CAP + hi-res mic) are NOT built here — they need the WDK and test/EV +# signing, which is a separate, manual pipeline. + +on: + push: + branches: [main, windows-native] + tags: ['windows-v*'] + paths: + - 'windows/**' + - '.github/workflows/ci-windows.yml' + pull_request: + paths: + - 'windows/**' + workflow_dispatch: + +jobs: + build: + runs-on: windows-latest + + steps: + - uses: actions/checkout@v4 + + # ---- Rust: daemon (native msvc target) --------------------------------- + - name: Install Rust + uses: dtolnay/rust-toolchain@stable + + - name: Cache Cargo + uses: actions/cache@v4 + with: + path: | + ~/.cargo/registry + ~/.cargo/git + windows/daemon/target + key: ${{ runner.os }}-cargo-${{ hashFiles('windows/**/Cargo.lock') }} + + - name: Fetch FFmpeg (AAC-ELD decode libs — not vendored in git) + shell: bash + run: windows/daemon/fetch-ffmpeg.sh + + - name: Build daemon (librepodsd) + working-directory: windows/daemon + run: cargo build --release + + # ---- WinUI 3 app (unpackaged, self-contained) -------------------------- + # WinUI resource generation runs the PRI MSBuild task + # (Microsoft.Build.Packaging.Pri.Tasks.dll), which is a .NET *Framework* + # assembly. `dotnet build` hosts MSBuild on .NET (Core) and cannot load it + # → MSB4062. Visual Studio's msbuild.exe is .NET Framework and loads it, so + # build the WinUI project with VS MSBuild. The .NET 10 SDK is still needed + # for the net10.0 target resolver. + - name: Setup .NET 10 + uses: actions/setup-dotnet@v4 + with: + dotnet-version: '10.0.x' + + - name: Setup MSBuild + uses: microsoft/setup-msbuild@v2 + + - name: Restore WinUI app + working-directory: windows/winui + run: msbuild LibrePods.WinUI/LibrePods.WinUI.csproj -t:Restore -p:Configuration=Release -p:Platform=x64 + + - name: Build WinUI app + working-directory: windows/winui + run: msbuild LibrePods.WinUI/LibrePods.WinUI.csproj -t:Build -p:Configuration=Release -p:Platform=x64 -p:RestorePackages=false + + # ---- Collect + upload artifacts ---------------------------------------- + - name: Collect artifacts + shell: pwsh + run: | + # Lay out dist/ exactly how windows/installer/install.ps1 expects it, so + # the zip is a ready-to-run installer bundle. + New-Item -ItemType Directory -Force dist | Out-Null + Copy-Item windows/daemon/target/release/librepodsd.exe dist/ + # FFmpeg runtime DLLs the daemon loads for AAC-ELD decode (fetched, not + # vendored) — must sit next to librepodsd.exe. + Copy-Item windows/daemon/vendor/ffmpeg/bin/*.dll dist/ + # Prebuilt, test-signed kernel drivers, in the installer's layout. + Copy-Item -Recurse windows/drivers/aap/prebuilt dist/driver + Copy-Item -Recurse windows/drivers/mic/prebuilt dist/driver-mic + # The one-shot installer + its bundled devcon. + Copy-Item windows/installer/install.ps1 dist/ + Copy-Item -Recurse windows/installer/tools dist/tools + # Find the WinUI output dir by locating the exe (path varies with TFM/RID). + $exe = Get-ChildItem -Recurse -Filter librepods-winui.exe ` + windows/winui/LibrePods.WinUI/bin | Select-Object -First 1 + if (-not $exe) { throw "WinUI build output (librepods-winui.exe) not found" } + Copy-Item -Recurse $exe.Directory.FullName dist/winui + + - name: Upload artifact + uses: actions/upload-artifact@v4 + with: + name: librepods-windows-frontend + path: dist + if-no-files-found: error + + # ---- Release (only on a windows-v* tag) -------------------------------- + - name: Package release zip + if: startsWith(github.ref, 'refs/tags/windows-v') + shell: pwsh + run: Compress-Archive -Path dist/* -DestinationPath "librepods-windows-${{ github.ref_name }}.zip" + + - name: Create GitHub Release + if: startsWith(github.ref, 'refs/tags/windows-v') + uses: softprops/action-gh-release@v2 + with: + tag_name: ${{ github.ref_name }} + name: LibrePods for Windows ${{ github.ref_name }} + files: librepods-windows-${{ github.ref_name }}.zip + generate_release_notes: true + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/AAP Definitions.md b/AAP Definitions.md new file mode 100644 index 000000000..9f79d8e64 --- /dev/null +++ b/AAP Definitions.md @@ -0,0 +1,768 @@ +# AAP Definitions (As per AirPods Pro 2 (USB-C) Firmware 7A305) + +AAP runs on top of L2CAP, with a PSM of 0x1001 or 4097. + +# Handshake +This packet is necessary to establish a connection with the AirPods. Or else, the AirPods will not respond to any packets. + +```plaintext +00 00 04 00 01 00 02 00 00 00 00 00 00 00 00 00 +``` + +# Setting specific features for AirPods Pro 2 + +> *may work for airpods 4 anc also, not tested* + +Since apple likes to wall off some features behind specific OS versions, and apple silicon devices, some packets are necessary to enable these features. + +I captured the following packet only accidentally, because Apple being Apple decided to hide *this* and *the handshake* from packetlogger, but sometimes it shows up. + +*Captured using PacketLogger on an Intel Mac running macOS Sequoia 15.0.1* +```plaintext +04 00 04 00 4d 00 ff 00 00 00 00 00 00 00 +``` + +This packet enables conversational awareness when playing audio. (CA works without this packet only when no audio is playing) + +It also enables the Adaptive Transparency feature. (We can set Adaptive Transparency, but it doesn't respond with the same packet See [Noise Cancellation](#changing-noise-control)) + +# Requesting notifications + +This packet is necessary to receive notifications from the AirPods like ear detection, noise control mode, conversational awareness, battery status, etc. + +*Captured using PacketLogger on an Intel Mac running macOS Sequoia 15.0.1* +```plaintext +04 00 04 00 0F 00 FF FF FE FF +``` + +This packet also works. + +```plaintext +04 00 04 00 0F 00 FF FF FF FF +``` + +# Notifications + +## Battery + +AirPods occasionally send battery status packets. The packet format is as follows: + +```plaintext +04 00 04 00 04 00 [battery count] ([component] 01 [level] [status] 01) times the battery count +``` + +| Components | Byte value | +|-----------------|------------| +| Headphone* | 01 | +| Case | 08 | +| Left | 04 | +| Right | 02 | + +*The `Headphone` component only exists on over-ear models (AirPods Max). On +earbuds (AirPods Pro/regular) the slot may still be reported with a **level of +`0xFF` (255)**, which means *absent* — treat it as "no such component" and skip +it (otherwise it renders as a bogus "255%"). + +| Status | Byte value | +|----------------------|------------| +| Unknown | 00 | +| Charging | 01 | +| Discharging | 02 | +| Disconnected | 04 | +| Charging (in case)** | 05 | + +**`0x05` is reported for an earbud that is **charging inside the case** (the +iPhone shows it charging with its current level). Discovered empirically — it was +previously only handled in the app's parser (`aacp.rs`), not documented here nor +in the Windows daemon's separate parser. Treat `0x01` and `0x05` both as +*charging*. + + +Example packet from AirPods Pro 2 + +```plaintext +04 00 04 00 04 00 03 02 01 64 02 01 04 01 63 01 01 08 01 11 02 01 +``` + +| Byte | Interpretation | +|-----------|------------------------------------| +| 7th byte | Battery Count - 3 | +| 8th byte | Battery type - Left | +| 9th byte | Spacer, value = 0x01 | +| 10th byte | Battery level 100% | +| 11th byte | Battery status - Discharging | +| 12th byte | Battery component end value = 0x01 | +| 13th byte | Battery type - Right | +| 14th byte | Spacer, value = 0x01 | +| 15th byte | Battery level 99% | +| 16th byte | Battery status - Charging | +| 17th byte | Battery component end value = 0x01 | +| 18th byte | Battery type - Case | +| 19th byte | Spacer, value = 0x01 | +| 20th byte | Battery level 17% | +| 21st byte | Battery status - Discharging | +| 22nd byte | Battery component end value = 0x01 | + +## Noise Control + +The AirPods Pro 2 send noise control packets when the noise control mode is changed (either by a stem long press or by the connected device, see [Changing noise control](#changing-noise-control)). The packet format is as follows: + +```plaintext +04 00 04 00 09 00 0D [mode] 00 00 00 +``` + +| Noise Control Mode | Byte value | +|-----------------------|------------| +| Off | 01 | +| Noise Cancellation | 02 | +| Transparency | 03 | +| Adaptive Transparency | 04 | + +## Ear Detection + +AirPods send ear detection packets when the ear detection status changes. The packet format is as follows: +```plaintext +04 00 04 00 06 00 [primary pod] [secondary pod] +``` + +If primary is removed, mic will be changed and the secondary will be the new primary, so the primary will be the one in the ear, and the packet will be sent again. + +| Pod Status | Byte value | +|------------|------------| +| In Ear | 00 | +| Out of Ear | 01 | +| In Case | 02 | + +## Conversational Awareness + +AirPods send conversational awareness packets when the person wearing them start speaking. The packet format is as follows: + +```plaintext +04 00 04 00 4B 00 02 00 01 [level] +``` + +| Level Byte Value | Meaning | +|---------------------|---------------------------------------------------------| +| 01/02 | Person Started Speaking; greatly reduce volume | +| 03 | Person Stopped Speaking; increase volume back to normal | +| Intermediate values | Intermediate volume levels | +| 08/09 | Normal Volume | +### Reading Conversational Awareness State + +After requesting notifications, the AirPods send a packet indicating the current state of Conversational Awareness (CA). This packet is only sent once after notifications are requested, not when the CA state is changed. + +The packet format is: + +```plaintext +04 00 04 00 09 00 28 [status] 00 00 00 +``` + +- `[status]` is a single byte at offset 7 (zero-based), immediately after the header. + - `0x01` — Conversational Awareness is **enabled** + - `0x02` — Conversational Awareness is **disabled** + - Any other value — Unknown/undetermined state + +**Example:** +```plaintext +04 00 04 00 09 00 28 01 00 00 00 +``` +Here, `01` at the 8th byte (offset 7) means CA is enabled. + +## Metadata + +This packet contains device information like name, model number, etc. The packet format is: + +```plaintext +04 00 04 00 1d [strings...] +``` + +The strings are null-terminated UTF-8 strings in the following order: + +1. Bluetooth advertising name (varies in length) +2. Model number +3. Manufacturer +4. Serial number +5. Firmware version +6. Firmware version 2 (the exact same as before??) +7. Software version (1.0.0 why would we need it?) +8. App identifier (com.apple.accessory.updater.app.71 what?) +9. Serial number 1 +10. Serial number 2 +11. Unknown numeric value +12. Encrypted data +13. Additional encrypted data + +Example packet: +```plaintext +040004001d0002d5000400416972506f64732050726f004133303438004170706c6520496e632e0051584e524848595850360036312e313836383034303030323030303030302e323731330036312e313836383034303030323030303030302e3237313300312e302e3000636f6d2e6170706c652e6163636573736f72792e757064617465722e6170702e3731004859394c5432454632364a59004833504c5748444a32364b3000363335373533360089312a6567a5400f84a3ca234947efd40b90d78436ae5946748d70273e66066a2589300035333935303630363400``` + +The packet contains device identification and version information followed by some encrypted data whose format is not known. +``` + +# Writing to the AirPods + +## Changing Noise Control + +We can send a packet to change the noise control mode. The packet format is as follows: + +```plaintext +04 00 04 00 09 00 0D [mode] 00 00 00 +``` + +| Noise Control Mode | Byte value | +|-----------------------|------------| +| Off | 01 | +| Noise Cancellation | 02 | +| Transparency | 03 | +| Adaptive Transparency | 04 | + +The airpods will respond with the same packet after the mode has been changed. + +> But if your airpods support Adaptive Transparency, and you haven't sent that [special packet](#setting-specific-features-for-airpods-pro-2) to enable it, the airpods will respond with the same packet but with a different mode (like 0x02). + +## Renaming AirPods + +We can send a packet to rename the AirPods. The packet format is as follows: + +```plaintext +04 00 04 00 1A 00 01 [size] 00 [name] +``` + +## Toggle case charging sounds + +> *This feature is only for cases with a speaker, i.e. the AirPods Pro 2 and the new AirPods 4. Tested only on AirPods Pro 2* + +We can send a packet to toggle if sounds should be played when the case is connected to a charger. The packet format is as follows: + +```plaintext +12 3A 00 01 00 08 [setting] +``` + +| Byte Value | Sound | +|------------|-------| +| 00 | On | +| 01 | Off | + +## Toggle Conversational Awareness + +> *This feature is only for AirPods Pro 2 and the new AirPods 4 with ANC. Tested only on AirPods Pro 2* + +We can send a packet to toggle Conversational Awareness. If enabled, the AirPods will switch to Transparency mode when the person wearing them starts speaking (and sends packet for notifying the device to reduce volume). The packet format is as follows: + +```plaintext +04 00 04 00 09 00 28 [setting] 00 00 00 +``` + +| Byte Value | C.A. | +|------------|------| +| 01 | On | +| 02 | Off | + +## Adaptive Audio Noise + +> *This feature is only for AirPods Pro 2 and the new AirPods 4 with ANC. Tested only on AirPods Pro 2* + +The new firmware `7A305` for app2 has a new feature called Adaptive Audio Noise. This allows us to control how much noise is passed through the AirPods when the noise control mode is set to Adaptive. The packet format is as follows: + +```plaintext +04 00 04 00 09 00 2E [level] 00 00 00 +``` + +The level can be any value between 0 and 100, 0 to allow maximum noise (i.e. minimum noise filtering), and 100 to filter out more noise. + +> This feature is only effective when the noise control mode is set to Adaptive. + +*I find it quite funny how I have greater control over the noise control on the AirPods on non-Apple devices than on Apple devices, becuase on Apple Devices, there are just 3 options More Noise (0), Midway through (50), and Less Noise (100), but here I can set any value between 0 and 100.* + +## Accessiblity Settings + +## Headphone Accomodation +``` +04 00 04 00 53 00 84 00 02 02 [Phone] [Media] +[EQ1][EQ2][EQ3][EQ4][EQ5][EQ6][EQ7][EQ8] +duplicated thrice for some reason +``` + +| Data | Type | Value range | +|---------------------|---------------|-----------------------------| +| Phone | Decimal | 1 (Enabled) or 2 (Disabled) | +| Media | Decimal | 1 (Enabled) or 2 (Disabled) | +| EQ | Little Endian | 0 to 100 | + +## Customize Transparency mode + +``` +12 18 00 [enabled] + +[EQ1][EQ2][EQ3][EQ4][EQ5][EQ6][EQ7][EQ8] +[Amplification] +[Tone] +[Conversation Boost] +[Ambient Noise Reduction] + +``` + + +All values are formatted as IEEE 754 floats in little endian order. +| Data | Type | Range | +|-------------------------|---------------|-------| +| Enabled | IEEE754 Float | 0/1 | +| EQ | IEEE754 Float | 0-100 | +| Amplification | IEEE754 Float | 0-2 | +| Tone | IEEE754 Float | 0-2 | +| Conversation Boost | IEEE754 Float | 0/1 | +| Ambient Noise Reduction | IEEE754 Float | 0-1 | +| Ambient Noise Reduction | IEEE754 Float | 0-1 | + +> [!IMPORTANT] +> Also send the [Headphone Accomodation](#headphone-accomodation) after this. + + +## Configure Stem Long Press + +I have noted all the packets sent to configure what the press and hold of the steam should do. The packets sent are specific to the current state. And are probably overwritten everytime the AirPods are connected to a new (apple) device that is not synced with icloud (i think)... So, for non-Apple device too, the configuration needs to be stored and overwritten everytime the AirPods are connected to the device. That is the only way to keep the configuration. + +This is also the only way to control the configuration as the previous state needs to be known, and then the new state can be set. + +The packets sent (based on the previous states) are as follows: + +
+Toggling Adaptive + +04 00 04 00 09 00 1A 0B 00 00 00 - Turns on Adaptive from O and ANC +04 00 04 00 09 00 1A 0D 00 00 00 - Turns on Adaptive from O and T +04 00 04 00 09 00 1A 0E 00 00 00 - Turns on Adaptive from T and ANC +04 00 04 00 09 00 1A 0F 00 00 00 - Turns on Adaptive from O, T, ANC + +04 00 04 00 09 00 1A 03 00 00 00 - Turns off Adaptive from O and ANC (and Adaptive) +04 00 04 00 09 00 1A 05 00 00 00 - Turns off Adaptive from O and T (and Adaptive) +04 00 04 00 09 00 1A 06 00 00 00 - Turns off Adaptive from T and ANC (and Adaptive) +04 00 04 00 09 00 1A 07 00 00 00 - Turns off Adaptive from O, T, ANC (and Adaptive) + +
+ +
+Toggling Transparency + +04 00 04 00 09 00 1A 07 00 00 00 - Turns on Transparency from O and ANC +04 00 04 00 09 00 1A 0D 00 00 00 - Turns on Transparency from O and Adaptive +04 00 04 00 09 00 1A 0E 00 00 00 - Turns on Transparency from Adaptive, and ANC +04 00 04 00 09 00 1A 0F 00 00 00 - Turns on Transparency from O and Adaptive and ANC + +04 00 04 00 09 00 1A 03 00 00 00 - Turns off Transparency from O and ANC (and Transparency) +04 00 04 00 09 00 1A 09 00 00 00 - Turns off Transparency from O and Adaptive (and Transparency) +04 00 04 00 09 00 1A 0A 00 00 00 - Turns off Transparency from Adaptive, and ANC (and Transparency) +04 00 04 00 09 00 1A 0B 00 00 00 - Turns off Transparency from O and Adaptive and ANC (and Transparency) + +
+ +
+Toggling ANC + +04 00 04 00 09 00 1A 07 00 00 00 - Turns on ANC from O, and Transparency +04 00 04 00 09 00 1A 0B 00 00 00 - Turns on ANC from O, and Adaptive +04 00 04 00 09 00 1A 0E 00 00 00 - Turns on ANC from Adaptive, and Transparency +04 00 04 00 09 00 1A 0F 00 00 00 - Turns on ANC from O and Adaptive and Transparency + +04 00 04 00 09 00 1A 05 00 00 00 - Turns off ANC from O and Transparency (and ANC) +04 00 04 00 09 00 1A 09 00 00 00 - Turns off ANC from O and Adaptive (and ANC) +04 00 04 00 09 00 1A 0C 00 00 00 - Turns off ANC from Adaptive, and Transparency (and ANC) +04 00 04 00 09 00 1A 0D 00 00 00 - Turns off ANC from O and Adaptive and Transparency (and ANC) + +
+ +
+Toggling O + +04 00 04 00 09 00 1A 07 00 00 00 - Turns on O from Transparency, and ANC +04 00 04 00 09 00 1A 0B 00 00 00 - Turns on O from Adaptive, and ANC +04 00 04 00 09 00 1A 0D 00 00 00 - Turns on O from Transparency, and Adaptive +04 00 04 00 09 00 1A 0F 00 00 00 - Turns on O from Transparency, and Adaptive, and ANC + +04 00 04 00 09 00 1A 06 00 00 00 - Turns off O from Transparency, and ANC (and O) +04 00 04 00 09 00 1A 0A 00 00 00 - Turns off O from Adaptive, and ANC (and O) +04 00 04 00 09 00 1A 0C 00 00 00 - Turns off O from Transparency, and Adaptive (and O) +04 00 04 00 09 00 1A 0E 00 00 00 - Turns off O from Transparency, and Adaptive, and ANC (and O) + +
+ +> *i do hate apple for not hardcoding these, like there are literally only 4^2 - ${\binom{4}{1}}$ - $\binom{4}{2}$* + +# Head Tracking + +## Start Tracking + +This packet initiates head tracking. When sent, the AirPods begin streaming head tracking data (e.g. orientation and acceleration) for live plotting and analysis. + +```plaintext +04 00 04 00 17 00 00 00 10 00 10 00 08 A1 02 42 0B 08 0E 10 02 1A 05 01 40 9C 00 00 +``` + +## Stop Tracking + +This packet stops the head tracking data stream. + +```plaintext +04 00 04 00 17 00 00 00 10 00 11 00 08 7E 10 02 42 0B 08 4E 10 02 1A 05 01 00 00 00 00 +``` +## Received Head Tracking Sensor Data + +Once tracking is active, the AirPods stream sensor packets with the following common structure: + +| Field | Offset | Length (bytes) | +|--------------------------|--------|----------------| +| orientation 1 | 43 | 2 | +| orientation 2 | 45 | 2 | +| orientation 3 | 47 | 2 | +| Horizontal Acceleration | 51 | 2 | +| Vertical Acceleration | 53 | 2 | + +# Starting and Stopping Sensor Streams + +Captured from **iOS 26.5.2 ↔ AirPods Pro 3 (firmware 8B41)** with `idevicebtlogger`, +across three sessions. See `windows/docs/aap-packet-discovery.md` for the method. + +Sensor streams are started and stopped with the **same `0x17` … `42 0B` frame family as Head +Tracking above** — not with a different mechanism. The frame carries a stream id and a +sampling period, and **a period of zero is the stop**: + +```plaintext +04 00 04 00 17 00 00 00 10 00 10 00 08 98 01 42 0b 08 13 10 02 1a 05 01 40 42 0f 00 + ^^^^^ ^^^^^^^^ ^^^^^ ^^ ^^^^^^^^^^^ + len seq varint stream id md period µs LE +``` + +| Field | Meaning | +|---|---| +| length | little-endian; the **real payload byte count**, so it moves with the form and the varint width | +| seq | varint, increments per control frame | +| stream id | `08 ` inside the `42 0B` block | +| mode | `1`, `2` or `4` — meaning unresolved | +| period | little-endian u32, **microseconds**; `0` = stop the stream | + +## Two forms of the frame + +**Mixing them produces a packet that appears in no capture.** Across 24 observed control +frames the correlation is exact, with no exceptions: + +| Form | Between the sequence and `42 0B` | Stream id | Length seen | +|---|---|---|---| +| **A** | nothing | bare: `0x10`, `0x12`, `0x13` | `0x10` | +| **B** | `10 02` | bit `0x40` set: `0x50`, `0x52`, `0x53` | `0x11`, `0x12` | + +The `10 02` field and the `0x40` bit always travel together. Form B also matches the Head +Tracking stop frame documented above (`08 4E`, type 14 with the bit set). + +**Which form appears depends on when the capture started.** Three captures that began +mid-session show only form B. The one capture that recorded a connection from scratch shows +form A for the stream it starts, alongside some form B traffic — but never `0x53`. So a client +establishing its own session should send **form A with the bare data type**. + +Observed stream ids: + +| Stream id | Period sent | Rate | Carries | +|---|---|---|---| +| `0x13` / `0x53` | `1000000` | 1 Hz | **heart rate** (data type 19) | +| `0x10` / `0x50` | `20000` | 50 Hz | raw PPG (data type 16) | +| `0x12` / `0x52` | `200` | — | the worn-state sensor (data type 18) | + +## Worked example — a full heart-rate session + +From the capture that recorded the connection from scratch, one clock: + +```plaintext +t=364.35 → 17 … 08 13 … period 1000000 start heart rate at 1 Hz +t=364.39 → 17 … 08 10 … period 20000 start raw PPG at 50 Hz +t=365.92 ← first heart-rate frame (1.57 s after the start frame) + … +t=438.15 → 17 … 08 10 … period 0 stop raw PPG +t=472.02 ← heart-rate frames still arriving +``` + +107 heart-rate frames at 1 Hz. Note the stream **outlives the workout**: raw PPG was stopped +34 s before the last heart-rate frame, and iOS had not yet sent the heart-rate stop. A client +must not assume the stream ends when the user ends the activity. + +## Notes for `windows/daemon/src/aap.rs` + +The original `HR_START` / `HR_STOP` constants were **form A and structurally correct** — same +length, same absent `10 02`, same bare `08 13`, same period. Only the sequence varint differed, +which is expected. An intermediate revision rewrote them as form B on the strength of the +mid-session captures; that was wrong and has been reverted. + +Reported symptom that prompted the recheck, on a daemon sending form B into a freshly +established session: raw PPG came up at 50 Hz, heart rate never did. + +## Opcode 0x44 + +Distinct from the above and **not** the mechanism that starts streams. Framing is settled: + +```plaintext +04 00 04 00 44 00 04 00 02 00 03 07 + ^^^^^ ^^^^^ ^^^^^^^^^^^ + op len payload +``` + +The length field is confirmed by a 14-byte variant: + +```plaintext +04 00 04 00 44 00 0e 00 03 00 02 01 00 00 23 0c 77 6a 00 00 00 00 +``` + +**Payload semantics remain unresolved.** In the 4-byte form the first three bytes were +`02 00 03` in every occurrence across all three captures and only the trailing byte varied +(`01`, `02`, `06`, `07`). It is *not* a sensor id list: `02 00 03 07` occurs without any stream +starting, and `02 00 03 01` / `02 00 03 06` start nothing at all. It does consistently appear +tens of milliseconds *before* the `0x17` control frames at a session change — 20 ms before in +the worked example above — so it reads as session or configuration signalling that brackets a +stream change rather than causing it. + +Observed sensors (`field 2` of the `0x17` protobuf): + +| Sensor id | Data type | Rate | Notes | +|---|---|---|---| +| 3 | 16 | ~50 Hz | raw PPG samples | +| 3 | 19 | 1 Hz | **heart rate** (see below) | +| 7 | 18 | ~5 Hz | **tracks the worn state** — see below | +| 1, 2 | — | bursty | seen only around reconnection and case transitions | + +### Sensor 7 follows the worn state, not audio playback + +Worth recording as a method note, because the two captures differ in exactly the confounding +variable and it would otherwise be easy to get this wrong. + +| | audio playing | worn | sensor 7 streaming | +|---|---|---|---| +| Capture 1 (workout) | no | entire 93 s — zero ear-detection events | **0.0 → 93.0 s**, i.e. throughout | +| Capture 2 (case) | yes, until ~52 s | until 53.06 s | **0.0 → 52.8 s** | + +In capture 2 alone, sensor 7 stopping looks like it tracks playback — the music was stopped +immediately before the buds were removed, so the two events are 260 ms apart and +indistinguishable. Capture 1 separates them: **no audio played at any point, yet sensor 7 +streamed for the full 93 s while the buds stayed in the ears.** + +So sensor 7 is tied to the buds being worn, not to playback. Its last packet precedes the +first ear-detection state change by 260 ms, which fits a motion or proximity sensor reacting +before the in-ear determination is published. + +## Received Heart Rate Data + +Sensor 3, protobuf field `0x3a`, inner type `19` (`0x13`), one packet per second: + +```plaintext +04 00 04 00 17 00 00 00 10 00 00 08 10 03 3a 08 13 1a 12 01 5D E1 07 00 02 … + ^^^^^ ^^ ^^ ^^ ^^ + type 19 bpm cf sq state + 93 225 7 locked +``` + +`1a 12` introduces an **18-byte payload**. Offsets below are relative to that payload, +matching `HEART_RATE_BPM_OFFSET` / `HEART_RATE_STATUS_TAIL_OFFSET` in +`windows/daemon/src/hr.rs`: + +| Field | Offset | Length | Meaning | +|---|---|---|---| +| subtype | 0 | 1 | `01` | +| **heart rate** | 1 | 1 | BPM, unsigned, direct value | +| confidence | 2 | 1 | `20` while settling, rises to ~`236` once locked | +| counter | 3 | 1 | increments by 1 per reading (confirms 1 Hz) | +| state | 5 | 1 | `1` = acquiring, `2` = locked | +| timestamp | 6 | 6 | little-endian | +| status tail | 15 | 3 | see below | + +**The first readings must be discarded.** In the reference capture the sequence opened +169 → 136 → 98 → 93 BPM with `confidence = 20` and `state = 1`, then settled at 91 BPM the +moment `state` flipped to `2` and confidence jumped to 189 — four readings, ~4 s from stream +start. The remaining 58 readings spanned 86–102 BPM, mean 93.1, tracking a plausible curve +for light activity. + +The existing status-tail filter in `hr.rs` already does exactly this job. Tails observed: + +| Tail | n | In `KNOWN_HEART_RATE_STATUS_TAILS` | `state` | +|---|---|---|---| +| `10 00 00` | 58 | yes | 2 (locked) | +| `10 02 81` | 3 | no | 1 (acquiring) | +| `10 82 81` | 1 | no | 1 (acquiring) | + +So the decoder accepted 58/62 and rejected precisely the four settling readings — the tail +filter and a `state == 2` test agree exactly on this capture. `10 02 81` / `10 82 81` are new +variants of the known `20 02 80` / `20 82 80` pair; they should **not** be added to the accept +list, since they mark unlocked readings. + +# Case and Charging Transitions + +Second capture, same rig (iOS 26.5.2 ↔ AirPods Pro 3, fw 8B41). Flow: **worn with music +playing** → playback stopped → removed from ears → placed in the open case → case +interaction → lid closed → lid reopened → idle. 176 s, 810 AAP packets. + +The music phase was not part of the intended test, and playback stopping happened to coincide +with the buds being removed — see the sensor 7 note below for why that nearly produced a wrong +conclusion. + +## Charging status: what selects `0x01` versus `0x05` + +`## Battery` above already documents `0x05` as *charging (in case)* and advises treating it +and `0x01` alike. This capture supports that advice and adds why it is needed: the two are not +alternative encodings chosen by firmware or model, they are **consecutive states of the same +charging session**, so an implementation that handles only one of them will work for part of +the time and then stop working. + +| t (s) | observation | +|---|---| +| 13.1 | baseline — both buds `not-charging` (`0x02`), case `disconnected` (`0x04`) | +| 78.1 | first bud enters case → **`0x05` charging-in-case**, immediately | +| 79.0 | case starts reporting a real level (one transient `0xFF` frame first) | +| 80.3 | second bud enters case → **`0x05`** | +| 106.8 | **both buds flip to `0x01` charging**, simultaneously, ~26 s after insertion | +| 145.9 | both levels have risen by 1 % — charging did occur | + +**`0x05` is not a mandatory precursor to `0x01`.** A controlled follow-up refuted the obvious +reading of the table above. One bud was seated in the case with the lid open while the other +stayed in an ear as a control, and nothing was touched for 240 s: + +| t (s) | observation | +|---|---| +| 94.2 | bud seated → **`0x01` charging immediately**, no `0x05` at any point; case reporting 60 % | +| 255.6 | cased bud 80 % → 81 %, still `0x01` | +| 305.5 | 81 % → 82 %, still `0x01` | +| 334.5 | worn control bud 79 % → 78 %, `not-charging` throughout, as expected | + +So the transition is **not driven by elapsed time** — 240 s hands-off produced no state change +at all — and a bud can report `0x01` from the instant it is seated. Charging genuinely +occurred throughout while `0x01` was reported. + +A second controlled run — **both** buds seated, lid open, untouched for 162 s — also produced +`0x01` from the instant each bud was seated and **never once reported `0x05`**. That kills the +number-of-buds explanation. + +Across four captures: + +| Capture | Battery packets | Components reporting `0x05` | +|---|---|---| +| Workout | 1 | 0 | +| Case — buds seated, **case and lid handled** | 15 | **12** | +| Battery — one bud, hands-off 240 s | 7 | 0 | +| Battery — both buds, hands-off 162 s | 10 | 0 | + +**Seating buds in the case does not by itself produce `0x05`.** Three hypotheses are now +refuted: it is not elapsed time (240 s of observation, no change), not the number of buds in +the case, and not whether the case is detected — a bud reported `0x01` while the case was +still sending `255 %` / `disconnected`. The only capture that produced `0x05` is also the only +one in which the case and its lid were physically interacted with, which makes that the +remaining candidate, untested. + +The practical consequence is unchanged and is what `## Battery` already advises — **treat +`0x01` and `0x05` both as charging.** The value that appears is not predictable from elapsed +time or from the case's own reported state. + +## Ear detection: two values beyond the documented three + +`## Ear Detection` above lists `00` in-ear, `01` out-of-ear and `02` in-case. Removing the buds +and casing them produced this sequence of `(primary, secondary)` pairs: + +```plaintext +00 01 → 01 01 → 01 04 → 04 04 → 04 01 → 01 01 → 02 01 → 01 02 → 02 02 +``` + +and later, with one bud powered down in the closed case, `02 03`. + +So **`03` and `04` are the two values not in that table**. `04` appears only while a bud is in +motion between resting states and never at rest, so it reads as a transitional value alongside +the documented `01`. `03` was seen at rest, on the bud that had dropped off the link — it +behaves as *disconnected* rather than as a position. + +# Opcode Names (from the apple-wireshark dissector) + +The Lua dissectors at [github.com/pabloaul/apple-wireshark](https://github.com/pabloaul/apple-wireshark) +name most of the AACP message types. Installing them (`plugins/` into the Wireshark plugin +directory) makes Wireshark label these automatically, and its `rtbuddy.proto` gives the +SensorDataWX protobuf schema — see the sensor-stream section above. + +Validated against three captures here: the RTBuddy length field matched the real payload on +100% of 4073 frames. + +| Opcode | Name | Covered elsewhere in this document | +|---|---|---| +| `0x01` | Capabilities Request | | +| `0x02` | Capabilities | | +| `0x04` | Battery Info | `## Battery` | +| `0x06` | Ear Detection | `## Ear Detection` | +| `0x08` | Bud Role | | +| `0x09` | Control / Listen Mode | `## Noise Control` | +| `0x0C` | MAC Address | | +| `0x0D` | Audio Source Request | | +| `0x0E` | Audio Source | | +| `0x0F` | Set Notification Filter | `# Requesting notifications` | +| `0x17` | BuddyCommand | sensor streams, above | +| `0x1A` | Rename | `## Renaming AirPods` | +| `0x1B` | Timestamp | carries an ISO 8601 local date-time string | +| `0x1D` | Information | `## Metadata` | +| `0x1F` | Notify Session State? | | +| `0x22` | **Case Info Request** | | +| `0x23` | **Case Info** | | +| `0x24` | Send Device Info? | | +| `0x29` | Set Country Code | | +| `0x2B` | Stream State Info | | +| `0x2D` | Connected Devices Request | | +| `0x2E` | Connected Devices | carries Bluetooth addresses | +| `0x44` | **Send Smart Routing 2.0 Info** | see correction below | +| `0x4B` | Conversational Awareness | `## Conversational Awareness` | +| `0x4C` | Adaptive Volume Message | | +| `0x4D` | Set Features | `# Setting specific features` | +| `0x4E` | Feature ProxCard Status Update | | +| `0x4F` | Unified Accessory Restore Protocol | firmware/asset transfer; the dissector has a separate `uarp` plugin | +| `0x52` | Source Context | | +| `0x53` | Personal Medical Equipment Config | `## Headphone Accomodation` | +| `0x54` | Set Band Edges | | +| `0x55` | Unknown | | +| `0x58` | Hi-res audio | | +| `0x59` | **Dynamic End Of Charge** | | + +## Correction: `0x44` is not sensor-related + +An earlier revision of this document described `0x44` as sensor subscription, on the strength +of it appearing shortly before stream changes. The dissector names it **Send Smart Routing 2.0 +Info** — audio routing. That fits the observations better than the sensor reading ever did: it +appears at session changes and around audio state changes, and its payload never correlated +with any stream starting. + +## Leads for the open case/charging question + +Two names are worth following up on the unresolved `0x01` vs `0x05` charging question above: + +- **`0x22` / `0x23` — Case Info Request / Case Info.** Direct case state, not inferred from + the battery packet. +- **`0x59` — Dynamic End Of Charge.** Present in the capture where `0x05` appeared and absent + from the hands-off captures where it did not. That is a correlation worth testing, not a + conclusion. + +> **Note for anyone sharing captures:** `0x1D` transmits the device serial number and the +> user-assigned device name in plaintext, and `0x2E` / `0x0C` / `0x0E` carry Bluetooth +> addresses. A raw `.pklg` is personally identifying even with MAC addresses stripped from +> the HCI layer. Publish derived protocol facts, not capture files. + +## Undocumented control command ids (opcode `0x09`) + +| Id | Direction | Value(s) seen | Notes | +|---|---|---|---| +| `0x0B` | phone → buds | `0x3C`, `0x96` (60, 150) | sent while worn, before any workout or case interaction | +| `0x38` | buds → phone | `0x52` | emitted twice, once while worn and once after entering the case | +| `0x3B` | phone → buds | `0x01` | 260 ms before the `0x05` → `0x01` charging flip | +| `0x1A` `0x32` `0x3D` | phone → buds | `0x0E`, `0x01`, `0x01` | always sent as a trio during the reconnection handshake | + +# LICENSE + +LibrePods - AirPods liberated from Apple’s ecosystem +Copyright (C) 2025 LibrePods contributors + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero General Public License as published +by the Free Software Foundation, either version 3 of the License. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Affero General Public License for more details. + +You should have received a copy of the GNU Affero General Public License +along with this program. If not, see . diff --git a/windows/.gitattributes b/windows/.gitattributes new file mode 100644 index 000000000..c5f8039c9 --- /dev/null +++ b/windows/.gitattributes @@ -0,0 +1,5 @@ +daemon/vendor/** linguist-vendored +drivers/**/prebuilt/** linguist-vendored +drivers/mic/** linguist-vendored +drivers/aap/** linguist-vendored +installer/tools/** linguist-vendored diff --git a/windows/.gitignore b/windows/.gitignore new file mode 100644 index 000000000..cd04eea3e --- /dev/null +++ b/windows/.gitignore @@ -0,0 +1,13 @@ +# Windows stack build output. Vendored deps (daemon/vendor/**) stay tracked. +**/target/ +winui/**/bin/ +winui/**/obj/ +.vs/ +*.user +DvlErrLog.txt + +# Prebuilt/release output — produced by CI, never committed +dist/ + +# devcon.exe is bundled with the installer (needed to create the ROOT mic device) +!installer/tools/devcon.exe diff --git a/windows/README.md b/windows/README.md new file mode 100644 index 000000000..83ba77e93 --- /dev/null +++ b/windows/README.md @@ -0,0 +1,96 @@ +# LibrePods on Windows + +Open-source AirPods control for Windows: read battery and switch noise-control +modes (Off / Noise Cancellation / Transparency / Adaptive) from the system tray. + +It has these parts: + +1. **`LibrePodsAAP` kernel driver** ([`drivers/aap`](drivers/aap)) — opens the Apple + Accessory Protocol (AAP) L2CAP channel to the AirPods in kernel mode, which + normal Windows apps cannot do, and exposes it via IOCTLs. A second driver + ([`drivers/mic`](drivers/mic)) exposes the AirPods hi-res mic as a Windows input. +2. **`librepodsd` daemon** ([`daemon`](daemon)) — owns the driver + AAP session and + serves UI clients over named-pipe IPC (battery, noise control, ear-detection, + hearing aid, hi-res mic …). +3. **`librepods-winui` app** ([`winui`](winui)) — the native **WinUI 3** client; it + lives in the system tray (closing hides it there) and is an IPC client of the + daemon. It's what you run day-to-day. + +Works with any AirPods (2/3, Pro 1/2/3, Max) and Apple Beats — the driver binds +to the AAP service every AirPod advertises, not to a specific model. Features +shown depend on the model (e.g. only Pro/Max have noise control). + +--- + +## 1. Install the driver (one-time) + +> ⚠️ The driver isn't signed by Microsoft, so Windows must run in **Test Mode** +> (same requirement as the commercial MagicAAP driver). This lowers a security +> setting. **Advanced users only** — a system restore point is recommended. + +### a) Prerequisites to build it +- Visual Studio 2022/2026 with **Desktop development with C++** + **Spectre + x64/x86 libs** + a **Windows 11 SDK** and the **matching WDK** (SDK & WDK + build numbers must match, e.g. `28000`). +- Build the driver package (`.sys` + `.inf`, then `inf2cat` a `.cat`) — see + [`../windows/drivers/aap/README.md`](../windows/drivers/aap/README.md). + +### b) Turn on Test Mode +1. Back up your **BitLocker recovery key** (if BitLocker is on) and make a + **restore point**. +2. Disable **Secure Boot** in your firmware/BIOS (it blocks test-signed drivers). +3. In an **admin** PowerShell: `bcdedit /set testsigning on` → **reboot**. + You should see "Test Mode" in the bottom-right of the desktop. + +### c) Install +In an **admin** PowerShell, run the helper (it test-signs and installs, removing +any previous version): +```powershell +& "\LibrePodsAAP\install.ps1" -PackageDir "\LibrePodsAAP\package" +``` +Success shows `Driver package installed on device: BTHENUM\{74ec2172-...}`. +Check it loaded (should be `OK`, not error 52): +```powershell +Get-PnpDevice -FriendlyName "LibrePods AAP*" | Select Status +``` + +### Uninstall / revert +```powershell +pnputil /delete-driver oem.inf /uninstall # find with: pnputil /enum-drivers +bcdedit /set testsigning off # then re-enable Secure Boot in BIOS +``` + +--- + +## 2. Run the app + +Two pieces run: the **daemon** (`librepodsd.exe`, headless) and the **WinUI app** +(`librepods-winui.exe`). Build the daemon from WSL/Linux (cross-compiled): +`cargo build --release --target x86_64-pc-windows-gnu` in `daemon/`, or natively on +Windows. Build the WinUI app with `dotnet build`. The CI's release artifact bundles +both plus the FFmpeg DLLs and the prebuilt drivers. Launch `librepods-winui.exe`; it +auto-starts the daemon, shows a tray icon, and its window hides to the tray on close. +To start it at login, use [`startup.ps1`](startup.ps1). + +### How it works +- The daemon finds your paired AirPods, opens the driver, and holds an **AAP + session** (connect → handshake → request notifications), keeping battery + + noise-mode state up to date and serving the app over IPC. +- **Left-click / right-click the WinUI tray icon** for the menu: + - a line showing **Left / Right / Case** battery, + - **Noise Control**: Off · Noise Cancellation · Transparency · Adaptive + (the current one is checked; click to switch — sends the AAP command), + - **Quit**. +- Hover the icon for a tooltip with battery + current mode. +- If the link drops it reconnects automatically. + +### Bonus +Keeping the AAP session alive (app running) tends to **stabilize the audio** — +the AirPods stop bouncing between the HFP (mono, "static") and A2DP (stereo) +profiles, because a proper AAP host is talking to them. + +### Notes / limits +- The driver is **exclusive** — only one app can + hold the channel at a time. +- No system-volume control yet (that's a Windows audio API, separate from AAP). +- Requires the driver installed and Test Mode on. diff --git a/windows/daemon/Cargo.lock b/windows/daemon/Cargo.lock new file mode 100644 index 000000000..e1ff7e7c1 --- /dev/null +++ b/windows/daemon/Cargo.lock @@ -0,0 +1,288 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "cc" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5add81bb678e6cb321aff7fa0dc7689ad82b112dbc032cea19f91d6b8e3582b9" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "librepods-ipc" +version = "0.1.0" +dependencies = [ + "serde", + "serde_json", +] + +[[package]] +name = "librepodsd" +version = "0.1.0" +dependencies = [ + "cc", + "librepods-ipc", + "serde_json", + "windows", + "windows-sys", +] + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "serde_json" +version = "1.0.151" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "windows" +version = "0.58.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd04d41d93c4992d421894c18c8b43496aa748dd4c081bac0dc93eb0489272b6" +dependencies = [ + "windows-core", + "windows-targets", +] + +[[package]] +name = "windows-core" +version = "0.58.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ba6d44ec8c2591c134257ce647b7ea6b20335bf6379a27dac5f1641fcf59f99" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-result", + "windows-strings", + "windows-targets", +] + +[[package]] +name = "windows-implement" +version = "0.58.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2bbd5b46c938e506ecbce286b6628a02171d56153ba733b6c741fc627ec9579b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-interface" +version = "0.58.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053c4c462dc91d3b1504c6fe5a726dd15e216ba718e84a0e46a88fbe5ded3515" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-result" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d1043d8214f791817bab27572aaa8af63732e11bf84aa21a45a78d6c317ae0e" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-strings" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cd9b125c486025df0eabcb585e62173c6c9eddcec5d117d3b6e8c30e2ee4d10" +dependencies = [ + "windows-result", + "windows-targets", +] + +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/windows/daemon/Cargo.toml b/windows/daemon/Cargo.toml new file mode 100644 index 000000000..3ced3d782 --- /dev/null +++ b/windows/daemon/Cargo.toml @@ -0,0 +1,47 @@ +[package] +name = "librepodsd" +version = "0.1.0" +edition = "2021" +description = "LibrePods Windows daemon — owns the AAP driver + hi-res mic, serves the tray/app over IPC." + +[dependencies] +librepods-ipc = { path = "../ipc" } +serde_json = "1" +windows-sys = { version = "0.59", features = [ + "Win32_Foundation", + "Win32_System_IO", + "Win32_System_Registry", + "Win32_System_Threading", + "Win32_System_Pipes", + "Win32_Storage_FileSystem", + "Win32_Security", + "Win32_Security_Authorization", + "Win32_Devices_DeviceAndDriverInstallation", + "Win32_Devices_Bluetooth", +] } +# `windows` (COM/WinRT) for SMTC ear-detection auto-pause (media.rs) + the BLE +# proximity watcher (le.rs). +windows = { version = "0.58", features = [ + "Win32_Foundation", + "Win32_System_Com", + "Win32_Media_Audio", + "Win32_Media_Audio_Endpoints", + "Foundation", + "Foundation_Collections", + "Media_Control", + "Devices_Bluetooth", + "Devices_Bluetooth_Advertisement", + "Storage_Streams", +] } + +[build-dependencies] +cc = "1" + +[[bin]] +name = "librepodsd" +path = "src/main.rs" + +[profile.release] +opt-level = "s" +lto = true +strip = true diff --git a/windows/daemon/build.rs b/windows/daemon/build.rs new file mode 100644 index 000000000..338ed7f49 --- /dev/null +++ b/windows/daemon/build.rs @@ -0,0 +1,23 @@ +use std::path::PathBuf; + +fn main() { + // The AAC-ELD decoder (FFmpeg) is only used on Windows. + if std::env::var("CARGO_CFG_TARGET_OS").as_deref() != Ok("windows") { + return; + } + let manifest = PathBuf::from(std::env::var("CARGO_MANIFEST_DIR").unwrap()); + let ff = manifest.join("vendor/ffmpeg"); + + // Compile the tiny C shim against the vendored FFmpeg headers. + cc::Build::new() + .file("src/eld_shim.c") + .include(ff.join("include")) + .compile("eld_shim"); + + // Link the FFmpeg import libs (avcodec pulls avutil + swresample). + println!("cargo:rustc-link-search=native={}", ff.join("lib").display()); + println!("cargo:rustc-link-lib=avcodec"); + println!("cargo:rustc-link-lib=avutil"); + println!("cargo:rustc-link-lib=swresample"); + println!("cargo:rerun-if-changed=src/eld_shim.c"); +} diff --git a/windows/daemon/fetch-ffmpeg.sh b/windows/daemon/fetch-ffmpeg.sh new file mode 100644 index 000000000..75ae2ac1f --- /dev/null +++ b/windows/daemon/fetch-ffmpeg.sh @@ -0,0 +1,43 @@ +#!/usr/bin/env bash +# Fetch the minimal FFmpeg 7.1 shared libraries the daemon links against for +# AAC-ELD decode (avcodec / avutil / swresample) into vendor/ffmpeg — so the +# ~28k lines of FFmpeg headers are NOT vendored into git. Runs both on the +# Windows CI runner (Git Bash) and locally (WSL / Linux cross-build). +# +# We copy BOTH import-lib formats: .lib (MSVC — what CI's default target uses) +# and .dll.a (MinGW — the x86_64-pc-windows-gnu cross-build), so build.rs links +# under either toolchain. Pinned to an IMMUTABLE dated BtbN autobuild tag + SHA256 +# (NOT the rolling "latest" tag, which gets rebuilt and breaks the checksum). To +# move versions: pick a new dated tag, keep the av*-NN.dll SONAMEs in sync with +# what the app loads, and update URL + URL_SHA256. +set -euo pipefail + +URL="https://github.com/BtbN/FFmpeg-Builds/releases/download/autobuild-2026-08-11-13-11/ffmpeg-n7.1.5-12-g1fdbca85aa-win64-lgpl-shared-7.1.zip" +URL_SHA256="2e970208067a30ce6d4c5d89ba50ff7bea110ab7406414d0735b6ced89c53cf8" + +here="$(cd "$(dirname "$0")" && pwd)" +dest="$here/vendor/ffmpeg" + +# Already fetched (or vendored) — nothing to do. +if [ -f "$dest/lib/libavcodec.dll.a" ] || [ -f "$dest/lib/avcodec.lib" ]; then + echo "ffmpeg already present in $dest" + exit 0 +fi + +tmp="$(mktemp -d)" +trap 'rm -rf "$tmp"' EXIT +echo "fetching FFmpeg 7.1 shared libs…" +curl -fsSL -o "$tmp/ff.zip" "$URL" +echo "${URL_SHA256} $tmp/ff.zip" | sha256sum -c - +unzip -q "$tmp/ff.zip" -d "$tmp/x" +root="$(find "$tmp/x" -maxdepth 1 -type d -name 'ffmpeg*' | head -1)" + +mkdir -p "$dest/include" "$dest/lib" "$dest/bin" +cp -r "$root"/include/libavcodec "$root"/include/libavutil "$root"/include/libswresample "$dest/include/" +# import libs — both MSVC (.lib) and MinGW (.dll.a) +cp "$root"/lib/avcodec.lib "$root"/lib/avutil.lib "$root"/lib/swresample.lib "$dest/lib/" +cp "$root"/lib/libavcodec.dll.a "$root"/lib/libavutil.dll.a "$root"/lib/libswresample.dll.a "$dest/lib/" +# runtime DLLs +cp "$root"/bin/avcodec-61.dll "$root"/bin/avutil-59.dll "$root"/bin/swresample-5.dll "$dest/bin/" + +echo "ffmpeg fetched into $dest" diff --git a/windows/daemon/src/a2dp.rs b/windows/daemon/src/a2dp.rs new file mode 100644 index 000000000..a5853fb35 --- /dev/null +++ b/windows/daemon/src/a2dp.rs @@ -0,0 +1,95 @@ +//! A2DP recovery: after the hi-res mic puts the AirPods into their bidirectional +//! "call" mode, A2DP playback degrades to mono/right until the audio link is +//! re-established. This toggles the AirPods' A2DP service off then on — the +//! programmatic equivalent of disconnecting + reconnecting them — to restore +//! stereo, without a full Bluetooth restart. + +use std::mem::{size_of, zeroed}; + +use windows_sys::Win32::Devices::Bluetooth::{ + BLUETOOTH_DEVICE_INFO, BLUETOOTH_DEVICE_SEARCH_PARAMS, BLUETOOTH_FIND_RADIO_PARAMS, + BluetoothFindDeviceClose, BluetoothFindFirstDevice, BluetoothFindFirstRadio, + BluetoothFindNextDevice, BluetoothFindRadioClose, BluetoothSetServiceState, +}; +use windows_sys::Win32::Foundation::{CloseHandle, HANDLE}; +use windows_sys::core::GUID; + +// AudioSink (A2DP) — the service the AirPods actually expose for audio (0x110B). +// (0x110D AdvancedAudioDistribution isn't an installed service here -> ERROR 1060.) +const A2DP_SERVICE: GUID = GUID { + data1: 0x0000_110B, + data2: 0x0000, + data3: 0x1000, + data4: [0x80, 0x00, 0x00, 0x80, 0x5F, 0x9B, 0x34, 0xFB], +}; + +const BLUETOOTH_SERVICE_DISABLE: u32 = 0x00; +const BLUETOOTH_SERVICE_ENABLE: u32 = 0x01; + +fn find_device(mac: u64) -> Option { + unsafe { + let mut params: BLUETOOTH_DEVICE_SEARCH_PARAMS = zeroed(); + params.dwSize = size_of::() as u32; + params.fReturnAuthenticated = 1; + params.fReturnRemembered = 1; + params.fReturnConnected = 1; + params.fReturnUnknown = 1; + + let mut info: BLUETOOTH_DEVICE_INFO = zeroed(); + info.dwSize = size_of::() as u32; + let h = BluetoothFindFirstDevice(¶ms, &mut info); + if h.is_null() { + return None; + } + let mut found = None; + loop { + if info.Address.Anonymous.ullLong == mac { + found = Some(info); + break; + } + info.dwSize = size_of::() as u32; + if BluetoothFindNextDevice(h, &mut info) == 0 { + break; + } + } + BluetoothFindDeviceClose(h); + found + } +} + +/// Reconnect the AirPods' A2DP service (disable then enable) to restore stereo. +/// Returns the (disable, enable) Win32 result codes: 0 = success, 5 = access +/// denied (needs elevation), 0xFFFFFFFF = device not found, 0xFFFFFFFE = no radio. +pub fn reset(mac: u64) -> (u32, u32) { + unsafe { + let info = match find_device(mac) { + Some(i) => i, + None => return (0xFFFF_FFFF, 0), + }; + let mut rparams: BLUETOOTH_FIND_RADIO_PARAMS = zeroed(); + rparams.dwSize = size_of::() as u32; + let mut radio: HANDLE = std::ptr::null_mut(); + let hfind = BluetoothFindFirstRadio(&rparams, &mut radio); + if hfind.is_null() { + return (0xFFFF_FFFE, 0); + } + + // Let the mic-stop transition settle so the AirPods have left mic mode + // before we touch A2DP. + std::thread::sleep(std::time::Duration::from_millis(150)); + let d = BluetoothSetServiceState(radio, &info, &A2DP_SERVICE, BLUETOOTH_SERVICE_DISABLE); + // Let the A2DP link tear down before reconnecting, else it re-establishes + // mid-teardown (mono/crackle) and needs another try. (The reconnect + // handshake after ENABLE is Windows/BT — we can't speed that part up.) + std::thread::sleep(std::time::Duration::from_millis(1000)); + let e = BluetoothSetServiceState(radio, &info, &A2DP_SERVICE, BLUETOOTH_SERVICE_ENABLE); + // ENABLE returns immediately but the A2DP reconnect handshake runs after + // it in the BT stack; wait for it so the caller's "restored" card lands + // when the stereo is actually back (not before). + std::thread::sleep(std::time::Duration::from_millis(1500)); + + CloseHandle(radio); + BluetoothFindRadioClose(hfind); + (d, e) + } +} diff --git a/windows/daemon/src/aap.rs b/windows/daemon/src/aap.rs new file mode 100644 index 000000000..760fd531f --- /dev/null +++ b/windows/daemon/src/aap.rs @@ -0,0 +1,431 @@ +//! AAP protocol: outgoing commands + parsers for the packets the AirPods push. + +pub const PSM_AACP: u16 = 0x1001; + +pub const HANDSHAKE: [u8; 16] = [ + 0x00, 0x00, 0x04, 0x00, 0x01, 0x00, 0x02, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, +]; +pub const SET_FEATURES: [u8; 14] = [ + 0x04, 0x00, 0x04, 0x00, 0x4D, 0x00, 0xFF, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, +]; +pub const REQUEST_NOTIFS: [u8; 10] = + [0x04, 0x00, 0x04, 0x00, 0x0F, 0x00, 0xFF, 0xFF, 0xFF, 0xFF]; + +/// Enable the hi-res (AAC-ELD) microphone stream — the AirPods start pushing +/// 0x58 uplink audio packets. From LibrePods PR #655. +pub const START_AUDIO: [u8; 19] = [ + 0x04, 0x00, 0x04, 0x00, 0x58, 0x00, 0x00, 0x00, 0x09, 0x00, 0x00, 0x01, 0x82, 0x00, 0x00, 0x00, + 0x04, 0x96, 0x00, +]; +/// Stop the hi-res microphone stream. +pub const STOP_AUDIO: [u8; 12] = [ + 0x04, 0x00, 0x04, 0x00, 0x58, 0x00, 0x00, 0x00, 0x02, 0x00, 0x03, 0x01, +]; + +// ---- AirPods Pro 3 RTBuddy heart-rate (PR #702) ---- +// +// Enable = the AACP 1.3 init handshake (the four CONNECT/CAPABILITIES packets, +// sent RAW via the driver like `sendPacket` on Android) then a `sensor_stream` +// frame for the heart-rate stream. The init packets carry the `04 00 04 00` +// header inline; `sensor_stream` bakes it in too, so every constant here is a +// ready-to-send driver packet. +// +// The init packets are **unrefuted rather than confirmed**: every iOS capture +// so far began with the AACP session already established, so they were never +// seen on the wire. They are kept because the Android implementation sends them +// and nothing contradicts that. + +/// AACP 1.3 init, service 0 — CONNECT (raw `sendPacket`). +pub const HR_CONNECT_SERVICE_0: [u8; 16] = [ + 0x00, 0x00, 0x00, 0x00, 0x01, 0x00, 0x03, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, +]; +/// AACP 1.3 init, service 0 — CAPABILITIES (raw `sendPacket`). +pub const HR_CAPABILITIES_SERVICE_0: [u8; 7] = [0x04, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00]; +/// AACP 1.3 init, service 4 — CONNECT (raw `sendPacket`). +pub const HR_CONNECT_SERVICE_4: [u8; 16] = [ + 0x00, 0x00, 0x04, 0x00, 0x01, 0x00, 0x03, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, +]; +/// AACP 1.3 init, service 4 — CAPABILITIES (raw `sendPacket`). +pub const HR_CAPABILITIES_SERVICE_4: [u8; 7] = [0x04, 0x00, 0x04, 0x00, 0x01, 0x00, 0x00]; + +/// HRM_STATE control command (id 0x30), value 0x01 = on — the switch that powers +/// the PPG measurement engine. +/// +/// The enable step the working Android client (upstream PR #702, produces real BPM +/// on AirPods Pro 3) sends: `sendControlCommand(HRM_STATE=0x30, true)` right after the +/// AACP 1.3 session init and before the stream start. iOS reaches the engine by another +/// (hidden) path, so the iOS PacketLogger captures never showed 0x30; the Android path +/// is the reproducible one. NOTE: sending this makes our enable byte-match Android, but +/// on the A3063 test unit the AirPods still ACK service 19 and emit no data frames — so +/// it is necessary but, standalone, not sufficient here (see hr_retry_campaign). +pub const HR_ENABLE: [u8; 11] = [0x04, 0x00, 0x04, 0x00, 0x09, 0x00, 0x30, 0x01, 0x00, 0x00, 0x00]; + +// ---- Sensor stream control ---- +// +// Streams are started and stopped with the `0x17` … `42 0B` frame family that +// `AAP Definitions.md` already documents for Head Tracking. The frame carries a +// stream id and a sampling period in microseconds; **a period of zero stops the +// stream**. Verified against four captures — see `AAP Definitions.md` → +// "Starting and Stopping Sensor Streams" for the alignment. +// +// There are **two forms** of this frame, and mixing them produces a packet that +// appears in no capture. Across 24 observed control frames the correlation is +// exact, with no exceptions: +// +// form A — no `10 02` after the sequence, bare stream id (0x10, 0x12, 0x13) +// form B — `10 02` after the sequence, stream id with bit 0x40 set (0x50, 0x52, 0x53) +// +// The payload length is not a constant: it is the real byte count, so it moves +// with both the form and the width of the sequence varint (0x10 for form A with a +// two-byte varint, 0x11 / 0x12 for form B). +// +// A session captured from the connection onwards uses **form A** to start heart +// rate: +// +// t=364.35 -> 08 13 ... period 1000000 heart rate at 1 Hz +// t=365.92 <- first type-19 frame (+1.57 s) +// +// Captures that begin mid-session show only form B, which is where an earlier +// revision here got 0x53 and the `10 02` field from. That revision also fixed the +// length at 17, which is only right for form B with a one-byte varint. This is +// form A, matching both the fresh-session capture and the original constant. +// +// The Windows symptom that prompted the recheck fits: raw PPG came up but heart +// rate never did, on a daemon sending form B into a freshly established session. + +/// Stream id for heart rate — data type 19. +pub const STREAM_HEART_RATE: u8 = 0x54; // HEARTRATE_COMMAND — newer firmware (version3 first digit >= 8) +pub const STREAM_HEART_RATE_LEGACY: u8 = 0x13; // HEARTRATE — older firmware +/// Stream id for 6-axis device motion — data type 16 (DEVMOTION6). This was +/// mislabelled "raw PPG": the RTBuddy schema's ServiceType enum is 16=DEVMOTION6, +/// 19=HEARTRATE. It is motion, unrelated to heart rate (Android never sends it), +/// so it is NOT part of the HR enable — the ~150 frames/window we saw were motion, +/// never PPG. Kept for reference only. +pub const STREAM_DEVMOTION6: u8 = 0x10; +/// Stream id for head tracking — data type 14. Head tracking lives on the same +/// 0x17 sensor service as heart rate, so a running head-tracking stream may be +/// what blocks the computed HR; stopping it first (period 0) is worth trying. +pub const STREAM_HEAD_TRACKING: u8 = 0x0E; + +/// One-second sampling period, in microseconds — the cadence iOS uses for heart rate. +pub const PERIOD_HEART_RATE_US: u32 = 1_000_000; +/// 50 Hz sampling period, in microseconds — the cadence iOS uses for raw PPG. +pub const PERIOD_PPG_US: u32 = 20_000; + +/// Build a sensor stream control frame, ready to send via the driver. +/// +/// `seq` is the sequence number the phone increments per control frame. It is +/// encoded as a **two-byte varint**, which is what both the captures and the +/// original constant here used, and what makes the payload length come out at +/// 16 — so pass a value that stays inside 14 bits and just count up. Whether the +/// AirPods validate it is untested. +/// +/// Pass `period_us = 0` to stop the stream. +/// +/// Reproduces the captured heart-rate start frame byte-for-byte (`seq = 152` +/// there, encoding as `98 01`): +/// `04 00 04 00 17 00 00 00 10 00 10 00 08 98 01 42 0b 08 13 10 02 1a 05 01 40 42 0f 00` +pub fn sensor_stream(seq: u16, stream_id: u8, period_us: u32) -> [u8; 28] { + // Two-byte varint: low 7 bits with the continuation bit, then the next 7. + let s0 = 0x80 | (seq & 0x7F) as u8; + let s1 = ((seq >> 7) & 0x7F) as u8; + let p = period_us.to_le_bytes(); + [ + 0x04, 0x00, 0x04, 0x00, // header + 0x17, 0x00, 0x00, 0x00, // opcode + 0x10, 0x00, // service + 0x10, 0x00, // payload length = 16 + 0x08, s0, s1, // sequence, two-byte varint + 0x42, 0x0B, // field 8, 11 bytes + 0x08, stream_id, // stream id + 0x10, 0x02, // field 2 = 2 + 0x1A, 0x05, // field 3, 5 bytes + 0x01, p[0], p[1], p[2], p[3], // mode 1 + period µs, little-endian + ] +} + +/// Kavish's confirmed-working heart-rate START frame (LibrePods maintainer, Discord +/// 2026-08-13: "this is what finally worked for me"). On newer AirPods Pro 3 firmware +/// the HR service id MOVED: it's now **84 (0x54)**, not 19 (0x13) — and the top-level +/// message carries an extra field-2=2 vs the generic `sensor_stream`. 1 Hz (period +/// 1 000 000 µs, the last 4 bytes = UINT32 LE). `seq` is a plain request counter (its +/// value is irrelevant); keep it < 128 so it stays a single-byte varint like his. +/// The maintainer's `setSensorServiceReportInterval` frame — sets a sensor service's +/// report interval (START = 1 Hz / period 1e6 µs, STOP = period 0). `service` is +/// HEARTRATE_COMMAND (84) or HEARTRATE (19) per firmware. Matches his +/// `SensorDataWX{ ServiceSettings{ service, setting=2, config=0x01+interval_µs_LE } }`. +pub fn hr_stream(seq: u8, service: u8, period_us: u32) -> [u8; 29] { + let p = period_us.to_le_bytes(); + [ + 0x04, 0x00, 0x04, 0x00, // header + 0x17, 0x00, 0x00, 0x00, // opcode (BuddyCommand) + 0x10, 0x00, // descriptor (SensorDataWX) + 0x11, 0x00, // payload length = 17 + 0x08, seq & 0x7F, // sequence, single-byte varint + 0x10, 0x02, // top-level field 2 = 2 + 0x42, 0x0B, // field 8 (ServiceSettings), 11 bytes + 0x08, service, // service (84 = HEARTRATE_COMMAND / 19 = HEARTRATE) + 0x10, 0x02, // setting = 2 + 0x1A, 0x05, // config, 5 bytes + 0x01, p[0], p[1], p[2], p[3], // 0x01 + interval µs, little-endian + ] +} + +/// SensorDataWX `request_all_descriptors` (protobuf field 4, `22 00` = empty +/// message) on the Sensor Data WX service. A *named discovery call* from the +/// RTBuddy schema (pabloaul/apple-wireshark): iOS sends it twice at session open — +/// once without `log_type`, once with `log_type=2` — before any stream. The daemon +/// never sent these. Two-byte varint seq → length field 5 (no log_type) or 7. +pub fn request_all_descriptors(seq: u16, log_type: bool) -> Vec { + let s0 = 0x80 | (seq & 0x7F) as u8; + let s1 = ((seq >> 7) & 0x7F) as u8; + let mut payload = vec![0x08, s0, s1]; + if log_type { + payload.extend_from_slice(&[0x10, 0x02]); // log_type = 2 + } + payload.extend_from_slice(&[0x22, 0x00]); // field 4 (request_all_descriptors), empty + let len = (payload.len() as u16).to_le_bytes(); + let mut f = vec![ + 0x04, 0x00, 0x04, 0x00, 0x17, 0x00, 0x00, 0x00, 0x10, 0x00, len[0], len[1], + ]; + f.extend_from_slice(&payload); + f +} + +/// True if `data` is a 0x58 uplink audio packet (carries AAC-ELD frames). +pub fn is_audio_packet(data: &[u8]) -> bool { + data.len() >= 8 + && data[0] == 0x04 + && data[2] == 0x04 + && data[4] == 0x58 + && data[6] == 0x01 + && data[7] == 0x00 +} + +/// 0x58 packet layout (PR #655): a 22-byte header, then one or more access +/// units, each a 5-byte record header (length at byte 4) followed by the AU +/// payload. Calls `f` with each AU's AAC-ELD bytes. +pub fn for_each_au(sdu: &[u8], mut f: impl FnMut(&[u8])) { + const HEADER_LEN: usize = 22; + let mut off = HEADER_LEN; + while off + 5 <= sdu.len() { + let au_len = sdu[off + 4] as usize; + let start = off + 5; + let end = start + au_len; + if au_len == 0 || end > sdu.len() { + break; + } + f(&sdu[start..end]); + off = end; + } +} + +/// Listening-mode (ANC) control command. value = mode (1 off, 2 anc, 3 transparency, 4 adaptive). +pub fn anc_command(mode: u8) -> [u8; 11] { + control_command(0x0D, mode) +} + +/// Generic AAP control command (opcode 0x09): `[HEADER, 0x09, 0x00, id, value, 0,0,0]`. +/// ANC and the boolean feature toggles are all this shape. +pub fn control_command(id: u8, value: u8) -> [u8; 11] { + [0x04, 0x00, 0x04, 0x00, 0x09, 0x00, id, value, 0x00, 0x00, 0x00] +} + +/// Boolean feature toggle: 0x01 = on, 0x02 = off (matches the AirPods encoding). +pub fn feature_command(id: u8, on: bool) -> [u8; 11] { + control_command(id, if on { 0x01 } else { 0x02 }) +} + +/// If `data` is a control-command status for `id` (opcode 0x09), return its value byte. +pub fn parse_control_value(data: &[u8], id: u8) -> Option { + if data.len() >= 8 && data[..4] == HEADER && data[4] == 0x09 && data[6] == id { + Some(data[7]) + } else { + None + } +} + +/// Parse a rename packet the app sends over the proxy: `[HEADER, 0x1A, 0x00, +/// 0x01, size, 0x00, ...name]`. Returns the new device name. +pub fn parse_rename(data: &[u8]) -> Option { + if data.len() >= 9 && data[..4] == HEADER && data[4] == 0x1A { + let size = data[7] as usize; + if data.len() >= 9 + size { + return String::from_utf8(data[9..9 + size].to_vec()).ok(); + } + } + None +} + +/// Build a rename command for the AirPods (the inverse of `parse_rename`): +/// `[HEADER, 0x1A, 0x00, 0x01, size, 0x00, ...name]`. +pub fn build_rename(name: &str) -> Vec { + let bytes = name.as_bytes(); + let mut f = vec![0x04, 0x00, 0x04, 0x00, 0x1A, 0x00, 0x01, bytes.len() as u8, 0x00]; + f.extend_from_slice(bytes); + f +} + +/// Conversational Awareness event (opcode 0x4B): the AirPods signal that you +/// started/stopped speaking; the status byte drives the host-side volume duck. +/// 1 = start, 2 = reduce, 3 = partial, 4/6/7 = end. +pub fn parse_conversational_awareness(data: &[u8]) -> Option { + if data.len() >= 10 && data[..4] == HEADER && data[4] == 0x4B { + Some(data[9]) + } else { + None + } +} + +pub fn anc_name(mode: u8) -> &'static str { + match mode { + 1 => "Off", + 2 => "Noise Cancellation", + 3 => "Transparency", + 4 => "Adaptive", + _ => "?", + } +} + +#[derive(Default, Clone, Copy)] +pub struct Battery { + pub headphone: Option, + pub left: Option, + pub right: Option, + pub case: Option, + // Per-component charging flag (status byte 0x01 charging, 0x05 charging in case). + pub headphone_charging: bool, + pub left_charging: bool, + pub right_charging: bool, + pub case_charging: bool, +} + +const HEADER: [u8; 4] = [0x04, 0x00, 0x04, 0x00]; + +/// If `data` is a battery packet (opcode 0x04), return the parsed levels. +pub fn parse_battery(data: &[u8]) -> Option { + if data.len() < 7 || data[..4] != HEADER || data[4] != 0x04 { + return None; + } + let payload = &data[4..]; // starts at opcode + let count = payload[2] as usize; + let mut b = Battery::default(); + for i in 0..count { + let base = 3 + i * 5; + if base + 3 >= payload.len() { + break; + } + // status 0x04 = component not connected/present; level 0xFF (255) = the + // slot exists but is absent (e.g. the headphone slot on earbuds) -> both + // report as unknown. (0x05 = charging in case, level valid.) + let status = payload[base + 3]; + let raw = payload[base + 2]; + let level = if status == 0x04 || raw == 0xFF { + None + } else { + Some(raw) + }; + // 0x01 = charging, 0x05 = charging while in the case; both mean "charging". + let charging = status == 0x01 || status == 0x05; + match payload[base] { + 0x01 => { + b.headphone = level; + b.headphone_charging = charging; + } + 0x02 => { + b.right = level; + b.right_charging = charging; + } + 0x04 => { + b.left = level; + b.left_charging = charging; + } + 0x08 => { + b.case = level; + b.case_charging = charging; + } + _ => {} + } + } + Some(b) +} + +/// In-ear state of one earbud, as reported by the AAP ear-detection packet. +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub enum EarStatus { + InEar, + OutOfEar, + InCase, + Disconnected, + /// 0x04 — a transitional value the iOS capture found: emitted only while a bud + /// is *in motion* between resting states, never at rest. Callers should hold + /// the previous state rather than act on it, to avoid false auto-pauses. + Transitional, +} + +impl EarStatus { + fn from_byte(b: u8) -> EarStatus { + match b { + 0x00 => EarStatus::InEar, + 0x01 => EarStatus::OutOfEar, + 0x02 => EarStatus::InCase, + 0x03 => EarStatus::Disconnected, + 0x04 => EarStatus::Transitional, + _ => EarStatus::OutOfEar, // anything unexpected + } + } + pub fn in_ear(self) -> bool { + self == EarStatus::InEar + } + /// True for the 0x04 in-motion value — callers should keep the prior state. + pub fn is_transitional(self) -> bool { + self == EarStatus::Transitional + } +} + +/// If `data` is an ear-detection packet (opcode 0x06), return the (primary, +/// secondary) earbud statuses. Which physical bud is "primary" varies, so +/// callers should treat them symmetrically (e.g. "is any bud in ear"). +pub fn parse_ear_detection(data: &[u8]) -> Option<(EarStatus, EarStatus)> { + if data.len() >= 8 && data[..4] == HEADER && data[4] == 0x06 { + Some((EarStatus::from_byte(data[6]), EarStatus::from_byte(data[7]))) + } else { + None + } +} + +/// Device metadata (from the iOS capture) parsed from the 0x1D packet: model +/// number, firmware version and serial. The payload is a short header then a run +/// of NUL-terminated ASCII strings in a stable order: +/// `[name, model, manufacturer, serial, firmware, …]` (see `AAP Definitions.md` +/// → "0x1D — device identity"). Best-effort — returns None if it can't be read. +pub fn parse_metadata(data: &[u8]) -> Option<(String, String, String)> { + if data.len() < 8 || data[..4] != HEADER || data[4] != 0x1D { + return None; + } + // Collect the NUL-separated printable-ASCII strings, in order. The binary + // blocks (digest, timestamps) come after the fields we want, so index-based + // lookup is stable for the first few strings. + let strings: Vec = data[6..] + .split(|&b| b == 0) + .filter(|s| s.len() >= 3 && s.iter().all(|&c| c.is_ascii_graphic() || c == b' ')) + .map(|s| String::from_utf8_lossy(s).into_owned()) + .collect(); + let model = strings.get(1).cloned().unwrap_or_default(); + let serial = strings.get(3).cloned().unwrap_or_default(); + let firmware = strings.get(4).cloned().unwrap_or_default(); + if model.is_empty() && firmware.is_empty() { + return None; + } + Some((model, firmware, serial)) +} + +/// If `data` reports the listening mode (control command 0x09, id 0x0D), +/// return the mode value. +pub fn parse_anc_mode(data: &[u8]) -> Option { + if data.len() >= 8 && data[..4] == HEADER && data[4] == 0x09 && data[6] == 0x0D { + Some(data[7]) + } else { + None + } +} diff --git a/windows/daemon/src/bt.rs b/windows/daemon/src/bt.rs new file mode 100644 index 000000000..e46f02cd1 --- /dev/null +++ b/windows/daemon/src/bt.rs @@ -0,0 +1,135 @@ +//! Locate the paired AirPods and return their 48-bit Bluetooth address. + +use std::mem::{size_of, zeroed}; + +use windows_sys::Win32::Devices::Bluetooth::{ + BLUETOOTH_DEVICE_INFO, BLUETOOTH_DEVICE_SEARCH_PARAMS, BLUETOOTH_FIND_RADIO_PARAMS, + BluetoothFindDeviceClose, BluetoothFindFirstDevice, BluetoothFindFirstRadio, + BluetoothFindNextDevice, BluetoothFindRadioClose, BluetoothSetServiceState, +}; +use windows_sys::Win32::Foundation::{CloseHandle, HANDLE}; +use windows_sys::core::GUID; + +// Classic-audio service GUIDs — A2DP AudioSink + Handsfree. +const AUDIO_SINK: GUID = GUID { + data1: 0x0000_110b, data2: 0, data3: 0x1000, + data4: [0x80, 0x00, 0x00, 0x80, 0x5f, 0x9b, 0x34, 0xfb], +}; +const HANDSFREE: GUID = GUID { + data1: 0x0000_111e, data2: 0, data3: 0x1000, + data4: [0x80, 0x00, 0x00, 0x80, 0x5f, 0x9b, 0x34, 0xfb], +}; + +/// Real Bluetooth connect/disconnect of the AirPods' AUDIO by toggling their audio +/// services on the local radio — distinct from releasing our AAP control session. +/// `connect = false` disconnects (Windows drops the device); `true` reconnects. +/// Returns true if at least one service state was set. May require the device to be +/// paired and, on some systems, elevation. +pub fn set_audio_connected(mac: u64, connect: bool) -> bool { + unsafe { + let dev = match device_info(mac) { + Some(d) => d, + None => return false, + }; + let mut rparams: BLUETOOTH_FIND_RADIO_PARAMS = zeroed(); + rparams.dwSize = size_of::() as u32; + let mut hradio: HANDLE = std::ptr::null_mut(); + let hfind = BluetoothFindFirstRadio(&rparams, &mut hradio); + if hfind.is_null() { + return false; + } + let flags: u32 = if connect { 1 } else { 0 }; // ENABLE / DISABLE + // Enable BOTH A2DP (stereo output) and Handsfree so at least one audio + // endpoint always comes up — enabling only A2DP left the user with NO sound + // when A2DP alone failed to connect (no HFP fallback). (The A2DP-vs-HFP + // routing / "mic but no audio" concern is better solved by picking the default + // output device, not by dropping HFP here.) + let a = BluetoothSetServiceState(hradio, &dev, &AUDIO_SINK, flags); + let b = BluetoothSetServiceState(hradio, &dev, &HANDSFREE, flags); + CloseHandle(hradio); + BluetoothFindRadioClose(hfind); + a == 0 || b == 0 // ERROR_SUCCESS on either + } +} + +/// Look up a paired device's `BLUETOOTH_DEVICE_INFO` by its 48-bit address. +unsafe fn device_info(mac: u64) -> Option { + let mut params: BLUETOOTH_DEVICE_SEARCH_PARAMS = zeroed(); + params.dwSize = size_of::() as u32; + params.fReturnAuthenticated = 1; + params.fReturnRemembered = 1; + params.fReturnConnected = 1; + let mut info: BLUETOOTH_DEVICE_INFO = zeroed(); + info.dwSize = size_of::() as u32; + let h = BluetoothFindFirstDevice(¶ms, &mut info); + if h.is_null() { + return None; + } + let mut found = None; + loop { + if info.Address.Anonymous.ullLong == mac { + found = Some(info); + break; + } + info.dwSize = size_of::() as u32; + if BluetoothFindNextDevice(h, &mut info) == 0 { + break; + } + } + BluetoothFindDeviceClose(h); + found +} + +fn utf16_name(buf: &[u16]) -> String { + let end = buf.iter().position(|&c| c == 0).unwrap_or(buf.len()); + String::from_utf16_lossy(&buf[..end]) +} + +/// Name patterns of Apple-chip audio devices that speak the AAP protocol — not +/// just AirPods but Beats too (Powerbeats, Beats Fit Pro, Studio Buds, Solo, +/// Studio3, Flex…), which share the same H1/W1 chip and endpoint. Matched +/// case-insensitively against the paired device's name. +const AAP_NAME_HINTS: &[&str] = &["airpod", "beats"]; + +/// (address, display name) of the first paired device whose name matches a known +/// AAP device (AirPods / Beats). The Windows " - Find My" suffix is stripped for +/// display. +pub fn find_airpods() -> Option<(u64, String)> { + unsafe { + let mut params: BLUETOOTH_DEVICE_SEARCH_PARAMS = zeroed(); + params.dwSize = size_of::() as u32; + params.fReturnAuthenticated = 1; + params.fReturnRemembered = 1; + params.fReturnConnected = 1; + params.fReturnUnknown = 1; + + let mut info: BLUETOOTH_DEVICE_INFO = zeroed(); + info.dwSize = size_of::() as u32; + + let h = BluetoothFindFirstDevice(¶ms, &mut info); + if h.is_null() { + return None; + } + + let mut found = None; + loop { + let name = utf16_name(&info.szName); + let lname = name.to_lowercase(); + if AAP_NAME_HINTS.iter().any(|h| lname.contains(h)) { + let clean = name + .trim_end_matches("- Find My") + .trim_end_matches(" -") + .trim() + .to_string(); + found = Some((info.Address.Anonymous.ullLong, clean)); + break; + } + info.dwSize = size_of::() as u32; + if BluetoothFindNextDevice(h, &mut info) == 0 { + break; + } + } + BluetoothFindDeviceClose(h); + found + } +} diff --git a/windows/daemon/src/driver.rs b/windows/daemon/src/driver.rs new file mode 100644 index 000000000..6b6979849 --- /dev/null +++ b/windows/daemon/src/driver.rs @@ -0,0 +1,215 @@ +//! Bridge to the LibrePodsAAP kernel driver. Cloneable + thread-safe so the +//! background receive loop and the tray's ANC-send can share one handle. + +use std::ffi::c_void; +use std::io; +use std::ptr; +use std::sync::Arc; + +use windows_sys::Win32::Devices::DeviceAndDriverInstallation::{ + DIGCF_DEVICEINTERFACE, DIGCF_PRESENT, SP_DEVICE_INTERFACE_DATA, + SP_DEVICE_INTERFACE_DETAIL_DATA_W, SetupDiDestroyDeviceInfoList, SetupDiEnumDeviceInterfaces, + SetupDiGetClassDevsW, SetupDiGetDeviceInterfaceDetailW, +}; +use windows_sys::Win32::Foundation::{CloseHandle, HANDLE, INVALID_HANDLE_VALUE}; +use windows_sys::Win32::Storage::FileSystem::{ + CreateFileW, FILE_SHARE_READ, FILE_SHARE_WRITE, OPEN_EXISTING, +}; +use windows_sys::Win32::System::IO::DeviceIoControl; +use windows_sys::core::GUID; + +const GENERIC_READ: u32 = 0x8000_0000; +const GENERIC_WRITE: u32 = 0x4000_0000; + +const GUID_DEVINTERFACE_LIBREPODSAAP: GUID = GUID { + data1: 0xC0FF_EE00, + data2: 0x1337, + data3: 0x4A5B, + data4: [0x9E, 0x6F, 0xA1, 0xB2, 0xC3, 0xD4, 0xE5, 0xF6], +}; + +const IOCTL_LP_CONNECT: u32 = 0x8000_2000; +const IOCTL_LP_SEND: u32 = 0x8000_2008; +const IOCTL_LP_RECEIVE: u32 = 0x8000_200C; +const IOCTL_LP_GET_STATUS: u32 = 0x8000_2010; +const IOCTL_LP_ATT_SEND: u32 = 0x8000_2014; +const IOCTL_LP_ATT_RECEIVE: u32 = 0x8000_2018; + +struct DriverHandle(HANDLE); +unsafe impl Send for DriverHandle {} +unsafe impl Sync for DriverHandle {} +impl Drop for DriverHandle { + fn drop(&mut self) { + unsafe { CloseHandle(self.0) }; + } +} + +#[derive(Clone)] +pub struct Driver { + handle: Arc, +} + +fn ioctl(handle: HANDLE, code: u32, input: &[u8], output: &mut [u8]) -> io::Result { + unsafe { + let mut returned: u32 = 0; + let in_ptr = if input.is_empty() { + ptr::null() + } else { + input.as_ptr() as *const c_void + }; + let out_ptr = if output.is_empty() { + ptr::null_mut() + } else { + output.as_mut_ptr() as *mut c_void + }; + if DeviceIoControl( + handle, code, in_ptr, input.len() as u32, out_ptr, output.len() as u32, &mut returned, + ptr::null_mut(), + ) == 0 + { + return Err(io::Error::last_os_error()); + } + Ok(returned) + } +} + +impl Driver { + pub fn open() -> io::Result { + let handle = open_driver()?; + Ok(Driver { + handle: Arc::new(DriverHandle(handle)), + }) + } + + pub fn connect(&self, addr: u64, psm: u16) -> io::Result { + let mut input = [0u8; 10]; + input[0..8].copy_from_slice(&addr.to_le_bytes()); + input[8..10].copy_from_slice(&psm.to_le_bytes()); + let mut out = [0u8; 8]; + ioctl(self.handle.0, IOCTL_LP_CONNECT, &input, &mut out)?; + Ok(u32::from_le_bytes([out[0], out[1], out[2], out[3]]) != 0) + } + + pub fn send(&self, data: &[u8]) -> io::Result<()> { + ioctl(self.handle.0, IOCTL_LP_SEND, data, &mut [])?; + Ok(()) + } + + pub fn recv(&self, timeout_ms: u32, buf: &mut [u8]) -> io::Result { + let to = timeout_ms.to_le_bytes(); + Ok(ioctl(self.handle.0, IOCTL_LP_RECEIVE, &to, buf)? as usize) + } + + /// Send a raw ATT PDU over the ATT (PSM 0x001F) hearing-aid channel. + pub fn att_send(&self, data: &[u8]) -> io::Result<()> { + ioctl(self.handle.0, IOCTL_LP_ATT_SEND, data, &mut [])?; + Ok(()) + } + + /// Receive a raw ATT PDU from the ATT channel (blocking up to timeout_ms). + pub fn att_recv(&self, timeout_ms: u32, buf: &mut [u8]) -> io::Result { + let to = timeout_ms.to_le_bytes(); + Ok(ioctl(self.handle.0, IOCTL_LP_ATT_RECEIVE, &to, buf)? as usize) + } + + /// Driver connection state (2 = connected). Reads a state variable only — + /// no L2CAP I/O, so it never disturbs the audio link. + pub fn status(&self) -> io::Result { + let mut out = [0u8; 32]; + ioctl(self.handle.0, IOCTL_LP_GET_STATUS, &[], &mut out)?; + Ok(u32::from_le_bytes([out[0], out[1], out[2], out[3]])) + } + + /// ATT (PSM 0x001F) hearing-aid server diagnostics from the driver: + /// (register_ntstatus, server_registered, connect_indications, accept_ntstatus, + /// channel_open). Lets us see the hearing-aid channel progress in the daemon log + /// without a kernel debugger. + pub fn att_diag(&self) -> io::Result<(i32, u32, u32, i32, u32)> { + let mut out = [0u8; 32]; + ioctl(self.handle.0, IOCTL_LP_GET_STATUS, &[], &mut out)?; + Ok(( + i32::from_le_bytes([out[28], out[29], out[30], out[31]]), // register status + u32::from_le_bytes([out[12], out[13], out[14], out[15]]), // registered 0/1 + u32::from_le_bytes([out[16], out[17], out[18], out[19]]), // indications + i32::from_le_bytes([out[20], out[21], out[22], out[23]]), // accept status + u32::from_le_bytes([out[24], out[25], out[26], out[27]]), // channel open + )) + } + + /// Close the underlying device handle NOW, without waiting for the last `Arc` + /// clone to drop. Graceful shutdown needs this: `std::process::exit` skips + /// destructors, and letting the OS close the handle on process teardown can + /// leave the AAP devnode stuck in Code 38 (CM_PROB_DRIVER_FAILED_PRIOR_UNLOAD), + /// so the next daemon's `driver open` fails. Closing it explicitly first runs + /// the driver's L2CAP channel teardown. All clones share the one handle, so + /// this frees it for every clone — call it only on the way out (exit right + /// after), never mid-session. + pub fn close_now(&self) { + unsafe { CloseHandle(self.handle.0) }; + } +} + +fn open_driver() -> io::Result { + unsafe { + let devinfo = SetupDiGetClassDevsW( + &GUID_DEVINTERFACE_LIBREPODSAAP, + ptr::null(), + ptr::null_mut(), + DIGCF_PRESENT | DIGCF_DEVICEINTERFACE, + ); + if devinfo == INVALID_HANDLE_VALUE as isize { + return Err(io::Error::last_os_error()); + } + + let mut ifdata: SP_DEVICE_INTERFACE_DATA = std::mem::zeroed(); + ifdata.cbSize = std::mem::size_of::() as u32; + if SetupDiEnumDeviceInterfaces( + devinfo, + ptr::null(), + &GUID_DEVINTERFACE_LIBREPODSAAP, + 0, + &mut ifdata, + ) == 0 + { + SetupDiDestroyDeviceInfoList(devinfo); + return Err(io::Error::new( + io::ErrorKind::NotFound, + "LibrePodsAAP driver not found (installed and bound to the AirPods?)", + )); + } + + let mut required: u32 = 0; + SetupDiGetDeviceInterfaceDetailW( + devinfo, &ifdata, ptr::null_mut(), 0, &mut required, ptr::null_mut(), + ); + + let mut buf = vec![0u8; required as usize]; + let detail = buf.as_mut_ptr() as *mut SP_DEVICE_INTERFACE_DETAIL_DATA_W; + (*detail).cbSize = if cfg!(target_pointer_width = "64") { 8 } else { 6 }; + + if SetupDiGetDeviceInterfaceDetailW( + devinfo, &ifdata, detail, required, ptr::null_mut(), ptr::null_mut(), + ) == 0 + { + SetupDiDestroyDeviceInfoList(devinfo); + return Err(io::Error::last_os_error()); + } + + let path = (*detail).DevicePath.as_ptr(); + let handle = CreateFileW( + path, + GENERIC_READ | GENERIC_WRITE, + FILE_SHARE_READ | FILE_SHARE_WRITE, + ptr::null(), + OPEN_EXISTING, + 0, + ptr::null_mut(), + ); + SetupDiDestroyDeviceInfoList(devinfo); + + if handle == INVALID_HANDLE_VALUE { + return Err(io::Error::last_os_error()); + } + Ok(handle) + } +} diff --git a/windows/daemon/src/eld.rs b/windows/daemon/src/eld.rs new file mode 100644 index 000000000..6abe7df36 --- /dev/null +++ b/windows/daemon/src/eld.rs @@ -0,0 +1,66 @@ +//! AAC-ELD decoder — thin Rust wrapper over the FFmpeg C shim (`eld_shim.c`). +//! Decodes the AirPods' hi-res mic frames (AAC-ELD, mono 48 kHz) to i16 PCM. + +use std::ffi::c_void; +use std::os::raw::c_int; + +extern "C" { + fn eld_open(asc: *const u8, asc_len: c_int, sample_rate: c_int) -> *mut c_void; + fn eld_decode( + h: *mut c_void, + au: *const u8, + au_len: c_int, + out: *mut i16, + out_cap: c_int, + ) -> c_int; + fn eld_close(h: *mut c_void); +} + +/// AudioSpecificConfig for the AirPods hi-res mic: AOT 39 (ER AAC ELD), 48 kHz, +/// mono. From LibrePods PR #655. +const ASC: [u8; 4] = [0xF8, 0xE6, 0x30, 0x00]; +const SAMPLE_RATE: c_int = 48_000; + +pub struct Decoder { + h: *mut c_void, + out: Vec, +} + +// The handle is only ever used from the receive thread. +unsafe impl Send for Decoder {} + +impl Decoder { + pub fn new() -> Option { + let h = unsafe { eld_open(ASC.as_ptr(), ASC.len() as c_int, SAMPLE_RATE) }; + if h.is_null() { + None + } else { + Some(Decoder { h, out: vec![0i16; 8192] }) + } + } + + /// Decode one access unit into i16 mono samples (48 kHz). May return empty + /// (the decoder buffers a frame of latency before the first output). + pub fn decode(&mut self, au: &[u8]) -> &[i16] { + let n = unsafe { + eld_decode( + self.h, + au.as_ptr(), + au.len() as c_int, + self.out.as_mut_ptr(), + self.out.len() as c_int, + ) + }; + if n <= 0 { + &[] + } else { + &self.out[..n as usize] + } + } +} + +impl Drop for Decoder { + fn drop(&mut self) { + unsafe { eld_close(self.h) }; + } +} diff --git a/windows/daemon/src/eld_shim.c b/windows/daemon/src/eld_shim.c new file mode 100644 index 000000000..f64895db7 --- /dev/null +++ b/windows/daemon/src/eld_shim.c @@ -0,0 +1,114 @@ +// Minimal AAC-ELD decoder shim over FFmpeg libavcodec + libswresample (LGPL). +// Exposes a small, version-stable C ABI so the Rust side never touches +// AVCodecContext internals. Decodes AAC-ELD access units and resamples whatever +// rate the decoder produces to a fixed 48 kHz mono s16 (matching the virtual +// mic's capture format), so the pitch is always correct. +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#define OUT_RATE 48000 +// The AirPods hi-res mic AAC-ELD actually decodes at 64 kHz (PR #655's +// ELD_SAMPLE_RATE), but its 4-byte ASC advertises index 3 (48000), so FFmpeg +// labels the frames 48000. Feeding that 64 kHz content as 48 kHz played ~0.75x +// slow (deep) and overfed the ring. Resample from the true 64 kHz instead. +#define IN_RATE 64000 +// The AirPods mic runs a few dB quiet with lots of headroom; a make-up gain +// brings it up to a comfortable level (applied on the float before resampling, +// so swr clamps cleanly if a loud transient would exceed full scale). +#define GAIN 3.0f + +typedef struct { + AVCodecContext *ctx; + AVPacket *pkt; + AVFrame *frame; + SwrContext *swr; + int in_rate; // decoder output rate, learned from the first frame +} EldDec; + +void *eld_open(const uint8_t *asc, int asc_len, int sample_rate) { + const AVCodec *c = avcodec_find_decoder_by_name("aac"); + if (!c) return NULL; + AVCodecContext *ctx = avcodec_alloc_context3(c); + if (!ctx) return NULL; + ctx->extradata = (uint8_t *)av_malloc(asc_len + AV_INPUT_BUFFER_PADDING_SIZE); + if (!ctx->extradata) { avcodec_free_context(&ctx); return NULL; } + memset(ctx->extradata, 0, asc_len + AV_INPUT_BUFFER_PADDING_SIZE); + memcpy(ctx->extradata, asc, asc_len); + ctx->extradata_size = asc_len; + ctx->sample_rate = sample_rate; // channels/rate ultimately come from the ASC + if (avcodec_open2(ctx, c, NULL) < 0) { avcodec_free_context(&ctx); return NULL; } + EldDec *d = (EldDec *)calloc(1, sizeof(EldDec)); + if (!d) { avcodec_free_context(&ctx); return NULL; } + d->ctx = ctx; + d->pkt = av_packet_alloc(); + d->frame = av_frame_alloc(); + d->swr = NULL; + d->in_rate = 0; + return d; +} + +// Decode one access unit into interleaved mono s16 at 48 kHz. `out_cap` is the +// sample capacity. Returns the number of samples written, or -1 on error. +int eld_decode(void *handle, const uint8_t *au, int au_len, int16_t *out, int out_cap) { + EldDec *d = (EldDec *)handle; + if (!d) return -1; + d->pkt->data = (uint8_t *)au; + d->pkt->size = au_len; + if (avcodec_send_packet(d->ctx, d->pkt) < 0) return -1; + + int total = 0; + while (avcodec_receive_frame(d->ctx, d->frame) == 0) { + // (Re)build the resampler when the decoder's output rate is first known + // or changes: decoder rate -> 48 kHz mono s16. + if (!d->swr || d->in_rate != d->frame->sample_rate) { + if (d->swr) swr_free(&d->swr); + AVChannelLayout out_ch; + av_channel_layout_default(&out_ch, 1); + swr_alloc_set_opts2(&d->swr, &out_ch, AV_SAMPLE_FMT_S16, OUT_RATE, + &d->frame->ch_layout, (enum AVSampleFormat)d->frame->format, + IN_RATE, 0, NULL); + swr_init(d->swr); + d->in_rate = d->frame->sample_rate; + } + // Make-up gain with a soft limiter (tanh): normal speech keeps the ~x3 + // gain (nearly linear at low levels), while loud transients saturate + // smoothly instead of hard-clipping (which sounded blown out). + if (d->frame->format == AV_SAMPLE_FMT_FLTP || d->frame->format == AV_SAMPLE_FMT_FLT) { + float *pf = (float *)d->frame->data[0]; + for (int i = 0; i < d->frame->nb_samples; i++) { + pf[i] = tanhf(pf[i] * GAIN); + } + } + uint8_t *outp = (uint8_t *)(out + total); + int room = out_cap - total; + if (room <= 0) { av_frame_unref(d->frame); break; } + int got = swr_convert(d->swr, &outp, room, + (const uint8_t **)d->frame->data, d->frame->nb_samples); + if (got > 0) total += got; + av_frame_unref(d->frame); + } + return total; +} + +// The decoder's output sample rate (learned after the first decoded frame), or 0. +int eld_in_rate(void *handle) { + EldDec *d = (EldDec *)handle; + return d ? d->in_rate : 0; +} + +void eld_close(void *handle) { + EldDec *d = (EldDec *)handle; + if (!d) return; + if (d->swr) swr_free(&d->swr); + av_frame_free(&d->frame); + av_packet_free(&d->pkt); + avcodec_free_context(&d->ctx); + free(d); +} diff --git a/windows/daemon/src/gatt.rs b/windows/daemon/src/gatt.rs new file mode 100644 index 000000000..3a47fbe3c --- /dev/null +++ b/windows/daemon/src/gatt.rs @@ -0,0 +1,166 @@ +//! One-shot GATT service/characteristic discovery over the ATT (PSM 0x001F) client +//! channel — an experiment to find a heart-rate characteristic the AirPods might +//! expose. We only ever touched the hearing-aid handle 0x2A; this walks the buds' +//! whole GATT server as a *client* (the role bthport allows on the reserved PSM) and +//! returns human-readable lines for the caller to log. Read-only: it discovers, it +//! does not subscribe. + +use crate::driver::Driver; +use std::thread; +use std::time::Duration; + +// ATT round-trip timeout (ms). The driver handle is shared with the AAP receive +// loop, so keep it short-ish. +const T: u32 = 1200; + +// Hearing-assist enable — the AirPods' GATT/ATT server is DORMANT until this is +// sent (an outbound ATT open just fails otherwise). We send only the two enable +// control commands (0x2C on + 0x33 on) and DELIBERATELY skip the Transparency +// switch the hearing-aid path uses, so probing doesn't disturb the user's noise +// control. If the ATT server won't wake without it, add it back + restore ANC after. +const HA_ON_2C: [u8; 11] = [0x04, 0x00, 0x04, 0x00, 0x09, 0x00, 0x2C, 0x01, 0x01, 0x00, 0x00]; +const HA_ON_33: [u8; 11] = [0x04, 0x00, 0x04, 0x00, 0x09, 0x00, 0x33, 0x01, 0x00, 0x00, 0x00]; + +/// Wake the buds' ATT server so the outbound ATT client channel can open. Does NOT +/// touch noise control (no Transparency switch). +fn wake(drv: &Driver) { + let _ = drv.send(&HA_ON_2C); + thread::sleep(Duration::from_millis(400)); + let _ = drv.send(&HA_ON_33); + thread::sleep(Duration::from_millis(900)); +} + +// ATT opcodes. +const OP_ERROR_RSP: u8 = 0x01; +const OP_READ_BY_TYPE_RSP: u8 = 0x09; +const OP_READ_BY_GROUP_TYPE_RSP: u8 = 0x11; + +/// One ATT request/response, dumping the raw reply hex so we can read whatever the +/// buds actually return (their discovery replies didn't match the spec opcodes, so +/// parse-by-opcode is unreliable — dump and interpret by eye). +fn round(drv: &Driver, label: &str, pdu: &[u8], out: &mut Vec, buf: &mut [u8]) -> usize { + if drv.att_send(pdu).is_err() { + out.push(format!("gatt-probe: {label}: att_send FAILED")); + return 0; + } + let n = drv.att_recv(T, buf).unwrap_or(0); + if n == 0 { + out.push(format!("gatt-probe: {label}: no response")); + return 0; + } + let hex: String = buf[..n].iter().map(|b| format!("{b:02x}")).collect(); + out.push(format!("gatt-probe: {label}: [{n}] {hex}")); + n +} + +/// Probe the AirPods' GATT server as a client. Wakes it, then sends the standard +/// discovery requests, dumping raw replies for us to interpret. +pub fn probe(drv: &Driver) -> Vec { + let mut out = Vec::new(); + let mut buf = [0u8; 512]; + + wake(drv); // buds' ATT server is dormant until this + out.push("gatt-probe: === raw discovery dump ===".into()); + + // MTU exchange. + round(drv, "mtu", &[0x02, 0xF7, 0x00], &mut out, &mut buf); + + // Primary services: Read By Group Type (0x10) of 0x2800, walking the handle range. + let mut start: u16 = 0x0001; + for _ in 0..24 { + let mut req = vec![0x10]; + req.extend_from_slice(&start.to_le_bytes()); + req.extend_from_slice(&0xFFFFu16.to_le_bytes()); + req.extend_from_slice(&0x2800u16.to_le_bytes()); + let n = round(drv, &format!("svc@0x{start:04X}"), &req, &mut out, &mut buf); + // 0x11 rsp: [op][len]{start,end,uuid}*. Advance past the last end handle. + if n < 2 || buf[0] != OP_READ_BY_GROUP_TYPE_RSP { + break; + } + let each = buf[1] as usize; + if each < 6 { + break; + } + let mut last_end = 0u16; + let mut i = 2; + while i + each <= n { + last_end = u16::from_le_bytes([buf[i + 2], buf[i + 3]]); + i += each; + } + if last_end == 0 || last_end >= 0xFFFF { + break; + } + start = last_end + 1; + } + + // Subscribe to every NOTIFY characteristic found in the discovery dump, then + // listen. Value handles from the enumerated char list; the CCCD (0x2902) sits at + // valHandle+1 (verified: hearing-aid char 0x2A has its CCCD at 0x2B). The driver's + // att_recv is a FIFO drain, so clear stale frames first for the responses to line + // up with the request we just sent. + let notify_vh: [u16; 7] = [0x0007, 0x000A, 0x000D, 0x0010, 0x0018, 0x0021, 0x002A]; + out.push("gatt-probe: === subscribing NOTIFY chars ===".into()); + for &vh in ¬ify_vh { + drain(drv, &mut buf); + let cccd = vh + 1; + let pdu = [0x12, (cccd & 0xff) as u8, (cccd >> 8) as u8, 0x01, 0x00]; + let _ = drv.att_send(&pdu); + let n = drv.att_recv(T, &mut buf).unwrap_or(0); + let verdict = if n >= 1 && buf[0] == 0x13 { + "OK (Write Rsp 0x13)".to_string() + } else if n >= 5 && buf[0] == 0x01 { + format!("ERROR (att err 0x{:02x} on handle 0x{:02x}{:02x})", buf[4], buf[3], buf[2]) + } else if n == 0 { + "no response".to_string() + } else { + format!("rsp[{n}] op=0x{:02x}", buf[0]) + }; + out.push(format!("gatt-probe: sub valH=0x{vh:04X} cccd=0x{cccd:04X} -> {verdict}")); + } + + // Listen ~15 s for Handle Value Notifications (0x1B) / Indications (0x1D). Log the + // source handle + payload hex so we can spot anything sensor/HR-shaped. + drain(drv, &mut buf); + out.push("gatt-probe: === listening ~15s ===".into()); + let mut notifs = 0u32; + for _ in 0..30 { + let n = drv.att_recv(500, &mut buf).unwrap_or(0); + if n < 3 { + continue; + } + let op = buf[0]; + if op == 0x1B || op == 0x1D { + let handle = u16::from_le_bytes([buf[1], buf[2]]); + let hex: String = buf[3..n].iter().map(|b| format!("{b:02x}")).collect(); + out.push(format!("gatt-probe: NOTIFY h=0x{handle:04X} [{}] {hex}", n - 3)); + notifs += 1; + } else { + let hex: String = buf[..n.min(48)].iter().map(|b| format!("{b:02x}")).collect(); + out.push(format!("gatt-probe: rx op=0x{op:02x} [{n}] {hex}")); + } + } + + let _ = OP_ERROR_RSP; + out.push(format!("gatt-probe: === done ({notifs} notifications) ===")); + out +} + +/// Drain any queued/stale ATT frames (the driver's att_recv is FIFO), so the next +/// send/recv pair lines up. Stops on the first empty read. +fn drain(drv: &Driver, buf: &mut [u8]) { + for _ in 0..8 { + if drv.att_recv(120, buf).unwrap_or(0) == 0 { + break; + } + } +} + +/// UUIDs are little-endian on the ATT wire; render big-endian. 16-bit as 0xXXXX, +/// 128-bit as a dash-less hex string. +fn uuid_str(b: &[u8]) -> String { + if b.len() == 2 { + format!("0x{:04X}", u16::from_le_bytes([b[0], b[1]])) + } else { + b.iter().rev().map(|x| format!("{x:02x}")).collect() + } +} diff --git a/windows/daemon/src/hearing.rs b/windows/daemon/src/hearing.rs new file mode 100644 index 000000000..3c5c9210b --- /dev/null +++ b/windows/daemon/src/hearing.rs @@ -0,0 +1,219 @@ +//! AirPods Pro 3 hearing assistance: enable it over AAP (control commands 0x2C / +//! 0x33), switch to Transparency, then write the full settings to the ATT/GATT +//! (PSM 0x001F, handle 0x2A) via a read-modify-write. Layout + semantics are ported +//! from the Linux `hearing-aid-adjustments.py`: an 8-band audiogram (hearing loss in +//! dB HL) per ear, per-ear amplification, tone, conversation-boost, ambient-noise- +//! reduction and own-voice — all little-endian f32 at fixed offsets. + +use crate::aap; +use crate::driver::Driver; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::Mutex; +use std::{thread, time::Duration}; + +// Serializes hearing-aid applies AND remembers whether the buds are already in +// hearing-assist mode. Holding this across a whole apply() stops two applies (a +// toggle + a slider tweak, or two quick tweaks — each spawned on its own thread by +// main.rs) from interleaving I/O on the shared ATT channel and tearing it down. +// The bool is the "already enabled" flag: true once the AAP handshake has run. +static HEARING_STATE: Mutex = Mutex::new(false); + +// Latest-wins coalescing. Filling in the 8-band audiogram fires one command per box; +// each apply hammers the exclusive driver handle (shared with the AAP receive loop) +// with ATT round-trips, and a burst of them starves AAP reads until the link looks +// dead and gets torn down. So every command bumps this generation; an apply that +// finds a newer generation waiting bails before touching the driver — a flood of N +// applies collapses to just the last one. +static HEARING_GEN: AtomicU64 = AtomicU64::new(0); + +/// Register a new hearing-aid command and get its generation. The apply started for +/// this generation will no-op if a later one arrives before it reaches the driver. +pub fn next_gen() -> u64 { + HEARING_GEN.fetch_add(1, Ordering::SeqCst) + 1 +} + +// AAP hearing-assist enable/disable (0x09 control commands 0x2C / 0x33). +const HA_ON_2C: [u8; 11] = [0x04, 0x00, 0x04, 0x00, 0x09, 0x00, 0x2C, 0x01, 0x01, 0x00, 0x00]; +const HA_ON_33: [u8; 11] = [0x04, 0x00, 0x04, 0x00, 0x09, 0x00, 0x33, 0x01, 0x00, 0x00, 0x00]; +const HA_OFF_2C: [u8; 11] = [0x04, 0x00, 0x04, 0x00, 0x09, 0x00, 0x2C, 0x01, 0x02, 0x00, 0x00]; +const HA_OFF_33: [u8; 11] = [0x04, 0x00, 0x04, 0x00, 0x09, 0x00, 0x33, 0x02, 0x00, 0x00, 0x00]; + +const H_SETTINGS: u16 = 0x002A; // hearing-aid settings characteristic +const H_CCCD: u16 = 0x002B; // its client-config descriptor + +// ATT round-trip timeout. Kept short: the driver handle is exclusive and shared with +// the AAP receive loop, so a long ATT stall here starves AAP reads and can trip the +// link-lost teardown. Better to fail fast and let the caller retry than to block. +const ATT_TIMEOUT_MS: u32 = 900; + +// f32 offsets into the settings value (bytes after the ATT opcode) — mirror the +// Linux reference exactly. +const OFF_MODE: usize = 2; +const OFF_LEFT_EQ: usize = 4; // + i*4, 8 bands +const OFF_LEFT_AMP: usize = 36; +const OFF_LEFT_TONE: usize = 40; +const OFF_LEFT_CONV: usize = 44; +const OFF_LEFT_ANR: usize = 48; +const OFF_RIGHT_EQ: usize = 52; // + i*4, 8 bands +const OFF_RIGHT_AMP: usize = 84; +const OFF_RIGHT_TONE: usize = 88; +const OFF_RIGHT_CONV: usize = 92; +const OFF_RIGHT_ANR: usize = 96; +const OFF_OWN_VOICE: usize = 100; + +fn put_f32(buf: &mut [u8], off: usize, v: f32) { + if off + 4 <= buf.len() { + buf[off..off + 4].copy_from_slice(&v.to_le_bytes()); + } +} + +fn att_read_req(handle: u16) -> [u8; 3] { + [0x0A, (handle & 0xff) as u8, (handle >> 8) as u8] +} + +fn att_write_pdu(handle: u16, value: &[u8]) -> Vec { + let mut p = vec![0x12u8, (handle & 0xff) as u8, (handle >> 8) as u8]; + p.extend_from_slice(value); + p +} + +/// Wake the buds' hearing-aid ATT server (dormant until enabled), switch to +/// Transparency (mode 3) so ambient sound passes through to be amplified, and enable +/// notifications on the settings CCCD. Only needed on the off→on transition (or to +/// recover a dropped ATT channel) — NOT on every slider tweak, since re-sending the +/// AAP enable makes the buds reset their ATT server and drop our channel. +fn enable_handshake(drv: &Driver) { + let _ = drv.send(&HA_ON_2C); + thread::sleep(Duration::from_millis(300)); + let _ = drv.send(&aap::anc_command(3)); + thread::sleep(Duration::from_millis(200)); + let _ = drv.send(&HA_ON_33); + thread::sleep(Duration::from_millis(900)); + + let mut b = [0u8; 512]; + let _ = drv.att_send(&att_write_pdu(H_CCCD, &[0x01, 0x00])); + let _ = drv.att_recv(ATT_TIMEOUT_MS, &mut b); +} + +/// Read-modify-write the settings characteristic over the (already-open) ATT channel. +/// This is the only step a settings tweak needs — no AAP re-enable, so the ATT +/// channel stays up. +#[allow(clippy::too_many_arguments)] +fn write_settings( + drv: &Driver, + left_eq: &[f32], + right_eq: &[f32], + amplification: f32, + balance: f32, + tone: f32, + conv_boost: bool, + anr: f32, + own_voice: f32, +) -> Result { + let mut b = [0u8; 512]; + + // 1) Read the current settings value (read-modify-write). + let _ = drv.att_send(&att_read_req(H_SETTINGS)); + let n = drv + .att_recv(ATT_TIMEOUT_MS, &mut b) + .map_err(|e| format!("ATT read err: {e}"))?; + if n < 104 || b[0] != 0x0B { + return Err(format!("bad ATT read resp [{n}]")); + } + let mut val = b[1..n].to_vec(); // the characteristic value (~104 bytes) + + // 2) Patch: audiogram (per-frequency loss in dB HL) + per-ear amplification from + // amplification/balance + tone/conversation-boost/ANR/own-voice. + let amp = amplification.clamp(0.0, 1.0); + let bal = balance.clamp(-1.0, 1.0); + let left_amp = (amp - bal / 2.0).clamp(-1.0, 1.0); + let right_amp = (amp + bal / 2.0).clamp(-1.0, 1.0); + let cb = if conv_boost { 1.0f32 } else { 0.0f32 }; + let tone = tone.clamp(-1.0, 1.0); + let anr = anr.clamp(0.0, 1.0); + let own_voice = own_voice.clamp(0.0, 1.0); + + if val.len() > OFF_MODE { + val[OFF_MODE] = 0x64; + } + for i in 0..8usize { + put_f32(&mut val, OFF_LEFT_EQ + i * 4, *left_eq.get(i).unwrap_or(&0.0)); + put_f32(&mut val, OFF_RIGHT_EQ + i * 4, *right_eq.get(i).unwrap_or(&0.0)); + } + put_f32(&mut val, OFF_LEFT_AMP, left_amp); + put_f32(&mut val, OFF_LEFT_TONE, tone); + put_f32(&mut val, OFF_LEFT_CONV, cb); + put_f32(&mut val, OFF_LEFT_ANR, anr); + put_f32(&mut val, OFF_RIGHT_AMP, right_amp); + put_f32(&mut val, OFF_RIGHT_TONE, tone); + put_f32(&mut val, OFF_RIGHT_CONV, cb); + put_f32(&mut val, OFF_RIGHT_ANR, anr); + put_f32(&mut val, OFF_OWN_VOICE, own_voice); + + // 3) Write it back. + let _ = drv.att_send(&att_write_pdu(H_SETTINGS, &val)); + let wn = drv.att_recv(ATT_TIMEOUT_MS, &mut b).unwrap_or(0); + let wr = if wn >= 1 && b[0] == 0x13 { "ok" } else { "no-resp" }; + + Ok(format!( + "hearing aid ON: wrote {} bytes leftAmp={left_amp:.2} rightAmp={right_amp:.2} conv={conv_boost} eqL={left_eq:?} write={wr}", + val.len() + )) +} + +/// Apply hearing-assist settings. Requires the AAP + ATT channels to be up (the +/// driver opens ATT on connect). Returns a short summary for the daemon log. +#[allow(clippy::too_many_arguments)] +pub fn apply( + drv: &Driver, + gen: u64, + on: bool, + left_eq: &[f32], + right_eq: &[f32], + amplification: f32, + balance: f32, + tone: f32, + conv_boost: bool, + anr: f32, + own_voice: f32, +) -> Result { + // Serialize applies and read the "already enabled" flag under the same lock, so + // overlapping applies can't interleave ATT I/O on the shared channel. + let mut state = HEARING_STATE.lock().unwrap_or_else(|p| p.into_inner()); + + // A newer command arrived while we waited for the lock — drop this stale apply + // before touching the driver (latest-wins coalescing). + if HEARING_GEN.load(Ordering::SeqCst) != gen { + return Ok(format!("hearing aid apply superseded (gen {gen})")); + } + + if !on { + let _ = drv.send(&HA_OFF_33); + thread::sleep(Duration::from_millis(300)); + let _ = drv.send(&HA_OFF_2C); + *state = false; + return Ok("hearing aid OFF".into()); + } + + // Only run the AAP enable handshake on the off→on transition; a settings tweak + // while already on goes straight to the ATT write and leaves the channel alone. + if !*state { + enable_handshake(drv); + *state = true; + } + + match write_settings( + drv, left_eq, right_eq, amplification, balance, tone, conv_boost, anr, own_voice, + ) { + Ok(s) => Ok(s), + // The ATT channel may have idle-closed since we last used it — re-run the + // enable handshake once (which reopens the buds' ATT server) and retry. + Err(e) => { + enable_handshake(drv); + write_settings( + drv, left_eq, right_eq, amplification, balance, tone, conv_boost, anr, own_voice, + ) + .map_err(|e2| format!("{e}; after re-enable: {e2}")) + } + } +} diff --git a/windows/daemon/src/hr.rs b/windows/daemon/src/hr.rs new file mode 100644 index 000000000..97669feff --- /dev/null +++ b/windows/daemon/src/hr.rs @@ -0,0 +1,443 @@ +//! AirPods Pro 3 RTBuddy heart-rate decoding. +//! +//! A faithful Rust port of `RtBuddyHeartRateDecoder` (Android/Kotlin, PR #702). +//! Reassembles RTBuddy SensorDataWX frames across chunks and extracts the +//! verified HEARTRATE(19) payload. The protocol checks that stop control/startup +//! frames from being read as BPM are kept deliberately intact: live log type, +//! service 19, exact 18-byte payload, a known status trailer, and the validated +//! physiological range. Length-delimited wrappers are traversed only to the same +//! bounded depth as the observed firmware variants. +//! +//! Unlike the Kotlin decoder (which owns the whole recv stream and re-dispatches +//! non-HR "passthrough" packets), this runs *alongside* the daemon's existing +//! recv loop: `feed` is handed a copy of each chunk and returns only decoded BPM +//! samples. Battery/ANC/ear parsing still runs on the same bytes independently. + +// ---- constants (mirror the Kotlin companion object) ---- + +const AACP_RTBUDDY_HEADER_LENGTH: usize = 12; +const MAX_RTBUDDY_PAYLOAD_LENGTH: usize = 16 * 1024; + +/// Firmware has emitted live records with both log types. +const LIVE_SENSOR_DATA_LOG_TYPES: [u64; 2] = [1, 3]; + +/// Different exact status trailers depending on whether one or both earbuds +/// participate in the session. +const KNOWN_HEART_RATE_STATUS_TAILS: [[u8; 3]; 4] = [ + [0x10, 0x00, 0x00], + [0x20, 0x00, 0x00], + [0x20, 0x02, 0x80], + [0x20, 0x82, 0x80], +]; + +const FIELD_LOG_TYPE: u32 = 2; +const FIELD_SERVICE: u32 = 1; +const FIELD_COMMAND_PAYLOAD: u32 = 3; +// Services that carry HR REPORTS, per the maintainer + thibaup (Discord 2026-08-13): +// 8* firmware sets AND reports on 19 (HEARTRATE); 9* firmware sets on 84 +// (HEARTRATE_COMMAND) but the readings arrive on 20. Accept all three so we catch the +// data whichever service the firmware reports on. +const HEART_RATE_REPORT_SERVICES: [u64; 3] = [84, 20, 19]; +const HEART_RATE_PAYLOAD_LENGTH: usize = 18; +const HEART_RATE_BPM_OFFSET: usize = 1; +const HEART_RATE_STATUS_TAIL_OFFSET: usize = 15; +const MIN_BPM: u8 = 30; +const MAX_BPM: u8 = 220; + +const SENSOR_DATA_COMMAND_FIELDS: [u32; 5] = [5, 7, 8, 9, 12]; +const MAX_COMMAND_ENVELOPE_DEPTH: u32 = 3; +const MAX_PAYLOAD_WRAPPER_DEPTH: u32 = 3; +const MAX_COMMANDS_PER_FRAME: usize = 16; +const MAX_PAYLOAD_CANDIDATES_PER_COMMAND: usize = 12; +const MAX_PROTO_MESSAGE_LENGTH: usize = MAX_RTBUDDY_PAYLOAD_LENGTH; +const MAX_PROTO_FIELDS: usize = 96; +const MAX_PROTO_FIELD_NUMBER: u32 = 4_096; + +const WIRE_VARINT: u8 = 0; +const WIRE_FIXED64: u8 = 1; +const WIRE_LENGTH_DELIMITED: u8 = 2; +const WIRE_FIXED32: u8 = 5; + +/// type=0x0004, service=0x0004, opcode=0x0017, descriptor=0x00100000 — the +/// AACP/RTBuddy SensorDataWX frame prefix (first 10 bytes of the 12-byte header). +const RTBUDDY_FRAME_PREFIX: [u8; 10] = + [0x04, 0x00, 0x04, 0x00, 0x17, 0x00, 0x00, 0x00, 0x10, 0x00]; + +// ---- proto scaffolding ---- + +struct ProtoField { + number: u32, + wire_type: u8, + varint_value: Option, + value_start: usize, + value_end: usize, +} + +struct ProtoMessage { + fields: Vec, +} + +impl ProtoMessage { + fn first_varint(&self, field_number: u32) -> Option { + self.fields + .iter() + .find(|f| f.number == field_number && f.wire_type == WIRE_VARINT) + .and_then(|f| f.varint_value) + } +} + +struct VarintRead { + value: u64, + next_index: usize, +} + +/// One HEARTRATE(19) command: its candidate payload byte-slices. +struct HeartRateCommand { + payload_candidates: Vec>, +} + +/// Reassembles RTBuddy frames and extracts verified HEARTRATE samples. +pub struct RtBuddyHeartRateDecoder { + carry: Vec, +} + +impl Default for RtBuddyHeartRateDecoder { + fn default() -> Self { + Self::new() + } +} + +impl RtBuddyHeartRateDecoder { + pub fn new() -> Self { + RtBuddyHeartRateDecoder { carry: Vec::new() } + } + + /// Drop any partial-frame carry (call on connect/disconnect). + pub fn reset(&mut self) { + self.carry.clear(); + } + + /// Feed one received chunk; returns any newly-decoded BPM samples. + pub fn feed(&mut self, chunk: &[u8]) -> Vec { + let mut samples = Vec::new(); + if chunk.is_empty() { + return samples; + } + + let data: Vec = if self.carry.is_empty() { + chunk.to_vec() + } else { + let mut d = std::mem::take(&mut self.carry); + d.extend_from_slice(chunk); + d + }; + self.carry.clear(); + + let mut cursor = 0usize; + while cursor < data.len() { + let frame_offset = match index_of_prefix(&data, &RTBUDDY_FRAME_PREFIX, cursor) { + Some(off) => off, + None => { + // No further frame starts; keep a partial prefix at the tail + // so it can complete with the next chunk. + let suffix_length = + longest_suffix_matching_prefix(&data, &RTBUDDY_FRAME_PREFIX, cursor); + if suffix_length > 0 { + let passthrough_end = data.len() - suffix_length; + self.carry = data[passthrough_end..].to_vec(); + } + break; + } + }; + + if data.len() - frame_offset < AACP_RTBUDDY_HEADER_LENGTH { + self.carry = data[frame_offset..].to_vec(); + break; + } + + let payload_length = read_le16(&data, frame_offset + 10); + if payload_length > MAX_RTBUDDY_PAYLOAD_LENGTH { + // The declared length is untrusted — drop the rest. + break; + } + + let frame_length = AACP_RTBUDDY_HEADER_LENGTH + payload_length; + if data.len() - frame_offset < frame_length { + self.carry = data[frame_offset..].to_vec(); + break; + } + + let frame = &data[frame_offset..frame_offset + frame_length]; + if let Some(bpm) = classify_frame(frame) { + samples.push(bpm); + } + cursor = frame_offset + frame_length; + } + + samples + } +} + +/// Classify a reassembled frame; returns a validated BPM if (and only if) it is a +/// live HEARTRATE record with an accepted payload. +fn classify_frame(frame: &[u8]) -> Option { + let top_level = parse_proto_message(frame, AACP_RTBUDDY_HEADER_LENGTH, frame.len())?; + + let log_type = top_level.first_varint(FIELD_LOG_TYPE).map(|v| v as i64).unwrap_or(-1); + let mut commands: Vec = Vec::new(); + + for field in &top_level.fields { + if field.wire_type == WIRE_LENGTH_DELIMITED + && SENSOR_DATA_COMMAND_FIELDS.contains(&field.number) + && commands.len() < MAX_COMMANDS_PER_FRAME + { + collect_heart_rate_commands(frame, field.value_start, field.value_end, 0, &mut commands); + } + } + + if commands.is_empty() { + return None; + } + if !LIVE_SENSOR_DATA_LOG_TYPES.contains(&(log_type as u64)) { + // Related but the wrong log type — reject (control/startup frame). + return None; + } + + let payloads: Vec<&Vec> = commands + .iter() + .flat_map(|c| c.payload_candidates.iter()) + .collect(); + + payloads + .iter() + .find(|p| is_valid_heart_rate_payload(p)) + .map(|p| p[HEART_RATE_BPM_OFFSET] as u16) +} + +fn collect_heart_rate_commands( + data: &[u8], + start: usize, + end: usize, + depth: u32, + commands: &mut Vec, +) { + if depth > MAX_COMMAND_ENVELOPE_DEPTH || commands.len() >= MAX_COMMANDS_PER_FRAME { + return; + } + let message = match parse_proto_message(data, start, end) { + Some(m) => m, + None => return, + }; + let service = message.first_varint(FIELD_SERVICE); + + if service.is_some_and(|s| HEART_RATE_REPORT_SERVICES.contains(&s)) { + let mut payloads: Vec> = Vec::new(); + for field in &message.fields { + if field.number == FIELD_COMMAND_PAYLOAD && field.wire_type == WIRE_LENGTH_DELIMITED { + collect_payload_candidates(data, field.value_start, field.value_end, 0, &mut payloads); + } + } + commands.push(HeartRateCommand { payload_candidates: payloads }); + } + + if depth == MAX_COMMAND_ENVELOPE_DEPTH { + return; + } + for field in &message.fields { + if field.wire_type == WIRE_LENGTH_DELIMITED && commands.len() < MAX_COMMANDS_PER_FRAME { + collect_heart_rate_commands(data, field.value_start, field.value_end, depth + 1, commands); + } + } +} + +fn collect_payload_candidates( + data: &[u8], + start: usize, + end: usize, + depth: u32, + candidates: &mut Vec>, +) { + if candidates.len() >= MAX_PAYLOAD_CANDIDATES_PER_COMMAND { + return; + } + + let direct = data[start..end].to_vec(); + if !candidates.iter().any(|c| *c == direct) { + candidates.push(direct); + } + if depth >= MAX_PAYLOAD_WRAPPER_DEPTH { + return; + } + + let wrapper = match parse_proto_message(data, start, end) { + Some(m) => m, + None => return, + }; + for field in &wrapper.fields { + if field.wire_type == WIRE_LENGTH_DELIMITED + && candidates.len() < MAX_PAYLOAD_CANDIDATES_PER_COMMAND + { + collect_payload_candidates(data, field.value_start, field.value_end, depth + 1, candidates); + } + } +} + +fn is_valid_heart_rate_payload(payload: &[u8]) -> bool { + if payload.len() != HEART_RATE_PAYLOAD_LENGTH { + return false; + } + let bpm = payload[HEART_RATE_BPM_OFFSET]; + if bpm < MIN_BPM || bpm > MAX_BPM { + return false; + } + KNOWN_HEART_RATE_STATUS_TAILS.iter().any(|tail| { + tail.iter() + .enumerate() + .all(|(i, b)| payload[HEART_RATE_STATUS_TAIL_OFFSET + i] == *b) + }) +} + +fn parse_proto_message(data: &[u8], start: usize, end: usize) -> Option { + if end < start || end > data.len() || end - start > MAX_PROTO_MESSAGE_LENGTH { + return None; + } + + let mut fields: Vec = Vec::new(); + let mut index = start; + while index < end { + if fields.len() >= MAX_PROTO_FIELDS { + return None; + } + let key = read_varint(data, index, end)?; + index = key.next_index; + + let field_number = (key.value >> 3) as u32; + if field_number == 0 || field_number as u64 > MAX_PROTO_FIELD_NUMBER as u64 { + return None; + } + let wire_type = (key.value & 0x07) as u8; + + match wire_type { + WIRE_VARINT => { + let value = read_varint(data, index, end)?; + fields.push(ProtoField { + number: field_number, + wire_type, + varint_value: Some(value.value), + value_start: index, + value_end: value.next_index, + }); + index = value.next_index; + } + WIRE_LENGTH_DELIMITED => { + let length = read_varint(data, index, end)?; + if length.value > usize::MAX as u64 { + return None; + } + let value_end = length.next_index.checked_add(length.value as usize)?; + if value_end < length.next_index || value_end > end { + return None; + } + fields.push(ProtoField { + number: field_number, + wire_type, + varint_value: None, + value_start: length.next_index, + value_end, + }); + index = value_end; + } + WIRE_FIXED64 => { + if end - index < 8 { + return None; + } + fields.push(ProtoField { + number: field_number, + wire_type, + varint_value: None, + value_start: index, + value_end: index + 8, + }); + index += 8; + } + WIRE_FIXED32 => { + if end - index < 4 { + return None; + } + fields.push(ProtoField { + number: field_number, + wire_type, + varint_value: None, + value_start: index, + value_end: index + 4, + }); + index += 4; + } + _ => return None, + } + } + Some(ProtoMessage { fields }) +} + +fn read_varint(data: &[u8], start: usize, end: usize) -> Option { + let mut value: u64 = 0; + let mut shift: u32 = 0; + let mut index = start; + + while index < end && shift < 64 { + let byte = data[index] as u64; + index += 1; + value |= (byte & 0x7F) << shift; + if byte & 0x80 == 0 { + return Some(VarintRead { value, next_index: index }); + } + shift += 7; + } + None +} + +// ---- byte-array helpers (mirror the Kotlin extension functions) ---- + +fn read_le16(data: &[u8], offset: usize) -> usize { + (data[offset] as usize) | ((data[offset + 1] as usize) << 8) +} + +/// Diagnostic: does this chunk contain an RTBuddy live-frame prefix? Used only +/// for logging whether the AirPods are actually streaming HR frames. +pub fn contains_frame_prefix(data: &[u8]) -> bool { + index_of_prefix(data, &RTBUDDY_FRAME_PREFIX, 0).is_some() +} + +fn index_of_prefix(data: &[u8], prefix: &[u8], start_index: usize) -> Option { + if prefix.is_empty() { + return Some(start_index.min(data.len())); + } + if data.len() < prefix.len() { + return None; + } + let last_start = data.len() - prefix.len(); + if start_index > last_start { + return None; + } + for start in start_index..=last_start { + if prefix.iter().enumerate().all(|(i, b)| data[start + i] == *b) { + return Some(start); + } + } + None +} + +/// The longest suffix of `data[start_index..]` that matches a *prefix* of +/// `prefix` (i.e. a truncated frame header at the tail we should carry forward). +fn longest_suffix_matching_prefix(data: &[u8], prefix: &[u8], start_index: usize) -> usize { + let start_index = start_index.min(data.len()); + let available = data.len() - start_index; + let max_length = available.min(prefix.len().saturating_sub(1)); + for length in (1..=max_length).rev() { + let start = data.len() - length; + if (0..length).all(|i| data[start + i] == prefix[i]) { + return length; + } + } + 0 +} diff --git a/windows/daemon/src/le.rs b/windows/daemon/src/le.rs new file mode 100644 index 000000000..14c75e979 --- /dev/null +++ b/windows/daemon/src/le.rs @@ -0,0 +1,95 @@ +//! BLE advertisement watcher: detect the AirPods nearby (their Apple +//! proximity-pairing advertisement) so the daemon can prompt "connect?" before +//! it opens the AAP session. Reads Apple manufacturer data from LE advertisements to detect the AirPods. +//! +//! Scanning is PASSIVE (listen only, no scan requests) and only runs while +//! `should_scan` is true — i.e. while disconnected — so the 2.4 GHz radio never +//! contends with the AirPods' A2DP audio while you're listening (which caused +//! static spikes, esp. on combo cards with poor coexistence). + +use windows::Devices::Bluetooth::Advertisement::{ + BluetoothLEAdvertisementReceivedEventArgs, BluetoothLEAdvertisementWatcher, + BluetoothLEScanningMode, +}; +use windows::Foundation::TypedEventHandler; +use windows::Storage::Streams::DataReader; +use windows::Win32::System::Com::{CoInitializeEx, COINIT_MULTITHREADED}; + +const APPLE_COMPANY_ID: u16 = 0x004C; +/// Apple manufacturer-data message type for proximity pairing (AirPods / Beats). +const PROXIMITY_PAIRING: u8 = 0x07; + +/// Watch for AirPods proximity advertisements, calling `on_nearby` on each — but +/// only scan while `should_scan()` is true. Blocks (keeps COM + the watcher +/// alive), so run it on its own thread. +pub fn watch_nearby( + on_nearby: impl Fn() + Send + Sync + 'static, + should_scan: impl Fn() -> bool + Send + 'static, +) { + unsafe { + let _ = CoInitializeEx(None, COINIT_MULTITHREADED); + } + let watcher = match setup(on_nearby) { + Ok(w) => w, + Err(_) => return, + }; + let mut scanning = false; + loop { + let want = should_scan(); + if want && !scanning { + scanning = watcher.Start().is_ok(); + } else if !want && scanning { + let _ = watcher.Stop(); + scanning = false; + } + std::thread::sleep(std::time::Duration::from_secs(2)); + } +} + +fn setup( + on_nearby: impl Fn() + Send + Sync + 'static, +) -> windows::core::Result { + let watcher = BluetoothLEAdvertisementWatcher::new()?; + watcher.SetScanningMode(BluetoothLEScanningMode::Passive)?; + let handler = TypedEventHandler::new( + move |_s: &Option, + args: &Option| { + if let Some(args) = args.as_ref() { + if is_airpods(args) { + on_nearby(); + } + } + Ok(()) + }, + ); + watcher.Received(&handler)?; + Ok(watcher) +} + +/// True if this advertisement is an Apple proximity-pairing message (AirPods / +/// Beats). (Not IRK-resolved — any nearby AirPods matches; fine for one user.) +fn is_airpods(args: &BluetoothLEAdvertisementReceivedEventArgs) -> bool { + let Ok(adv) = args.Advertisement() else { + return false; + }; + let Ok(mfg) = adv.ManufacturerData() else { + return false; + }; + let size = mfg.Size().unwrap_or(0); + for i in 0..size { + let Ok(md) = mfg.GetAt(i) else { continue }; + if md.CompanyId().unwrap_or(0) != APPLE_COMPANY_ID { + continue; + } + if let Ok(buf) = md.Data() { + let len = buf.Length().unwrap_or(0) as usize; + if let Ok(reader) = DataReader::FromBuffer(&buf) { + let mut bytes = vec![0u8; len]; + if reader.ReadBytes(&mut bytes).is_ok() && bytes.first() == Some(&PROXIMITY_PAIRING) { + return true; + } + } + } + } + false +} diff --git a/windows/daemon/src/main.rs b/windows/daemon/src/main.rs new file mode 100644 index 000000000..9e1661e8a --- /dev/null +++ b/windows/daemon/src/main.rs @@ -0,0 +1,1568 @@ +//! librepodsd — the LibrePods Windows daemon. Owns the exclusive AAP driver +//! handle, the AAP session, and the hi-res mic pipeline, and serves the tray / +//! full app over a named-pipe IPC (NDJSON). See ../../../docs/windows/daemon-ipc/PLAN.md. +//! Runs headless — no console window (it's spawned by the tray/app). + +#![windows_subsystem = "windows"] +#![allow(dead_code)] + +mod a2dp; +mod aap; +mod bt; +mod driver; +mod eld; +mod gatt; +mod hearing; +mod hr; +mod le; +mod media; +mod micpipe; +mod rename; +mod volume; + +use std::ptr; +use std::sync::atomic::{AtomicBool, AtomicU16, Ordering}; +use std::sync::{Arc, Mutex}; +use std::thread; +use std::time::{Duration, Instant}; + +use librepods_ipc as ipc; +use librepods_ipc::{ + from_line, to_line, Command, Event, Snapshot, PIPE_CMDS, PIPE_EVENTS, PIPE_L2CAP_RX, + PIPE_L2CAP_TX, +}; + +use windows_sys::Win32::Foundation::{ + CloseHandle, GetLastError, ERROR_ALREADY_EXISTS, ERROR_PIPE_CONNECTED, HANDLE, + INVALID_HANDLE_VALUE, +}; +use windows_sys::Win32::Security::Authorization::ConvertStringSecurityDescriptorToSecurityDescriptorW; +use windows_sys::Win32::Security::{PSECURITY_DESCRIPTOR, SECURITY_ATTRIBUTES}; +use windows_sys::Win32::Storage::FileSystem::{ReadFile, WriteFile, PIPE_ACCESS_DUPLEX}; +use windows_sys::Win32::System::Pipes::{ + ConnectNamedPipe, CreateNamedPipeW, PIPE_READMODE_BYTE, PIPE_TYPE_BYTE, PIPE_UNLIMITED_INSTANCES, + PIPE_WAIT, +}; +use windows_sys::Win32::System::Threading::CreateMutexW; + +fn wide(s: &str) -> Vec { + s.encode_utf16().chain(std::iter::once(0)).collect() +} + +/// Frame a raw AAP packet for the L2CAP proxy: u16 LE length, then the bytes. +fn frame(packet: &[u8]) -> Vec { + let mut f = Vec::with_capacity(packet.len() + 2); + f.extend_from_slice(&(packet.len() as u16).to_le_bytes()); + f.extend_from_slice(packet); + f +} + +fn log(s: &str) { + use std::io::Write; + // Wall-clock UTC HH:MM:SS.mmm prefix so log lines can be correlated in time. + let ts = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| { + let secs = d.as_secs() % 86_400; + format!( + "{:02}:{:02}:{:02}.{:03}", + secs / 3600, + (secs % 3600) / 60, + secs % 60, + d.subsec_millis() + ) + }) + .unwrap_or_default(); + if let Ok(la) = std::env::var("LOCALAPPDATA") { + if let Ok(mut f) = std::fs::OpenOptions::new() + .create(true) + .append(true) + .open(format!("{la}\\LibrePods\\daemon.log")) + { + let _ = writeln!(f, "{ts} {s}"); + } + } +} + +fn battery_text(b: &librepods_ipc::Battery, connected: bool) -> String { + if !connected { + return "Disconnected".to_string(); + } + let f = |v: Option| v.map(|p| format!("{p}%")).unwrap_or_else(|| "—".into()); + format!("Left {} Right {} Case {}", f(b.left), f(b.right), f(b.case)) +} + +/// Owns a client's pipe HANDLE; closes it once both the reader and writer +/// threads have dropped their `Arc`. Duplex pipes allow the reader and +/// writer to use the same handle concurrently. +struct Pipe(HANDLE); +impl Drop for Pipe { + fn drop(&mut self) { + unsafe { CloseHandle(self.0) }; + } +} +unsafe impl Send for Pipe {} +unsafe impl Sync for Pipe {} + +/// Outgoing queue to one client (drained by its writer thread — so a slow client +/// never blocks the session/broadcast, i.e. async delivery). +type ClientTx = std::sync::mpsc::Sender>; + +/// Everything the session, poll and IPC threads share. +#[derive(Clone)] +struct Ctx { + state: Arc>, + clients: Arc>>, + /// Raw-L2CAP proxy clients (the full app): each incoming AAP packet is + /// forwarded to them (length-prefixed) so the app runs its session over us. + l2cap_clients: Arc>>, + /// The last battery + ANC packets (raw), keyed by kind (0=battery, 1=ANC), + /// replayed to a newly-attached app so it shows the current state without us + /// re-requesting (which cuts audio). + replay: Arc>>>, + driver_cell: Arc>>, + mic_on: Arc, + auto_mode: Arc, + /// AirPods Pro 3 heart-rate monitoring is on (opt-in — off by default). When + /// set, `run_receiver` feeds each recv chunk into the RTBuddy HR decoder. + hr_on: Arc, + /// Set by `run_receiver` the moment the decoder yields its first BPM sample. + /// The HR retry thread polls this to know an enable attempt actually took + /// (the RTBuddy stream almost never starts on the first try) — cleared before + /// each attempt. This is the run_receiver↔retry-thread rendezvous. + hr_got_sample: Arc, + /// Set by `run_receiver` when an HR-prefixed frame arrives (the HEARTRATE + /// service is streaming, even if it carries no reading payload yet). Once the + /// service is live the retry campaign stops churning STOP/re-init and just + /// keeps the stream open, so a reading can land whenever the sensor produces + /// one (it may take real sustained activity). Cleared before each attempt. + hr_stream_live: Arc, + /// True while an HR retry thread is live. A one-thread guard so rapid on/off + /// never stacks two retry campaigns over the one driver. + hr_retrying: Arc, + /// The user accepted the "connect?" prompt — the session may start. + connect_requested: Arc, + /// Set by the "Repair connection" command: asks `run_receiver` to drop + reopen + /// the driver (fresh AAP session + ATT channel) to recover a wedged / desynced + /// link. run_receiver clears it. + wants_reconnect: Arc, + pipe: Arc, + /// Conversational Awareness volume duck — shared so `apply_command` can + /// restore the volume if the user turns CA off mid-duck (no end event comes). + conv_duck: Arc>, + /// Latest rename seen on the app→driver proxy + when. Flushed to an overlay + /// once it settles (the app may send several as you type), so there's a + /// visible "Renamed to X" confirmation. + pending_rename: Arc>>, + /// The ANC mode last commanded by the user + when. Used to ignore the + /// transitional ANC echoes the AirPods emit while switching modes quickly + /// (they briefly report Off), so the UI/toasts don't flicker through Off. + anc_cmd: Arc>>, + dev_name: Arc>, + mac: u64, +} + +impl Ctx { + /// Queue one NDJSON event for every client (never blocks — each client's + /// writer thread drains its own queue). Drops clients whose writer has gone. + fn send_event(&self, ev: &Event) { + let bytes = to_line(ev).into_bytes(); + let mut clients = self.clients.lock().unwrap(); + clients.retain(|tx| tx.send(bytes.clone()).is_ok()); + } + + /// Broadcast the current state (with the live mic/auto flags folded in). + fn push_state(&self) { + let snap = { + let mut s = self.state.lock().unwrap(); + s.mic_recording = self.mic_on.load(Ordering::Relaxed); + s.auto_mode = self.auto_mode.load(Ordering::Relaxed); + s.dev_name = self.dev_name.lock().unwrap().clone(); + s.clone() + }; + self.send_event(&Event::State(snap)); + } + + /// Broadcast a notification for clients to render. + fn overlay(&self, body: &str) { + self.send_event(&Event::Overlay { + title: self.dev_name.lock().unwrap().clone(), + body: body.to_string(), + }); + } + + /// Forward one raw AAP packet (length-prefixed: u16 LE + bytes) to every + /// L2CAP-proxy client (the app). Never blocks — per-client writer threads. + fn forward_l2cap(&self, packet: &[u8]) { + let mut clients = self.l2cap_clients.lock().unwrap(); + if clients.is_empty() { + return; + } + let f = frame(packet); + clients.retain(|tx| tx.send(f.clone()).is_ok()); + } + + /// Remember a state packet (kind 0=battery, 1=ANC) to replay to new apps. + fn cache_replay(&self, kind: u8, packet: &[u8]) { + self.replay.lock().unwrap().insert(kind, packet.to_vec()); + } + + /// Read the live WASAPI volume/mute into the snapshot; push only on change so + /// we don't spam clients. The caller's thread must have COM initialized. + fn sync_volume(&self) { + let vol = volume::get().unwrap_or(0); + let muted = volume::is_muted(); + let changed = { + let mut s = self.state.lock().unwrap(); + let c = s.volume != vol || s.muted != muted; + s.volume = vol; + s.muted = muted; + c + }; + if changed { + self.push_state(); + } + } +} + +/// Write the whole buffer to a pipe handle. Returns false if the client is gone. +unsafe fn write_all(h: HANDLE, buf: &[u8]) -> bool { + let mut off = 0usize; + while off < buf.len() { + let mut written = 0u32; + let ok = WriteFile( + h, + buf[off..].as_ptr(), + (buf.len() - off) as u32, + &mut written, + ptr::null_mut(), + ); + if ok == 0 || written == 0 { + return false; + } + off += written as usize; + } + true +} + +/// Per-client reader: parse NDJSON commands and apply them until it disconnects. +fn client_reader(pipe: Arc, ctx: Ctx) { + volume::init(); // this thread applies StepVolume/ToggleMute (WASAPI needs COM) + let h = pipe.0; + let mut buf = [0u8; 4096]; + let mut acc = String::new(); + loop { + let mut read = 0u32; + let ok = + unsafe { ReadFile(h, buf.as_mut_ptr(), buf.len() as u32, &mut read, ptr::null_mut()) }; + if ok == 0 || read == 0 { + break; // disconnected + } + acc.push_str(&String::from_utf8_lossy(&buf[..read as usize])); + while let Some(nl) = acc.find('\n') { + let line: String = acc.drain(..=nl).collect(); + if let Some(cmd) = from_line::(&line) { + apply_command(&ctx, cmd); + } + } + } + // The pipe closes once this Arc and the writer thread's Arc both drop. +} + +/// Build a security descriptor that lets same-user clients connect (the default +/// null descriptor denies them). Leaks one small SD per server — negligible. +unsafe fn pipe_sa(psd: &mut PSECURITY_DESCRIPTOR) -> SECURITY_ATTRIBUTES { + let sddl = wide("D:(A;;GA;;;AU)(A;;GA;;;SY)"); + let ok = ConvertStringSecurityDescriptorToSecurityDescriptorW( + sddl.as_ptr(), + 1, // SDDL_REVISION_1 + psd, + ptr::null_mut(), + ); + SECURITY_ATTRIBUTES { + nLength: std::mem::size_of::() as u32, + lpSecurityDescriptor: if ok != 0 { *psd } else { ptr::null_mut() }, + bInheritHandle: 0, + } +} + +/// Create one pipe instance and block until a client connects; returns its handle. +unsafe fn accept(name: &[u16], sa: *const SECURITY_ATTRIBUTES) -> Option { + let h = CreateNamedPipeW( + name.as_ptr(), + PIPE_ACCESS_DUPLEX, + PIPE_TYPE_BYTE | PIPE_READMODE_BYTE | PIPE_WAIT, + PIPE_UNLIMITED_INSTANCES, + 4096, + 4096, + 0, + sa, + ); + if h == INVALID_HANDLE_VALUE { + thread::sleep(Duration::from_secs(1)); + return None; + } + // ERROR_PIPE_CONNECTED = the client beat us to it (still a success). + let ok = ConnectNamedPipe(h, ptr::null_mut()); + if ok == 0 && GetLastError() != ERROR_PIPE_CONNECTED { + CloseHandle(h); + return None; + } + Some(h) +} + +/// Events pipe: the daemon only WRITES here (one direction → no sync-handle +/// serialization). Each client gets a queue drained by its own writer thread. +unsafe fn events_server(ctx: Ctx) { + let name = wide(PIPE_EVENTS); + let mut psd: PSECURITY_DESCRIPTOR = ptr::null_mut(); + let sa = pipe_sa(&mut psd); + loop { + let h = match accept(&name, &sa) { + Some(h) => h, + None => continue, + }; + let pipe = Arc::new(Pipe(h)); + let (tx, rx) = std::sync::mpsc::channel::>(); + let n = { + let mut cl = ctx.clients.lock().unwrap(); + cl.push(tx); + cl.len() + }; + log(&format!("events: client connected ({n} total)")); + let p = pipe.clone(); + thread::spawn(move || { + for msg in rx { + if !unsafe { write_all(p.0, &msg) } { + break; + } + } + }); + ctx.push_state(); // greet the newcomer + } +} + +/// Commands pipe: the daemon only READS here (one direction). Commands are +/// global, so any client's commands just apply to the daemon. +unsafe fn cmds_server(ctx: Ctx) { + let name = wide(PIPE_CMDS); + let mut psd: PSECURITY_DESCRIPTOR = ptr::null_mut(); + let sa = pipe_sa(&mut psd); + loop { + let h = match accept(&name, &sa) { + Some(h) => h, + None => continue, + }; + log("cmds: client connected"); + let pipe = Arc::new(Pipe(h)); + let c = ctx.clone(); + thread::spawn(move || client_reader(pipe, c)); + } +} + +/// L2CAP-RX pipe: the daemon only WRITES forwarded AAP packets here (→ the app). +unsafe fn l2cap_rx_server(ctx: Ctx) { + let name = wide(PIPE_L2CAP_RX); + let mut psd: PSECURITY_DESCRIPTOR = ptr::null_mut(); + let sa = pipe_sa(&mut psd); + loop { + let h = match accept(&name, &sa) { + Some(h) => h, + None => continue, + }; + let pipe = Arc::new(Pipe(h)); + let (tx, rx) = std::sync::mpsc::channel::>(); + // Replay the cached battery/ANC packets so the app shows current state + // immediately (without us re-requesting, which would cut audio). + for pkt in ctx.replay.lock().unwrap().values() { + let _ = tx.send(frame(pkt)); + } + ctx.l2cap_clients.lock().unwrap().push(tx); + log("l2cap-rx: app attached"); + let p = pipe.clone(); + thread::spawn(move || { + for msg in rx { + if !unsafe { write_all(p.0, &msg) } { + break; + } + } + }); + } +} + +/// L2CAP-TX pipe: the daemon only READS the app's outgoing AAP packets and sends +/// them to the driver — dropping the setup packets it already sent itself. +unsafe fn l2cap_tx_server(ctx: Ctx) { + let name = wide(PIPE_L2CAP_TX); + let mut psd: PSECURITY_DESCRIPTOR = ptr::null_mut(); + let sa = pipe_sa(&mut psd); + loop { + let h = match accept(&name, &sa) { + Some(h) => h, + None => continue, + }; + log("l2cap-tx: app attached"); + let pipe = Arc::new(Pipe(h)); + let c = ctx.clone(); + thread::spawn(move || l2cap_reader(pipe, c)); + } +} + +/// A setup packet the daemon already sent — re-sending it re-negotiates the audio +/// profile and cuts sound, so we drop the app's copy. +fn is_setup(p: &[u8]) -> bool { + p == aap::HANDSHAKE.as_slice() + || p == aap::SET_FEATURES.as_slice() + || p == aap::REQUEST_NOTIFS.as_slice() +} + +/// Read length-prefixed ([u16 LE len][bytes]) AAP packets from the app → driver. +fn l2cap_reader(pipe: Arc, ctx: Ctx) { + let h = pipe.0; + let mut acc: Vec = Vec::new(); + let mut buf = [0u8; 4096]; + loop { + let mut read = 0u32; + let ok = + unsafe { ReadFile(h, buf.as_mut_ptr(), buf.len() as u32, &mut read, ptr::null_mut()) }; + if ok == 0 || read == 0 { + break; + } + acc.extend_from_slice(&buf[..read as usize]); + while acc.len() >= 2 { + let len = u16::from_le_bytes([acc[0], acc[1]]) as usize; + if acc.len() < 2 + len { + break; + } + let packet = acc[2..2 + len].to_vec(); + acc.drain(..2 + len); + if !is_setup(&packet) { + // The app renames over the proxy; remember the latest name so we + // can show a "Renamed to X" confirmation once typing settles. + if let Some(name) = aap::parse_rename(&packet) { + *ctx.pending_rename.lock().unwrap() = Some((name, Instant::now())); + } + if let Some(drv) = ctx.driver_cell.lock().unwrap().clone() { + let _ = drv.send(&packet); + } + } + } + } +} + +/// Enable/disable the hi-res mic stream (manual path), with the A2DP restore. +fn set_mic(ctx: &Ctx, on: bool) { + let was = ctx.mic_on.swap(on, Ordering::Relaxed); + if on && !was { + if let Some(drv) = ctx.driver_cell.lock().unwrap().clone() { + let _ = drv.send(&aap::START_AUDIO); + } + ctx.overlay("Hi-res microphone on"); + } else if !on && was { + if let Some(drv) = ctx.driver_cell.lock().unwrap().clone() { + let _ = drv.send(&aap::STOP_AUDIO); + } + ctx.overlay("Microphone released — restoring stereo…"); + a2dp::reset(ctx.mac); + ctx.overlay("Stereo restored"); + } + ctx.push_state(); +} + +// ---- HR retry constants (mirror the Android HeartRateMonitor companion) ---- +/// Wait this long for a decoded reading before re-enabling (Android's +/// FIRST_SAMPLE_TIMEOUT). +const HR_FIRST_SAMPLE_TIMEOUT_MS: u64 = 8_000; +/// Gap after HRM_STATE before the stream start (Android's START_COMMAND_DELAY). +const HR_START_COMMAND_DELAY_MS: u64 = 120; + +/// Sequence number for sensor stream control frames. iOS increments this per +/// frame; whether the AirPods validate it is untested, so we count up too. Kept +/// above 127 so it always encodes as the two-byte varint the captures show, and +/// masked to 14 bits so it never overflows that encoding. +static HR_SEQ: AtomicU16 = AtomicU16::new(0); + +fn next_hr_seq() -> u16 { + let n = HR_SEQ.fetch_add(1, Ordering::Relaxed); + 128 + (n % (16_384 - 128)) +} +/// Backoff between attempts, indexed by attempt number (last value repeats). +const HR_RETRY_BACKOFF_MS: [u64; 3] = [500, 1_000, 2_000]; +/// Consecutive enable attempts (each an ~8 s sample wait) with no reading before we +/// give up. We do NOT rebuild / reconnect the L2CAP channel to "try again": on this +/// firmware the buds only ever ACK service 19 and never stream, so reconnecting is +/// pointless churn (it just re-opens the audio link). The user toggles HR off/on to +/// retry. +const HR_MAX_ATTEMPTS: u32 = 4; + +/// How one retry campaign ended. +enum HrOutcome { + /// The stream came up — a real sample was decoded; run_receiver keeps decoding. + Live, + /// The user turned HR off mid-campaign (`hr_on` went false). + Stopped, + /// The transport was lost (no driver), or the enable retries were exhausted with + /// only ACKs — give up; re-arms on the next connect / HR re-toggle. + GiveUp, +} + +/// Enable/disable AirPods Pro 3 heart-rate monitoring. On → spawn a retry thread +/// that re-sends the RTBuddy AACP 1.3 enable sequence until the first sample +/// arrives (the stream almost never starts on the first attempt). Off → send the +/// STOP frame, clear the reading, and let the retry thread notice `hr_on` and +/// exit. The decoder itself is driven in `run_receiver` off `hr_on`. +fn set_heart_rate(ctx: &Ctx, on: bool) { + let was = ctx.hr_on.swap(on, Ordering::Relaxed); + let has_driver = ctx.driver_cell.lock().unwrap().is_some(); + log(&format!( + "HR: set on={on} was={was} driver={}", + if has_driver { "connected" } else { "NONE(no session yet)" } + )); + if on && !was { + spawn_hr_retry(ctx); + ctx.overlay("Heart rate monitoring on"); + } else if !on && was { + if let Some(drv) = ctx.driver_cell.lock().unwrap().clone() { + // Stop = interval 0 on both services (matching the start). + let s = next_hr_seq() as u8; + let _ = drv.send(&aap::hr_stream(s, aap::STREAM_HEART_RATE, 0)); + let _ = drv.send(&aap::hr_stream(s.wrapping_add(1), aap::STREAM_HEART_RATE_LEGACY, 0)); + } + ctx.state.lock().unwrap().heart_rate = None; + ctx.overlay("Heart rate monitoring off"); + // Any running retry thread observes hr_on=false on its next poll and exits. + } + ctx.push_state(); +} + +/// Start the HR retry campaign on its own thread — so the ~1 s of init sleeps and +/// the up-to-8 s sample waits never block the command reader or the recv loop. +/// Guarded by `hr_retrying` so two campaigns can't run over the one driver. +fn spawn_hr_retry(ctx: &Ctx) { + // One-thread guard: bail if a campaign is already live. + if ctx.hr_retrying.swap(true, Ordering::SeqCst) { + return; + } + let ctx = ctx.clone(); + thread::spawn(move || { + // Loop only to cover a rapid off→on that lost its spawn to the guard: a + // campaign that Stopped (user off) re-runs iff hr_on is true again. + loop { + match hr_retry_campaign(&ctx) { + HrOutcome::Live | HrOutcome::GiveUp => break, + HrOutcome::Stopped => { + if !ctx.hr_on.load(Ordering::Relaxed) { + break; + } + } + } + } + ctx.hr_retrying.store(false, Ordering::SeqCst); + }); +} + +/// Keep re-sending the enable sequence and waiting for a REAL heart-rate reading, +/// mirroring the Android HeartRateMonitor loop. One attempt = full enable + up to +/// FIRST_SAMPLE_TIMEOUT waiting for a *decoded reading*. Crucially, mere ACKs / a +/// live-but-empty stream do NOT end the campaign: the whole failure mode is that the +/// AirPods ACK service 19 yet never stream data. Plain re-enable retries repeat over +/// the SAME channel, up to HR_MAX_ATTEMPTS, then give up — we never rebuild/reconnect +/// the L2CAP channel, because the audio + mic links ride it and must never be +/// collapsed for a feature that (on this firmware) never yields data. Runs until a +/// reading lands, the user turns HR off, or the attempts are spent. +fn hr_retry_campaign(ctx: &Ctx) -> HrOutcome { + let mut attempt: u32 = 0; + while ctx.hr_on.load(Ordering::Relaxed) { + ctx.hr_got_sample.store(false, Ordering::Relaxed); + ctx.hr_stream_live.store(false, Ordering::Relaxed); + let drv = match ctx.driver_cell.lock().unwrap().clone() { + Some(d) => d, + None => { + log("HR: no driver — retry aborted (re-arms on reconnect)"); + return HrOutcome::GiveUp; + } + }; + // beforeFirstStart (Android): stop head tracking up front — it shares the + // sensor service — and settle 220 ms, BEFORE the session init, matching the + // working client's ordering exactly (the PR author confirmed his flow). + let _ = drv.send(&aap::sensor_stream(next_hr_seq(), aap::STREAM_HEAD_TRACKING, 0)); + thread::sleep(Duration::from_millis(220)); + // AACP 1.3 session init (connect0/caps0/connect4/caps4), re-sent every attempt + // so each retry re-establishes the session before the enable. + let init: [(&[u8], u64); 4] = [ + (&aap::HR_CONNECT_SERVICE_0, 180), + (&aap::HR_CAPABILITIES_SERVICE_0, 220), + (&aap::HR_CONNECT_SERVICE_4, 180), + (&aap::HR_CAPABILITIES_SERVICE_4, 220), + ]; + for (pkt, delay) in init { + if !ctx.hr_on.load(Ordering::Relaxed) { + return HrOutcome::Stopped; + } + let _ = drv.send(pkt); + thread::sleep(Duration::from_millis(delay)); + } + // Enable + start, faithfully reproducing the working Android sequence + // (upstream PR #702): switch on the PPG engine with HRM_STATE (control 0x30), + // then start the 1 Hz heart-rate stream (service 19). The earlier + // `request_all_descriptors` guess is dropped (Android doesn't send it and it + // never helped); the 0x10 DEVMOTION6 stream is unrelated to HR and dropped. + let _ = drv.send(&aap::HR_ENABLE); + thread::sleep(Duration::from_millis(HR_START_COMMAND_DELAY_MS)); + // The HR "set interval" service differs by firmware (8*: 19; 9*: 84) and + // thibaup saw it vary — so set the 1 Hz interval on BOTH 84 and 19; the buds + // ignore the wrong one, and the decoder catches the reports on 19/20/84. + let s = next_hr_seq() as u8; + let _ = drv.send(&aap::hr_stream(s, aap::STREAM_HEART_RATE, aap::PERIOD_HEART_RATE_US)); // 84 + thread::sleep(Duration::from_millis(120)); + let _ = drv.send(&aap::hr_stream( + s.wrapping_add(1), + aap::STREAM_HEART_RATE_LEGACY, + aap::PERIOD_HEART_RATE_US, + )); // 19 + + // Wait up to FIRST_SAMPLE_TIMEOUT for a REAL decoded reading. ACKs / a + // live-but-empty stream are ignored on purpose — they are exactly the state + // we are trying to get past. + let wait_until = Instant::now() + Duration::from_millis(HR_FIRST_SAMPLE_TIMEOUT_MS); + while Instant::now() < wait_until { + if !ctx.hr_on.load(Ordering::Relaxed) { + return HrOutcome::Stopped; + } + if ctx.hr_got_sample.load(Ordering::Relaxed) { + log("HR: stream live (reading decoded) — retries done"); + return HrOutcome::Live; + } + thread::sleep(Duration::from_millis(200)); + } + + attempt += 1; + let streaming = ctx.hr_stream_live.load(Ordering::Relaxed); + log(&format!( + "HR retry: attempt={attempt} — no reading in 8s (stream_frames={streaming})" + )); + // No reading after this attempt. Do NOT rebuild / reconnect the L2CAP channel: + // on this firmware the buds only ever ACK service 19 and never stream, so + // reconnecting to "try again" is pointless churn (it just re-opens the audio + // link). Give up after HR_MAX_ATTEMPTS; the user toggles HR off/on to retry. + if attempt >= HR_MAX_ATTEMPTS { + log("HR: no reading after the enable retries (ACKs only) — giving up; \ + toggle HR off/on to retry"); + ctx.overlay("Heart rate unavailable"); + return HrOutcome::GiveUp; + } + let backoff = HR_RETRY_BACKOFF_MS[(attempt as usize - 1).min(HR_RETRY_BACKOFF_MS.len() - 1)]; + let nap_until = Instant::now() + Duration::from_millis(backoff); + while Instant::now() < nap_until && ctx.hr_on.load(Ordering::Relaxed) { + thread::sleep(Duration::from_millis(100)); + } + } + HrOutcome::Stopped +} + +fn apply_command(ctx: &Ctx, cmd: Command) { + log(&format!("cmd received: {cmd:?}")); + match cmd { + Command::Hello { .. } | Command::GetState => ctx.push_state(), + Command::SetAnc { mode } => { + if (1..=4).contains(&mode) { + // Remember the target so run_receiver can ignore transitional + // echoes (the buds briefly report Off when switching quickly), and + // reflect the click immediately so the UI feels instant. + *ctx.anc_cmd.lock().unwrap() = Some((mode, Instant::now())); + ctx.state.lock().unwrap().anc = mode; + ctx.push_state(); + if let Some(drv) = ctx.driver_cell.lock().unwrap().clone() { + let _ = drv.send(&aap::anc_command(mode)); + } + } + } + Command::SetMicMode { auto, manual } => { + ctx.auto_mode.store(auto, Ordering::Relaxed); + if !auto { + set_mic(ctx, manual); + } else { + ctx.push_state(); + } + } + Command::SetFeature { feature, on } => { + if let Some(drv) = ctx.driver_cell.lock().unwrap().clone() { + let _ = drv.send(&aap::feature_command(feature, on)); + } + // Turning CA off mid-duck: no end event will arrive, so restore the + // pre-duck volume now instead of leaving it stuck low. + if feature == ipc::feature::CONVERSATIONAL_AWARENESS && !on { + ctx.conv_duck.lock().unwrap().restore(); + } + // Optimistic: reflect the toggle immediately; the AirPods echo a + // status which run_receiver uses to correct it if it differs. + { + let mut s = ctx.state.lock().unwrap(); + match feature { + ipc::feature::CONVERSATIONAL_AWARENESS => s.conversational_awareness = on, + ipc::feature::ADAPTIVE_VOLUME => s.adaptive_volume = on, + ipc::feature::ALLOW_OFF => s.allow_off = on, + _ => {} + } + } + ctx.push_state(); + } + Command::SetControl { id, value } => { + if let Some(drv) = ctx.driver_cell.lock().unwrap().clone() { + let _ = drv.send(&aap::control_command(id, value)); + } + } + Command::StepVolume { delta } => { + volume::step(delta); + ctx.sync_volume(); + } + Command::SetVolume { percent } => { + volume::set(percent.min(100)); + ctx.sync_volume(); + } + Command::ToggleMute => { + volume::toggle_mute(); + ctx.sync_volume(); + } + Command::SetHeartRate { on } => set_heart_rate(ctx, on), + Command::SetHearingAid { + on, + left_eq, + right_eq, + amplification, + balance, + tone, + conversation_boost, + ambient_noise_reduction, + own_voice, + } => { + // Runs on its own thread — hearing::apply has ~1.3 s of enable settle + // sleeps + ATT round-trips and must not block the command pump. Stamp a + // generation first: a burst of commands (one per audiogram box) collapses + // to just the latest, so the rest bail before hammering the driver. + let gen = hearing::next_gen(); + if let Some(drv) = ctx.driver_cell.lock().unwrap().clone() { + let ctx2 = ctx.clone(); + thread::spawn(move || { + match hearing::apply( + &drv, gen, on, &left_eq, &right_eq, amplification, balance, tone, + conversation_boost, ambient_noise_reduction, own_voice, + ) { + Ok(s) => { + log(&s); + ctx2.overlay(if on { "Hearing aid on" } else { "Hearing aid off" }); + } + Err(e) => log(&format!("hearing aid FAILED: {e}")), + } + }); + } + } + Command::Connect => { + // The user accepted the prompt — let the session start, and ask the OS + // to (re)connect the audio in case the device was BT-disconnected. + ctx.connect_requested.store(true, Ordering::Relaxed); + let mac = ctx.mac; + thread::spawn(move || { + let ok = bt::set_audio_connected(mac, true); + log(&format!("bt: connect audio = {ok}")); + }); + } + Command::RepairConnection => { + // Force a clean reconnect: make sure the session is requested (in case we + // were waiting on a prompt) and ask run_receiver to drop + reopen the + // driver so a wedged / desynced link gets a fresh AAP session + ATT + // channel. Also nudge the OS to (re)connect audio. + log("cmd: repair connection — forcing a clean reconnect"); + ctx.connect_requested.store(true, Ordering::Relaxed); + ctx.wants_reconnect.store(true, Ordering::Relaxed); + let mac = ctx.mac; + thread::spawn(move || { + let ok = bt::set_audio_connected(mac, true); + log(&format!("bt: repair connect audio = {ok}")); + }); + } + Command::Disconnect => { + // Real disconnect: drop the control session AND ask the OS to + // disconnect the audio (toggle the A2DP/HFP services off). Never + // auto-reconnect on our own (a prompt is required). + ctx.connect_requested.store(false, Ordering::Relaxed); + ctx.state.lock().unwrap().connected = false; + *ctx.driver_cell.lock().unwrap() = None; + ctx.push_state(); + ctx.overlay("Disconnected"); + let mac = ctx.mac; + thread::spawn(move || { + let ok = bt::set_audio_connected(mac, false); + log(&format!("bt: disconnect audio = {ok}")); + }); + } + Command::SetName { name } => { + if !name.is_empty() && name.len() <= 64 { + if let Some(drv) = ctx.driver_cell.lock().unwrap().clone() { + let _ = drv.send(&aap::build_rename(&name)); + } + // Update our name optimistically so the UI keeps the new name + // (the AirPods apply it; the OS's cached BT name may need re-pair). + *ctx.dev_name.lock().unwrap() = name.clone(); + ctx.push_state(); + ctx.overlay(&format!("Renamed to “{name}”")); + } + } + Command::Shutdown => { + // Release the exclusive AAP driver handle BEFORE exiting so the kernel + // devnode doesn't stick in Code 38 (CM_PROB_DRIVER_FAILED_PRIOR_UNLOAD) + // for the next daemon. `exit(0)` skips destructors, so close it + // explicitly here — this runs the driver's channel teardown, which the + // OS-on-exit handle close does not do reliably. + if let Some(drv) = ctx.driver_cell.lock().unwrap().clone() { + log("shutdown: releasing AAP driver handle"); + drv.close_now(); + } + std::process::exit(0); + } + } +} + +/// The AAP session: keep the link up, decode the mic, track battery/ANC/ear +/// detection, and broadcast state + overlay events. (Ported from the tray.) +fn run_receiver(ctx: Ctx) { + let mac = ctx.mac; + log("run_receiver: entered"); + let mut buf = [0u8; 8192]; + let mut decoder: Option = None; + // Whether we've announced "mic fully operational" for the current capture session + // (fires when the buds actually start streaming audio, not just when it's requested). + let mut mic_announced = false; + // RTBuddy heart-rate decoder (inert unless `hr_on`). Carry is reset per + // connection so a partial frame never straddles a reconnect. + let mut hr_decoder = hr::RtBuddyHeartRateDecoder::new(); + media::init(); // COM (MTA) for the SMTC ear-detection auto-pause + volume::init(); // COM for the CA volume duck (same MTA) + log("run_receiver: media init done"); + let mut last_anc = 0u8; + let mut last_case_present: Option = None; + let mut pending_card = false; + // Consecutive failures to reach the AirPods. After a few (they're on the + // iPhone / gone), we give up so we DON'T keep stealing them back — reset the + // gate and wait for a fresh prompt/Connect. + let mut reach_fails = 0u32; + // The user asked to connect — keep trying for a generous window (the AirPods + // may take a few seconds to become reachable) before giving up. We never + // *steal* here: a failed connect() doesn't pull them, and once connected a + // drop releases the gate (below). + let give_up = |ctx: &Ctx, fails: &mut u32| { + *fails += 1; + if *fails >= 12 { + *fails = 0; + ctx.connect_requested.store(false, Ordering::Relaxed); + log("run_receiver: gave up reaching AirPods — releasing"); + } + }; + loop { + // Gate: stay idle until the user accepts the "connect?" prompt. + if !ctx.connect_requested.load(Ordering::Relaxed) { + thread::sleep(Duration::from_millis(500)); + continue; + } + let driver = match driver::Driver::open() { + Ok(d) => { + log("run_receiver: driver opened"); + d + } + Err(_) => { + log("run_receiver: driver open FAILED"); + ctx.state.lock().unwrap().connected = false; + *ctx.driver_cell.lock().unwrap() = None; + ctx.push_state(); + give_up(&ctx, &mut reach_fails); + thread::sleep(Duration::from_millis(1500)); + continue; + } + }; + *ctx.driver_cell.lock().unwrap() = Some(driver.clone()); + let connected = driver.connect(mac, aap::PSM_AACP).unwrap_or(false); + log(&format!("run_receiver: connect({mac:#x}) = {connected}")); + if !connected { + ctx.state.lock().unwrap().connected = false; + ctx.push_state(); + give_up(&ctx, &mut reach_fails); + thread::sleep(Duration::from_millis(1500)); + continue; + } + reach_fails = 0; // reached them — reset the give-up counter + let _ = driver.send(&aap::HANDSHAKE); + thread::sleep(Duration::from_millis(300)); + let _ = driver.send(&aap::SET_FEATURES); + thread::sleep(Duration::from_millis(300)); + let _ = driver.send(&aap::REQUEST_NOTIFS); + ctx.state.lock().unwrap().connected = true; + pending_card = true; + ctx.push_state(); + log("run_receiver: handshake done, connected=true"); + // EXPERIMENT (opt-in): one-shot GATT discovery — walk the buds' GATT server as + // a CLIENT to find any heart-rate characteristic we never enumerated. It wakes + // hearing-assist and blocks the AAP loop while it listens, so it must NOT run in + // normal use — it's gated behind the LIBREPODS_GATT_PROBE env flag (start the + // daemon with that var set to enable it). Runs once per session. + if std::env::var_os("LIBREPODS_GATT_PROBE").is_some() { + static GATT_PROBED: AtomicBool = AtomicBool::new(false); + if !GATT_PROBED.swap(true, Ordering::Relaxed) { + for line in gatt::probe(&driver) { + log(&line); + } + } + } + hr_decoder.reset(); // fresh connection — drop any stale HR carry + // Re-arm the HR stream if the user had it on before the (re)connect — + // through the same retry path (the stream rarely starts first try). + if ctx.hr_on.load(Ordering::Relaxed) { + spawn_hr_retry(&ctx); + } + // Re-arm the hi-res mic uplink if it was engaged before the drop. The AirPods + // forget their stream state on a BT disconnect, so without re-sending + // START_AUDIO they never resume pushing 0x58 uplink packets — the device + // looks connected but the virtual mic stays silent (and the app, seeing no + // input, churns the audio device connecting/disconnecting). Drop the decoder + // so the first packet after the resume lays a fresh silence cushion. + decoder = None; + mic_announced = false; // re-announce "operational" once audio resumes + if ctx.mic_on.load(Ordering::Relaxed) { + let _ = driver.send(&aap::START_AUDIO); + log("run_receiver: re-armed hi-res mic uplink (mic was on before reconnect)"); + } + + let mut we_paused = false; + let mut prev_ear = [false; 2]; // last [primary, secondary] in-ear state + let mut last_status = Instant::now(); + let mut last_audio = Instant::now(); // last time hi-res mic SDUs arrived + let mut status_fails = 0u32; + let mut low_warned = false; // low-battery overlay fired (hysteresis) + let mut case_low_warned = false; // case low-battery overlay fired + // Per-bud ear status, to notify on the transition into the case. + let mut prev_status = [aap::EarStatus::Disconnected; 2]; + // HR diagnostics (logged every ~3s while monitoring). + let mut hr_last_log = Instant::now(); + let (mut hr_bytes, mut hr_frames, mut hr_samples) = (0usize, 0u32, 0u32); + // Frames carrying the type-19 heart-rate signature `08 13 1a 12` (vs the + // 50 Hz type-16 raw-PPG flood, which shares the RTBuddy prefix). + let mut hr_type19 = 0u32; + let mut hr_type14 = 0u32; // head-tracking frames (sensor-service contention) + // Diagnose stale-"connected": throttled log of the raw driver status when + // it isn't a clean 2, so we can see what "cased" vs "both-out-resting" + // actually report (the teardown decision hinges on them differing). + let mut status_diag = Instant::now(); + // Last time an AAP packet actually arrived. The driver State drops to 0 + // both on a transient channel re-negotiation (e.g. both buds just left the + // ears) and on a real disconnect (cased / on the phone) — status alone + // can't tell them apart, so data flow is the tie-breaker. + let mut last_data = Instant::now(); + // Rate-limit the in-place mic-uplink re-arm (see the stall handler below). + let mut last_mic_rearm = Instant::now(); + // ATT (PSM 0x001F) hearing-aid server diagnostics, polled from the driver + // and logged on change (DebugView never showed the driver's KdPrint). + let mut att_poll = Instant::now(); + let mut last_att: (i32, u32, u32, i32, u32) = (0, 0, 0, 0, 0); + loop { + if att_poll.elapsed() >= Duration::from_millis(1500) { + att_poll = Instant::now(); + if let Ok(d) = driver.att_diag() { + if d != last_att { + let was_open = last_att.4; + last_att = d; + let _ = was_open; + log(&format!( + "ATT: register=0x{:08X} registered={} indications={} accept=0x{:08X} channel_open={}", + d.0 as u32, d.1, d.2, d.3 as u32, d.4 + )); + } + } + } + // The user pressed Disconnect (connect_requested cleared) — release. + if !ctx.connect_requested.load(Ordering::Relaxed) { + log("run_receiver: disconnect requested — releasing"); + *ctx.driver_cell.lock().unwrap() = None; + break; + } + // The user pressed "Repair connection": drop + reopen the driver for a + // fresh AAP session + ATT channel. connect_requested stays set, so the + // outer loop reconnects instead of releasing. + if ctx.wants_reconnect.swap(false, Ordering::Relaxed) { + log("run_receiver: repair requested — reopening AACP channel"); + *ctx.driver_cell.lock().unwrap() = None; + break; + } + let mut got_data = false; + if let Ok(n) = driver.recv(2000, &mut buf) { + if n > 0 { + got_data = true; + last_data = Instant::now(); + let data = &buf[..n]; + // Forward the raw packet to the full app (if attached) so it + // runs its own AAP session over us. + ctx.forward_l2cap(data); + // Heart rate (opt-in): feed each chunk into the RTBuddy + // decoder; publish the latest validated BPM. Inert when off. + if ctx.hr_on.load(Ordering::Relaxed) { + hr_bytes += data.len(); + if hr::contains_frame_prefix(data) { + hr_frames += 1; + ctx.hr_stream_live.store(true, Ordering::Relaxed); + // Count head-tracking (type 14: `08 0e 1a`) frames too, + // to see whether it's still streaming and stealing the + // sensor service from the computed heart rate. + if data.windows(3).any(|w| w == [0x08, 0x0e, 0x1a]) { + hr_type14 += 1; + } + // Only the 1 Hz heart-rate stream carries `08 13 1a 12` + // (type 19, 18-byte payload). Dump those; the 50 Hz + // type-16 raw-PPG frames share the prefix and would + // drown the log, so we only count them (hr_frames). + if data.windows(4).any(|w| w == [0x08, 0x13, 0x1a, 0x12]) { + hr_type19 += 1; + let dump: String = data + .iter() + .take(48) + .map(|b| format!("{b:02x}")) + .collect::>() + .join(" "); + log(&format!("HR type-19 ({} bytes): {dump}", data.len())); + } + } + let samples = hr_decoder.feed(data); + hr_samples += samples.len() as u32; + if !samples.is_empty() { + // Rendezvous with the retry thread: the stream is live. + ctx.hr_got_sample.store(true, Ordering::Relaxed); + } + if let Some(bpm) = samples.into_iter().last() { + let changed = { + let mut s = ctx.state.lock().unwrap(); + let c = s.heart_rate != Some(bpm); + s.heart_rate = Some(bpm); + c + }; + if changed { + ctx.push_state(); + } + } + if hr_last_log.elapsed() >= Duration::from_secs(3) { + log(&format!( + "HR diag: bytes={hr_bytes} prefix={hr_frames} type14={hr_type14} type19={hr_type19} bpm_samples={hr_samples}" + )); + hr_last_log = Instant::now(); + hr_bytes = 0; + hr_frames = 0; + hr_type19 = 0; + hr_type14 = 0; + hr_samples = 0; + } + } else if ctx.state.lock().unwrap().heart_rate.take().is_some() { + ctx.push_state(); + } + // Hi-res mic: decode the 0x58 uplink AUs → feed the virtual mic. + if ctx.mic_on.load(Ordering::Relaxed) { + if aap::is_audio_packet(data) { + last_audio = Instant::now(); // watchdog: stream alive + if decoder.is_none() { + decoder = eld::Decoder::new(); + ctx.pipe.write(&[0i16; 3840]); // ~80 ms cushion + } + if let Some(dec) = decoder.as_mut() { + let mut out: Vec = Vec::new(); + aap::for_each_au(data, |au| out.extend_from_slice(dec.decode(au))); + if !out.is_empty() { + ctx.pipe.write(&out); + // First real PCM reached the virtual mic — the hi-res + // uplink is fully operational end-to-end. Announce once. + if !mic_announced { + mic_announced = true; + ctx.overlay("Microphone ready — hi-res active"); + } + } + } + } + } else if decoder.is_some() { + decoder = None; + mic_announced = false; // mic released — next session re-announces + } + if let Some(b) = aap::parse_battery(data) { + ctx.cache_replay(0, data); // replay to a newly-attached app + let (batt_text, present) = { + let mut s = ctx.state.lock().unwrap(); + if b.left.is_some() { + s.battery.left = b.left; + s.battery.left_charging = b.left_charging; + } + if b.right.is_some() { + s.battery.right = b.right; + s.battery.right_charging = b.right_charging; + } + if b.case.is_some() { + s.battery.case = b.case; + s.battery.case_charging = b.case_charging; + } + if b.headphone.is_some() { + s.battery.headphone = b.headphone; + s.battery.headphone_charging = b.headphone_charging; + } + let present = s.battery.case.is_some(); + (battery_text(&s.battery, s.connected), present) + }; + ctx.push_state(); + if pending_card { + ctx.overlay(&format!("Connected · {batt_text}")); + pending_card = false; + } else if last_case_present.is_some_and(|prev| prev != present) { + let ev = if present { "Case opened" } else { "Case closed" }; + ctx.overlay(&format!("{ev} · {batt_text}")); + } + last_case_present = Some(present); + // Low-battery notification: warn once when either bud + // falls to <=20%, re-arm only after it recovers above 25% + // (hysteresis so it doesn't spam around the threshold). + let low = { + let s = ctx.state.lock().unwrap(); + [s.battery.left, s.battery.right].into_iter().flatten().min() + }; + if let Some(min) = low { + if min <= 20 && !low_warned { + ctx.overlay(&format!("Battery low — {min}%")); + low_warned = true; + } else if min > 25 { + low_warned = false; + } + } + // Case low battery (separate, quieter threshold). + if let Some(cl) = { ctx.state.lock().unwrap().battery.case } { + if cl <= 15 && !case_low_warned { + ctx.overlay(&format!("Case battery low — {cl}%")); + case_low_warned = true; + } else if cl > 20 { + case_low_warned = false; + } + } + } + if let Some(m) = aap::parse_anc_mode(data) { + ctx.cache_replay(1, data); // replay to a newly-attached app + // Ignore transitional echoes that don't match a recent user + // command (the buds briefly report Off when switching modes + // quickly). Accept once it matches, or when nothing is + // pending / the window passed (an external change). + let accept = match *ctx.anc_cmd.lock().unwrap() { + Some((target, t)) if t.elapsed() < Duration::from_millis(1500) => { + m == target + } + _ => true, + }; + if accept { + ctx.state.lock().unwrap().anc = m; + ctx.push_state(); + if last_anc != 0 && m != last_anc { + ctx.overlay(aap::anc_name(m)); + } + last_anc = m; + } + } + // Sync the feature toggles from the AirPods' own status echoes + // (0x01 = on, 0x02 = off), so the tray checkmarks reflect the + // real device state — including whatever the iPhone last set. + { + let mut changed = false; + let mut s = ctx.state.lock().unwrap(); + for (id, field) in [ + (ipc::feature::CONVERSATIONAL_AWARENESS, 0), + (ipc::feature::ADAPTIVE_VOLUME, 1), + (ipc::feature::ALLOW_OFF, 2), + ] { + if let Some(v) = aap::parse_control_value(data, id) { + let on = v == 0x01; + let slot = match field { + 0 => &mut s.conversational_awareness, + 1 => &mut s.adaptive_volume, + _ => &mut s.allow_off, + }; + if *slot != on { + *slot = on; + changed = true; + } + } + } + drop(s); + if changed { + ctx.push_state(); + } + } + // Conversational Awareness: the AirPods signal speech start/stop; + // we (the host, single volume owner) duck/restore the volume. + if let Some(status) = aap::parse_conversational_awareness(data) { + // Don't duck while the hi-res mic is in use (you're on a + // call — you ARE talking, but the call audio shouldn't + // drop). Restore if a duck was already in progress. + if ctx.mic_on.load(Ordering::Relaxed) { + ctx.conv_duck.lock().unwrap().restore(); + } else { + ctx.conv_duck.lock().unwrap().on_status(status); + } + } + if let Some((model, firmware, serial)) = aap::parse_metadata(data) { + // Device identity (0x1D): store model/firmware/serial once. + let changed = { + let mut s = ctx.state.lock().unwrap(); + let c = s.model != model; + if c { + s.model = model.clone(); + s.firmware = firmware; + s.serial = serial; + } + c + }; + if changed { + log(&format!("device metadata: model={model}")); + ctx.push_state(); + } + } + if let Some((primary, secondary)) = aap::parse_ear_detection(data) { + // "In case" notification on the transition into the case. + // Ear-detection reports both buds together (never partial, + // unlike the battery packet) and comes over AAP — so this + // is reliable with no BLE, hence no audio static. + let now = [primary, secondary]; + for i in 0..2 { + if now[i] == aap::EarStatus::InCase + && prev_status[i] != aap::EarStatus::InCase + { + ctx.overlay("AirPod in case"); + break; // one overlay even if both go in at once + } + } + prev_status = now; + // 0x04 is a transitional (in-motion) value — hold the prior + // in-ear state for that bud instead of reading it as "out", + // so a bud being handled doesn't trigger a false auto-pause. + let new_ear = [ + if primary.is_transitional() { prev_ear[0] } else { primary.in_ear() }, + if secondary.is_transitional() { prev_ear[1] } else { secondary.in_ear() }, + ]; + if new_ear != prev_ear { + let all_in = new_ear[0] && new_ear[1]; + let was_wearing = prev_ear[0] || prev_ear[1]; + if all_in { + // both back in the ears → resume what we paused + if we_paused { + media::play(); + we_paused = false; + } + } else if was_wearing && media::is_playing() { + // a bud was just removed (Apple-style: pause on a + // single removal, not only when both are out) + media::pause(); + we_paused = true; + } + prev_ear = new_ear; + } + } + } + } + if !got_data { + // Tight while streaming (no ring underrun), throttle hard on idle. + let nap = if ctx.mic_on.load(Ordering::Relaxed) { 4 } else { 150 }; + thread::sleep(Duration::from_millis(nap)); + } + // Hi-res mic stall handling. Linux PR #655 re-armed the uplink (STOP→START) + // on a stall, but on a weak RF link that churn tips the L2CAP link into a + // full reconnect and drops the call — so we never touch the uplink here. + // Crucially, while the mic is IN USE we leave the stream completely alone: + // a silence is just you not speaking (muted / listening), not a fault, and + // dropping the decoder mid-call would glitch the next words. We only reset + // the clock and drop the decoder when the mic is NOT in use, so the next + // capture session starts from a clean silence cushion. + if ctx.mic_on.load(Ordering::Relaxed) { + // in use — do nothing + } else { + last_audio = Instant::now(); + if decoder.is_some() { + decoder = None; + mic_announced = false; + } + } + if last_status.elapsed() >= Duration::from_secs(1) { + last_status = Instant::now(); + let st = driver.status(); + if !matches!(st, Ok(2)) && status_diag.elapsed() >= Duration::from_secs(2) { + let both_out = !prev_ear[0] && !prev_ear[1]; + log(&format!( + "status diag: st={st:?} both_out={both_out} fails={status_fails} data_age={}ms", + last_data.elapsed().as_millis() + )); + status_diag = Instant::now(); + } + if matches!(st, Ok(2)) { + status_fails = 0; + } else { + // The driver State reads not-connected far more often than a real + // loss: whenever the buds play audio, the A2DP stream contends for the + // radio and the AAP channel goes quiet, and an idle channel reads the + // same. Tearing down on it churns a reconnect that toggles the OS + // audio link and kicks calls / playback. So don't trust the driver + // State — trust Windows' own BT status: hold the session as long as + // the OS still sees the AirPods connected, and give up only once it + // has lost them for a few seconds (really cased / handed to the phone). + let release = if bt::find_airpods().is_some() { + status_fails = 0; // still connected to Windows — keep the session + // Connected, but the AAP channel may have stalled (not just idle). + // If the hi-res mic is engaged and the channel has been silent for + // a while, the mic uplink has died — rebuild the channel IN PLACE + // (drop + reopen, which re-arms START_AUDIO on the handshake) to + // recover it, WITHOUT toggling the OS audio, so the mic comes back + // and the call isn't kicked. connect_requested stays set → the + // outer loop reopens the L2CAP channel, no set_audio_connected. + // Mic engaged but the AAP channel has gone silent — the uplink + // may have stalled. NEVER drop the channel here: the user is on a + // call, so a reconnect kicks it, and repeated drop+reopen churn + // bricks the driver into Code 38 (a self-feeding "driver open + // FAILED" loop). Instead re-arm the uplink IN PLACE by re-sending + // START_AUDIO on the still-open channel, at most once every few + // seconds. If it was just a silence (you not speaking), this is a + // harmless no-op; if the stream really stopped, it nudges the 0x58 + // uplink back WITHOUT touching the L2CAP link. + if ctx.mic_on.load(Ordering::Relaxed) + && last_data.elapsed() >= Duration::from_secs(8) + && last_mic_rearm.elapsed() >= Duration::from_secs(5) + { + log("run_receiver: mic uplink silent — re-arming START_AUDIO in place"); + let _ = driver.send(&aap::START_AUDIO); + last_mic_rearm = Instant::now(); + } + false + } else { + status_fails += 1; + status_fails >= 3 + }; + if release { + log("run_receiver: AirPods no longer connected (OS) — releasing"); + ctx.overlay("Disconnected"); + { + let mut s = ctx.state.lock().unwrap(); + s.connected = false; + s.heart_rate = None; // stale once the link is gone + } + *ctx.driver_cell.lock().unwrap() = None; + hr_decoder.reset(); // drop HR carry across the reconnect + last_anc = 0; + last_case_present = None; + // Released: never reconnect on our own (that would steal them + // back from the iPhone) — wait for a prompt. + ctx.connect_requested.store(false, Ordering::Relaxed); + ctx.push_state(); + break; + } + } + } + } + thread::sleep(Duration::from_secs(2)); + } +} + +/// Auto-activate: enable the hi-res stream when an app records from the virtual +/// mic, disable it (debounced) when it stops, and restore A2DP stereo. +fn poll_mic(ctx: Ctx) { + const MIC_IDLE_STOP_POLLS: u32 = 20; // 20 × 500 ms = 10 s (bridges VAD/probe gaps) + let mut prev = ctx.pipe.status(); + let mut idle = 0u32; + let mut on = false; + loop { + thread::sleep(Duration::from_millis(500)); + // Self-healing: `status()` reopens the pipe if it wasn't ready at boot or the + // handle broke, so mic auto-detection never dies silently. + let cur = ctx.pipe.status(); + let capturing = cur != prev; + prev = cur; + if !ctx.auto_mode.load(Ordering::Relaxed) { + on = ctx.mic_on.load(Ordering::Relaxed); + continue; + } + if capturing { + idle = 0; + if !on { + on = true; + ctx.mic_on.store(true, Ordering::Relaxed); + if let Some(drv) = ctx.driver_cell.lock().unwrap().clone() { + let _ = drv.send(&aap::START_AUDIO); + } + ctx.overlay("Microphone in use — hi-res on"); + ctx.push_state(); + } + } else { + idle += 1; + if on && idle >= MIC_IDLE_STOP_POLLS { + on = false; + ctx.mic_on.store(false, Ordering::Relaxed); + if let Some(drv) = ctx.driver_cell.lock().unwrap().clone() { + let _ = drv.send(&aap::STOP_AUDIO); + } + ctx.overlay("Microphone released — restoring stereo…"); + a2dp::reset(ctx.mac); + ctx.overlay("Stereo restored"); + ctx.push_state(); + } + } + } +} + +fn main() { + // Single instance: never run two daemons over the one exclusive driver. + unsafe { + let name = wide("Local\\LibrePodsDaemonSingleton"); + let _ = CreateMutexW(ptr::null(), 0, name.as_ptr()); + if GetLastError() == ERROR_ALREADY_EXISTS { + return; + } + } + + log("=== librepodsd start ==="); + let (mac, dev_name) = match bt::find_airpods() { + Some((m, n)) => (m, n), + None => (0, "AirPods".to_string()), + }; + log(&format!("find_airpods: mac={mac:#x} name='{dev_name}'")); + + let pipe = Arc::new(micpipe::MicPipeCell::new()); + log(&format!("mic pipe opened: {}", pipe.is_open())); + let ctx = Ctx { + state: Arc::new(Mutex::new(Snapshot { + dev_name: dev_name.clone(), + auto_mode: true, + ..Default::default() + })), + clients: Arc::new(Mutex::new(Vec::new())), + l2cap_clients: Arc::new(Mutex::new(Vec::new())), + replay: Arc::new(Mutex::new(std::collections::HashMap::new())), + driver_cell: Arc::new(Mutex::new(None)), + mic_on: Arc::new(AtomicBool::new(false)), + auto_mode: Arc::new(AtomicBool::new(true)), + hr_on: Arc::new(AtomicBool::new(false)), + hr_got_sample: Arc::new(AtomicBool::new(false)), + hr_stream_live: Arc::new(AtomicBool::new(false)), + hr_retrying: Arc::new(AtomicBool::new(false)), + connect_requested: Arc::new(AtomicBool::new(false)), + wants_reconnect: Arc::new(AtomicBool::new(false)), + pipe, + conv_duck: Arc::new(Mutex::new(volume::ConvDuck::default())), + pending_rename: Arc::new(Mutex::new(None)), + anc_cmd: Arc::new(Mutex::new(None)), + dev_name: Arc::new(Mutex::new(dev_name.clone())), + mac, + }; + + // Name the virtual mic after the connected device (elevated task, no UAC). + if mac != 0 { + rename::apply(&dev_name); + } + + // IPC: two one-directional pipe servers (events out, commands in). + { + let c = ctx.clone(); + thread::spawn(move || unsafe { events_server(c) }); + } + { + let c = ctx.clone(); + thread::spawn(move || unsafe { cmds_server(c) }); + } + // Raw-L2CAP proxy for the full app (Phase 3): RX (packets → app) + TX (app → driver). + { + let c = ctx.clone(); + thread::spawn(move || unsafe { l2cap_rx_server(c) }); + } + { + let c = ctx.clone(); + thread::spawn(move || unsafe { l2cap_tx_server(c) }); + } + + // BLE proximity: prompt "connect?" ONCE when the AirPods appear. The watcher + // fires on every advertisement (several/sec), so a plain time debounce still + // re-asks forever while they sit nearby — spammy, and it ignores a dismissed + // prompt. Instead edge-trigger: fire once, then stay quiet until the buds have + // been ABSENT (no advertisement for a while — cased or out of range) and return, + // which is the natural "ask again on the next case-open" behaviour. + if mac != 0 { + let c = ctx.clone(); + let c_scan = ctx.clone(); + // (prompted_this_visit, last_advertisement_seen) + let prox: Arc> = Arc::new(Mutex::new((false, Instant::now()))); + thread::spawn(move || { + le::watch_nearby( + move || { + let now = Instant::now(); + let mut p = prox.lock().unwrap(); + // A >45 s gap since the last advertisement means they went away; + // re-arm so their next appearance prompts once more. + if now.duration_since(p.1) > Duration::from_secs(45) { + p.0 = false; + } + p.1 = now; + if p.0 + || c.state.lock().unwrap().connected + || c.connect_requested.load(Ordering::Relaxed) + { + return; + } + p.0 = true; // acted on this visit — don't re-fire until they leave + drop(p); + // Auto-connect: open the AAP session automatically when the + // AirPods appear, instead of prompting. run_receiver picks up + // connect_requested; also nudge the OS audio up in case the + // classic link is down. + c.connect_requested.store(true, Ordering::Relaxed); + let mac = c.mac; + thread::spawn(move || { + let ok = bt::set_audio_connected(mac, true); + log(&format!("bt: auto-connect audio = {ok}")); + }); + log("ble: AirPods nearby → auto-connecting"); + }, + // Only scan while idle (disconnected) — no BLE radio during audio. + move || !c_scan.state.lock().unwrap().connected, + ); + }); + } + + // AAP session + auto-activate poll (only if we have a paired device). + if mac != 0 { + { + let c = ctx.clone(); + thread::spawn(move || run_receiver(c)); + } + { + let c = ctx.clone(); + thread::spawn(move || poll_mic(c)); + } + } + // Volume poller: keep the Snapshot's volume/mute fresh (the daemon owns + // volume) so the tray renders it — runs regardless of a paired device. + { + let c = ctx.clone(); + thread::spawn(move || { + volume::init(); + loop { + c.sync_volume(); + // Flush a settled rename into a confirmation overlay. + let ready = { + let mut p = c.pending_rename.lock().unwrap(); + match p.as_ref() { + Some((_, t)) if t.elapsed() >= Duration::from_millis(900) => p.take(), + _ => None, + } + }; + if let Some((name, _)) = ready { + c.overlay(&format!("Renamed to “{name}”")); + } + thread::sleep(Duration::from_millis(500)); + } + }); + } + + log("threads spawned; serving events + cmds pipes"); + loop { + thread::sleep(Duration::from_secs(3600)); + } +} diff --git a/windows/daemon/src/media.rs b/windows/daemon/src/media.rs new file mode 100644 index 000000000..10f74ed12 --- /dev/null +++ b/windows/daemon/src/media.rs @@ -0,0 +1,49 @@ +//! Media auto-pause/resume via the Windows System Media Transport Controls +//! (SMTC) — the same session API every media app registers with. This is how we +//! pause playback when the AirPods leave your ears (the Apple/MagicPods +//! behaviour) WITHOUT touching the A2DP profile: Windows manages A2DP itself, so +//! unlike the Linux build there is no profile to toggle. We simply pause/resume +//! whatever app currently owns the system media session (Spotify, a browser, …). + +use windows::Media::Control::{ + GlobalSystemMediaTransportControlsSession as Session, + GlobalSystemMediaTransportControlsSessionManager as SessionManager, + GlobalSystemMediaTransportControlsSessionPlaybackStatus as PlaybackStatus, +}; +use windows::Win32::System::Com::{COINIT_MULTITHREADED, CoInitializeEx}; + +/// Initialize COM (MTA) for the calling thread — required before any SMTC call. +/// Call once, from the thread that will drive the media calls. +pub fn init() { + unsafe { + let _ = CoInitializeEx(None, COINIT_MULTITHREADED); + } +} + +fn current_session() -> windows::core::Result { + // RequestAsync + GetCurrentSession are both quick; block on the async op. + let manager = SessionManager::RequestAsync()?.get()?; + manager.GetCurrentSession() +} + +/// True if the system's current media session is actively playing. False when +/// there is no session or the call fails. +pub fn is_playing() -> bool { + (|| -> windows::core::Result { + let status = current_session()?.GetPlaybackInfo()?.PlaybackStatus()?; + Ok(status == PlaybackStatus::Playing) + })() + .unwrap_or(false) +} + +/// Pause the current media session. Returns true if the control was accepted. +pub fn pause() -> bool { + (|| -> windows::core::Result { Ok(current_session()?.TryPauseAsync()?.get()?) })() + .unwrap_or(false) +} + +/// Resume the current media session. Returns true if the control was accepted. +pub fn play() -> bool { + (|| -> windows::core::Result { Ok(current_session()?.TryPlayAsync()?.get()?) })() + .unwrap_or(false) +} diff --git a/windows/daemon/src/micpipe.rs b/windows/daemon/src/micpipe.rs new file mode 100644 index 000000000..20590d678 --- /dev/null +++ b/windows/daemon/src/micpipe.rs @@ -0,0 +1,173 @@ +//! Writer for the LibrePodsMic virtual microphone: pushes decoded PCM into the +//! driver's ring buffer over the control device `\\.\LibrePodsMic`. + +use std::ffi::c_void; +use std::ptr; +use std::sync::Mutex; + +use windows_sys::Win32::Foundation::{CloseHandle, HANDLE, INVALID_HANDLE_VALUE}; +use windows_sys::Win32::Storage::FileSystem::{ + CreateFileW, FILE_SHARE_READ, FILE_SHARE_WRITE, OPEN_EXISTING, +}; +use windows_sys::Win32::System::IO::DeviceIoControl; + +const GENERIC_READ: u32 = 0x8000_0000; +const GENERIC_WRITE: u32 = 0x4000_0000; +// CTL_CODE(FILE_DEVICE_UNKNOWN, 0x800, METHOD_BUFFERED, FILE_WRITE_DATA) +const IOCTL_LIBREPODS_MIC_WRITE_PCM: u32 = 0x0022_A000; +// CTL_CODE(FILE_DEVICE_UNKNOWN, 0x801, METHOD_BUFFERED, FILE_READ_DATA) +const IOCTL_LIBREPODS_MIC_STATUS: u32 = 0x0022_6004; + +pub struct MicPipe { + handle: HANDLE, +} + +// One tray owns the single exclusive handle and uses it from the receive thread +// (writes) and the status-poll thread (reads); both DeviceIoControl paths are +// independent, so sharing the handle is safe. +unsafe impl Send for MicPipe {} +unsafe impl Sync for MicPipe {} + +impl MicPipe { + /// Open the virtual-mic control device. None if the LibrePodsMic driver + /// isn't installed (or another writer holds the exclusive handle). + pub fn open() -> Option { + let path: Vec = r"\\.\LibrePodsMic" + .encode_utf16() + .chain(std::iter::once(0)) + .collect(); + let handle = unsafe { + CreateFileW( + path.as_ptr(), + GENERIC_READ | GENERIC_WRITE, + FILE_SHARE_READ | FILE_SHARE_WRITE, + ptr::null(), + OPEN_EXISTING, + 0, + ptr::null_mut(), + ) + }; + if handle == INVALID_HANDLE_VALUE || handle.is_null() { + None + } else { + Some(MicPipe { handle }) + } + } + + /// Capture-activity counter — advances while an app is recording from the virtual + /// mic. `None` = the DeviceIoControl failed (handle dead / device gone), so the + /// caller can reopen. + pub fn status(&self) -> Option { + let mut out = [0u8; 4]; + let mut returned = 0u32; + let ok = unsafe { + DeviceIoControl( + self.handle, + IOCTL_LIBREPODS_MIC_STATUS, + ptr::null(), + 0, + out.as_mut_ptr() as *mut c_void, + 4, + &mut returned, + ptr::null_mut(), + ) + }; + if ok != 0 { + Some(i32::from_le_bytes(out)) + } else { + None + } + } + + /// Push mono 16-bit PCM samples into the mic ring. Returns false if the write + /// failed (handle dead) so the caller can reopen. + pub fn write(&self, samples: &[i16]) -> bool { + if samples.is_empty() { + return true; + } + let bytes = std::mem::size_of_val(samples); + let mut returned = 0u32; + let ok = unsafe { + DeviceIoControl( + self.handle, + IOCTL_LIBREPODS_MIC_WRITE_PCM, + samples.as_ptr() as *const c_void, + bytes as u32, + ptr::null_mut(), + 0, + &mut returned, + ptr::null_mut(), + ) + }; + ok != 0 + } +} + +/// Self-healing holder for the virtual-mic pipe. The device may not be enumerated yet +/// when the daemon starts at boot, and the handle can later break (mic driver +/// reinstalled / device re-plugged) — either used to leave the mic dead until a daemon +/// restart. All mic access goes through this cell, which (re)opens the pipe on demand +/// so audio + mic recover on their own. Cheap Mutex; the write path is uncontended. +pub struct MicPipeCell { + inner: Mutex>, +} + +impl MicPipeCell { + /// Try to open once up front; a failure here is fine — it reopens on first use. + pub fn new() -> MicPipeCell { + MicPipeCell { + inner: Mutex::new(MicPipe::open()), + } + } + + fn lock(&self) -> std::sync::MutexGuard<'_, Option> { + self.inner.lock().unwrap_or_else(|p| p.into_inner()) + } + + /// Whether a pipe is currently open (reopening if it was closed). + pub fn is_open(&self) -> bool { + let mut g = self.lock(); + if g.is_none() { + *g = MicPipe::open(); + } + g.is_some() + } + + /// Push PCM; reopen + retry once if the handle broke. + pub fn write(&self, samples: &[i16]) { + let mut g = self.lock(); + if g.is_none() { + *g = MicPipe::open(); + } + if let Some(p) = g.as_ref() { + if !p.write(samples) { + *g = MicPipe::open(); // handle broke — reopen and retry once + if let Some(p2) = g.as_ref() { + let _ = p2.write(samples); + } + } + } + } + + /// Capture-activity counter (0 when unavailable); a dead handle is dropped so the + /// next call reopens it. + pub fn status(&self) -> i32 { + let mut g = self.lock(); + if g.is_none() { + *g = MicPipe::open(); + } + match g.as_ref().and_then(|p| p.status()) { + Some(v) => v, + None => { + *g = None; + 0 + } + } + } +} + +impl Drop for MicPipe { + fn drop(&mut self) { + unsafe { CloseHandle(self.handle) }; + } +} diff --git a/windows/daemon/src/rename.rs b/windows/daemon/src/rename.rs new file mode 100644 index 000000000..55a6a6777 --- /dev/null +++ b/windows/daemon/src/rename.rs @@ -0,0 +1,43 @@ +//! Auto-rename the virtual microphone to the connected device's name (e.g. +//! "AirPods Pro de Pedro", "Beats Fit Pro"), so calls/recordings show that. +//! +//! Renaming an audio endpoint writes to HKLM and needs admin, but the tray runs +//! unelevated. So `install.ps1` registers an elevated, on-demand scheduled task +//! ("LibrePods Rename Mic") that runs `lp-mic-rename` with highest privileges. We +//! drop the desired name in a file and trigger the task via `schtasks /run`, +//! which runs it elevated WITHOUT a UAC prompt. `lp-mic-rename` is idempotent — +//! it does nothing (no audio-service restart) when the mic is already named +//! correctly, so firing this on every launch is cheap. + +use std::os::windows::process::CommandExt; +use std::process::Command; + +const TASK_NAME: &str = "LibrePods Rename Mic"; +const CREATE_NO_WINDOW: u32 = 0x0800_0000; + +/// Best-effort, non-blocking: publish `dev_name` and kick the elevated rename +/// task. Never blocks the tray or surfaces errors — if the task isn't installed, +/// the manual `lp-mic-rename ""` still works. +pub fn apply(dev_name: &str) { + let name = dev_name.trim().to_string(); + if name.is_empty() { + return; + } + std::thread::spawn(move || { + let la = match std::env::var("LOCALAPPDATA") { + Ok(la) => la, + Err(_) => return, + }; + let dir = format!("{la}\\LibrePods"); + let _ = std::fs::create_dir_all(&dir); + // Publish the name for the elevated task to read. + if std::fs::write(format!("{dir}\\micname.txt"), &name).is_err() { + return; + } + // Fire the on-demand elevated task (registered by install.ps1; no UAC). + let _ = Command::new("schtasks") + .args(["/run", "/tn", TASK_NAME]) + .creation_flags(CREATE_NO_WINDOW) + .status(); + }); +} diff --git a/windows/daemon/src/volume.rs b/windows/daemon/src/volume.rs new file mode 100644 index 000000000..38e8fe0fc --- /dev/null +++ b/windows/daemon/src/volume.rs @@ -0,0 +1,125 @@ +//! System output volume via Core Audio (WASAPI IAudioEndpointVolume). The daemon +//! owns volume so it's the single arbiter — it reports it in the Snapshot, serves +//! the tray's volume commands, and ducks it for Conversational Awareness without +//! an IPC round-trip. Controls the default render device (the AirPods when they +//! are the active output). This is Windows audio, independent of the AAP driver. + +use windows::Win32::Media::Audio::Endpoints::IAudioEndpointVolume; +use windows::Win32::Media::Audio::{IMMDeviceEnumerator, MMDeviceEnumerator, eConsole, eRender}; +use windows::Win32::System::Com::{ + CLSCTX_ALL, COINIT_MULTITHREADED, CoCreateInstance, CoInitializeEx, +}; + +/// Join the process MTA for the calling thread. Idempotent-safe (a second call +/// returns S_FALSE). Every daemon thread that touches volume must call this. +pub fn init() { + unsafe { + let _ = CoInitializeEx(None, COINIT_MULTITHREADED); + } +} + +fn endpoint() -> windows::core::Result { + unsafe { + let enumerator: IMMDeviceEnumerator = + CoCreateInstance(&MMDeviceEnumerator, None, CLSCTX_ALL)?; + let device = enumerator.GetDefaultAudioEndpoint(eRender, eConsole)?; + device.Activate(CLSCTX_ALL, None) + } +} + +/// Current master volume of the default output (0..=100), or None if it fails. +pub fn get() -> Option { + let vol = endpoint().ok()?; + let level = unsafe { vol.GetMasterVolumeLevelScalar().ok()? }; + Some((level * 100.0).round().clamp(0.0, 100.0) as u8) +} + +pub fn set(percent: u8) { + if let Ok(vol) = endpoint() { + let level = percent.min(100) as f32 / 100.0; + unsafe { + let _ = vol.SetMasterVolumeLevelScalar(level, std::ptr::null()); + } + } +} + +pub fn step(delta: i32) { + if let Some(cur) = get() { + set((cur as i32 + delta).clamp(0, 100) as u8); + } +} + +pub fn is_muted() -> bool { + endpoint() + .and_then(|v| unsafe { v.GetMute() }) + .map(|b| b.as_bool()) + .unwrap_or(false) +} + +pub fn toggle_mute() { + if let Ok(vol) = endpoint() { + let new = !unsafe { vol.GetMute() }.map(|b| b.as_bool()).unwrap_or(false); + unsafe { + let _ = vol.SetMute(new, std::ptr::null()); + } + } +} + +/// Conversational Awareness volume ducking. The AirPods only *signal* the state +/// (status byte of the 0x4B event) — the host lowers/restores the media volume. +/// Mirrors the app's MediaController::handle_conversational_awareness. +#[derive(Default)] +pub struct ConvDuck { + original: Option, + started: bool, +} + +impl ConvDuck { + /// Apply a Conversational Awareness status, Apple-style (aggressive): the + /// media drops to a low background level so you focus on the conversation + /// (the AirPods add the transparency/voice boost themselves). Mirrors iOS / + /// LibrePods PR #655: 1 = start (→25%), 2 = reduce (→15%), 3 = partial + /// (→min(original,25)), 4/6/7 = end (→restore original). + pub fn on_status(&mut self, status: u8) { + match status { + 1 => { + let cur = get().unwrap_or(0); + if !self.started { + self.original = Some(cur); + self.started = true; + } + if self.original.unwrap_or(cur) > 25 { + set(25); + } + } + 2 => { + if let Some(orig) = self.original { + if orig > 15 { + set(15); + } + } + } + 3 => { + if self.started { + if let Some(orig) = self.original { + set(orig.min(25)); + } + } + } + 4 | 6 | 7 => self.restore(), + _ => {} + } + } + + /// Restore the pre-duck volume immediately (e.g. the user turned CA off while + /// it was mid-duck — no end event will come, so we'd be stuck low otherwise). + pub fn restore(&mut self) { + if self.started { + if let Some(orig) = self.original { + set(orig); + } + self.started = false; + self.original = None; + } + } +} diff --git a/windows/daemon/vendor/ffmpeg/.gitignore b/windows/daemon/vendor/ffmpeg/.gitignore new file mode 100644 index 000000000..3f9baec40 --- /dev/null +++ b/windows/daemon/vendor/ffmpeg/.gitignore @@ -0,0 +1,6 @@ +# FFmpeg headers / import-libs / DLLs are FETCHED at build time (~28k lines of +# headers), not committed. Run ../fetch-ffmpeg.sh (CI does it automatically). +# Only the license and README are tracked here. +include/ +lib/ +bin/ diff --git a/windows/daemon/vendor/ffmpeg/LICENSE-ffmpeg.txt b/windows/daemon/vendor/ffmpeg/LICENSE-ffmpeg.txt new file mode 100644 index 000000000..58af0d378 --- /dev/null +++ b/windows/daemon/vendor/ffmpeg/LICENSE-ffmpeg.txt @@ -0,0 +1,502 @@ + GNU LESSER GENERAL PUBLIC LICENSE + Version 2.1, February 1999 + + Copyright (C) 1991, 1999 Free Software Foundation, Inc. + 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + +[This is the first released version of the Lesser GPL. It also counts + as the successor of the GNU Library Public License, version 2, hence + the version number 2.1.] + + Preamble + + The licenses for most software are designed to take away your +freedom to share and change it. By contrast, the GNU General Public +Licenses are intended to guarantee your freedom to share and change +free software--to make sure the software is free for all its users. + + This license, the Lesser General Public License, applies to some +specially designated software packages--typically libraries--of the +Free Software Foundation and other authors who decide to use it. You +can use it too, but we suggest you first think carefully about whether +this license or the ordinary General Public License is the better +strategy to use in any particular case, based on the explanations below. + + When we speak of free software, we are referring to freedom of use, +not price. Our General Public Licenses are designed to make sure that +you have the freedom to distribute copies of free software (and charge +for this service if you wish); that you receive source code or can get +it if you want it; that you can change the software and use pieces of +it in new free programs; and that you are informed that you can do +these things. + + To protect your rights, we need to make restrictions that forbid +distributors to deny you these rights or to ask you to surrender these +rights. These restrictions translate to certain responsibilities for +you if you distribute copies of the library or if you modify it. + + For example, if you distribute copies of the library, whether gratis +or for a fee, you must give the recipients all the rights that we gave +you. You must make sure that they, too, receive or can get the source +code. If you link other code with the library, you must provide +complete object files to the recipients, so that they can relink them +with the library after making changes to the library and recompiling +it. And you must show them these terms so they know their rights. + + We protect your rights with a two-step method: (1) we copyright the +library, and (2) we offer you this license, which gives you legal +permission to copy, distribute and/or modify the library. + + To protect each distributor, we want to make it very clear that +there is no warranty for the free library. Also, if the library is +modified by someone else and passed on, the recipients should know +that what they have is not the original version, so that the original +author's reputation will not be affected by problems that might be +introduced by others. + + Finally, software patents pose a constant threat to the existence of +any free program. We wish to make sure that a company cannot +effectively restrict the users of a free program by obtaining a +restrictive license from a patent holder. Therefore, we insist that +any patent license obtained for a version of the library must be +consistent with the full freedom of use specified in this license. + + Most GNU software, including some libraries, is covered by the +ordinary GNU General Public License. This license, the GNU Lesser +General Public License, applies to certain designated libraries, and +is quite different from the ordinary General Public License. We use +this license for certain libraries in order to permit linking those +libraries into non-free programs. + + When a program is linked with a library, whether statically or using +a shared library, the combination of the two is legally speaking a +combined work, a derivative of the original library. The ordinary +General Public License therefore permits such linking only if the +entire combination fits its criteria of freedom. The Lesser General +Public License permits more lax criteria for linking other code with +the library. + + We call this license the "Lesser" General Public License because it +does Less to protect the user's freedom than the ordinary General +Public License. It also provides other free software developers Less +of an advantage over competing non-free programs. These disadvantages +are the reason we use the ordinary General Public License for many +libraries. However, the Lesser license provides advantages in certain +special circumstances. + + For example, on rare occasions, there may be a special need to +encourage the widest possible use of a certain library, so that it becomes +a de-facto standard. To achieve this, non-free programs must be +allowed to use the library. A more frequent case is that a free +library does the same job as widely used non-free libraries. In this +case, there is little to gain by limiting the free library to free +software only, so we use the Lesser General Public License. + + In other cases, permission to use a particular library in non-free +programs enables a greater number of people to use a large body of +free software. For example, permission to use the GNU C Library in +non-free programs enables many more people to use the whole GNU +operating system, as well as its variant, the GNU/Linux operating +system. + + Although the Lesser General Public License is Less protective of the +users' freedom, it does ensure that the user of a program that is +linked with the Library has the freedom and the wherewithal to run +that program using a modified version of the Library. + + The precise terms and conditions for copying, distribution and +modification follow. Pay close attention to the difference between a +"work based on the library" and a "work that uses the library". The +former contains code derived from the library, whereas the latter must +be combined with the library in order to run. + + GNU LESSER GENERAL PUBLIC LICENSE + TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION + + 0. This License Agreement applies to any software library or other +program which contains a notice placed by the copyright holder or +other authorized party saying it may be distributed under the terms of +this Lesser General Public License (also called "this License"). +Each licensee is addressed as "you". + + A "library" means a collection of software functions and/or data +prepared so as to be conveniently linked with application programs +(which use some of those functions and data) to form executables. + + The "Library", below, refers to any such software library or work +which has been distributed under these terms. A "work based on the +Library" means either the Library or any derivative work under +copyright law: that is to say, a work containing the Library or a +portion of it, either verbatim or with modifications and/or translated +straightforwardly into another language. (Hereinafter, translation is +included without limitation in the term "modification".) + + "Source code" for a work means the preferred form of the work for +making modifications to it. For a library, complete source code means +all the source code for all modules it contains, plus any associated +interface definition files, plus the scripts used to control compilation +and installation of the library. + + Activities other than copying, distribution and modification are not +covered by this License; they are outside its scope. The act of +running a program using the Library is not restricted, and output from +such a program is covered only if its contents constitute a work based +on the Library (independent of the use of the Library in a tool for +writing it). Whether that is true depends on what the Library does +and what the program that uses the Library does. + + 1. You may copy and distribute verbatim copies of the Library's +complete source code as you receive it, in any medium, provided that +you conspicuously and appropriately publish on each copy an +appropriate copyright notice and disclaimer of warranty; keep intact +all the notices that refer to this License and to the absence of any +warranty; and distribute a copy of this License along with the +Library. + + You may charge a fee for the physical act of transferring a copy, +and you may at your option offer warranty protection in exchange for a +fee. + + 2. You may modify your copy or copies of the Library or any portion +of it, thus forming a work based on the Library, and copy and +distribute such modifications or work under the terms of Section 1 +above, provided that you also meet all of these conditions: + + a) The modified work must itself be a software library. + + b) You must cause the files modified to carry prominent notices + stating that you changed the files and the date of any change. + + c) You must cause the whole of the work to be licensed at no + charge to all third parties under the terms of this License. + + d) If a facility in the modified Library refers to a function or a + table of data to be supplied by an application program that uses + the facility, other than as an argument passed when the facility + is invoked, then you must make a good faith effort to ensure that, + in the event an application does not supply such function or + table, the facility still operates, and performs whatever part of + its purpose remains meaningful. + + (For example, a function in a library to compute square roots has + a purpose that is entirely well-defined independent of the + application. Therefore, Subsection 2d requires that any + application-supplied function or table used by this function must + be optional: if the application does not supply it, the square + root function must still compute square roots.) + +These requirements apply to the modified work as a whole. If +identifiable sections of that work are not derived from the Library, +and can be reasonably considered independent and separate works in +themselves, then this License, and its terms, do not apply to those +sections when you distribute them as separate works. But when you +distribute the same sections as part of a whole which is a work based +on the Library, the distribution of the whole must be on the terms of +this License, whose permissions for other licensees extend to the +entire whole, and thus to each and every part regardless of who wrote +it. + +Thus, it is not the intent of this section to claim rights or contest +your rights to work written entirely by you; rather, the intent is to +exercise the right to control the distribution of derivative or +collective works based on the Library. + +In addition, mere aggregation of another work not based on the Library +with the Library (or with a work based on the Library) on a volume of +a storage or distribution medium does not bring the other work under +the scope of this License. + + 3. You may opt to apply the terms of the ordinary GNU General Public +License instead of this License to a given copy of the Library. To do +this, you must alter all the notices that refer to this License, so +that they refer to the ordinary GNU General Public License, version 2, +instead of to this License. (If a newer version than version 2 of the +ordinary GNU General Public License has appeared, then you can specify +that version instead if you wish.) Do not make any other change in +these notices. + + Once this change is made in a given copy, it is irreversible for +that copy, so the ordinary GNU General Public License applies to all +subsequent copies and derivative works made from that copy. + + This option is useful when you wish to copy part of the code of +the Library into a program that is not a library. + + 4. You may copy and distribute the Library (or a portion or +derivative of it, under Section 2) in object code or executable form +under the terms of Sections 1 and 2 above provided that you accompany +it with the complete corresponding machine-readable source code, which +must be distributed under the terms of Sections 1 and 2 above on a +medium customarily used for software interchange. + + If distribution of object code is made by offering access to copy +from a designated place, then offering equivalent access to copy the +source code from the same place satisfies the requirement to +distribute the source code, even though third parties are not +compelled to copy the source along with the object code. + + 5. A program that contains no derivative of any portion of the +Library, but is designed to work with the Library by being compiled or +linked with it, is called a "work that uses the Library". Such a +work, in isolation, is not a derivative work of the Library, and +therefore falls outside the scope of this License. + + However, linking a "work that uses the Library" with the Library +creates an executable that is a derivative of the Library (because it +contains portions of the Library), rather than a "work that uses the +library". The executable is therefore covered by this License. +Section 6 states terms for distribution of such executables. + + When a "work that uses the Library" uses material from a header file +that is part of the Library, the object code for the work may be a +derivative work of the Library even though the source code is not. +Whether this is true is especially significant if the work can be +linked without the Library, or if the work is itself a library. The +threshold for this to be true is not precisely defined by law. + + If such an object file uses only numerical parameters, data +structure layouts and accessors, and small macros and small inline +functions (ten lines or less in length), then the use of the object +file is unrestricted, regardless of whether it is legally a derivative +work. (Executables containing this object code plus portions of the +Library will still fall under Section 6.) + + Otherwise, if the work is a derivative of the Library, you may +distribute the object code for the work under the terms of Section 6. +Any executables containing that work also fall under Section 6, +whether or not they are linked directly with the Library itself. + + 6. As an exception to the Sections above, you may also combine or +link a "work that uses the Library" with the Library to produce a +work containing portions of the Library, and distribute that work +under terms of your choice, provided that the terms permit +modification of the work for the customer's own use and reverse +engineering for debugging such modifications. + + You must give prominent notice with each copy of the work that the +Library is used in it and that the Library and its use are covered by +this License. You must supply a copy of this License. If the work +during execution displays copyright notices, you must include the +copyright notice for the Library among them, as well as a reference +directing the user to the copy of this License. Also, you must do one +of these things: + + a) Accompany the work with the complete corresponding + machine-readable source code for the Library including whatever + changes were used in the work (which must be distributed under + Sections 1 and 2 above); and, if the work is an executable linked + with the Library, with the complete machine-readable "work that + uses the Library", as object code and/or source code, so that the + user can modify the Library and then relink to produce a modified + executable containing the modified Library. (It is understood + that the user who changes the contents of definitions files in the + Library will not necessarily be able to recompile the application + to use the modified definitions.) + + b) Use a suitable shared library mechanism for linking with the + Library. A suitable mechanism is one that (1) uses at run time a + copy of the library already present on the user's computer system, + rather than copying library functions into the executable, and (2) + will operate properly with a modified version of the library, if + the user installs one, as long as the modified version is + interface-compatible with the version that the work was made with. + + c) Accompany the work with a written offer, valid for at + least three years, to give the same user the materials + specified in Subsection 6a, above, for a charge no more + than the cost of performing this distribution. + + d) If distribution of the work is made by offering access to copy + from a designated place, offer equivalent access to copy the above + specified materials from the same place. + + e) Verify that the user has already received a copy of these + materials or that you have already sent this user a copy. + + For an executable, the required form of the "work that uses the +Library" must include any data and utility programs needed for +reproducing the executable from it. However, as a special exception, +the materials to be distributed need not include anything that is +normally distributed (in either source or binary form) with the major +components (compiler, kernel, and so on) of the operating system on +which the executable runs, unless that component itself accompanies +the executable. + + It may happen that this requirement contradicts the license +restrictions of other proprietary libraries that do not normally +accompany the operating system. Such a contradiction means you cannot +use both them and the Library together in an executable that you +distribute. + + 7. You may place library facilities that are a work based on the +Library side-by-side in a single library together with other library +facilities not covered by this License, and distribute such a combined +library, provided that the separate distribution of the work based on +the Library and of the other library facilities is otherwise +permitted, and provided that you do these two things: + + a) Accompany the combined library with a copy of the same work + based on the Library, uncombined with any other library + facilities. This must be distributed under the terms of the + Sections above. + + b) Give prominent notice with the combined library of the fact + that part of it is a work based on the Library, and explaining + where to find the accompanying uncombined form of the same work. + + 8. You may not copy, modify, sublicense, link with, or distribute +the Library except as expressly provided under this License. Any +attempt otherwise to copy, modify, sublicense, link with, or +distribute the Library is void, and will automatically terminate your +rights under this License. However, parties who have received copies, +or rights, from you under this License will not have their licenses +terminated so long as such parties remain in full compliance. + + 9. You are not required to accept this License, since you have not +signed it. However, nothing else grants you permission to modify or +distribute the Library or its derivative works. These actions are +prohibited by law if you do not accept this License. Therefore, by +modifying or distributing the Library (or any work based on the +Library), you indicate your acceptance of this License to do so, and +all its terms and conditions for copying, distributing or modifying +the Library or works based on it. + + 10. Each time you redistribute the Library (or any work based on the +Library), the recipient automatically receives a license from the +original licensor to copy, distribute, link with or modify the Library +subject to these terms and conditions. You may not impose any further +restrictions on the recipients' exercise of the rights granted herein. +You are not responsible for enforcing compliance by third parties with +this License. + + 11. If, as a consequence of a court judgment or allegation of patent +infringement or for any other reason (not limited to patent issues), +conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot +distribute so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you +may not distribute the Library at all. For example, if a patent +license would not permit royalty-free redistribution of the Library by +all those who receive copies directly or indirectly through you, then +the only way you could satisfy both it and this License would be to +refrain entirely from distribution of the Library. + +If any portion of this section is held invalid or unenforceable under any +particular circumstance, the balance of the section is intended to apply, +and the section as a whole is intended to apply in other circumstances. + +It is not the purpose of this section to induce you to infringe any +patents or other property right claims or to contest validity of any +such claims; this section has the sole purpose of protecting the +integrity of the free software distribution system which is +implemented by public license practices. Many people have made +generous contributions to the wide range of software distributed +through that system in reliance on consistent application of that +system; it is up to the author/donor to decide if he or she is willing +to distribute software through any other system and a licensee cannot +impose that choice. + +This section is intended to make thoroughly clear what is believed to +be a consequence of the rest of this License. + + 12. If the distribution and/or use of the Library is restricted in +certain countries either by patents or by copyrighted interfaces, the +original copyright holder who places the Library under this License may add +an explicit geographical distribution limitation excluding those countries, +so that distribution is permitted only in or among countries not thus +excluded. In such case, this License incorporates the limitation as if +written in the body of this License. + + 13. The Free Software Foundation may publish revised and/or new +versions of the Lesser General Public License from time to time. +Such new versions will be similar in spirit to the present version, +but may differ in detail to address new problems or concerns. + +Each version is given a distinguishing version number. If the Library +specifies a version number of this License which applies to it and +"any later version", you have the option of following the terms and +conditions either of that version or of any later version published by +the Free Software Foundation. If the Library does not specify a +license version number, you may choose any version ever published by +the Free Software Foundation. + + 14. If you wish to incorporate parts of the Library into other free +programs whose distribution conditions are incompatible with these, +write to the author to ask for permission. For software which is +copyrighted by the Free Software Foundation, write to the Free +Software Foundation; we sometimes make exceptions for this. Our +decision will be guided by the two goals of preserving the free status +of all derivatives of our free software and of promoting the sharing +and reuse of software generally. + + NO WARRANTY + + 15. BECAUSE THE LIBRARY IS LICENSED FREE OF CHARGE, THERE IS NO +WARRANTY FOR THE LIBRARY, TO THE EXTENT PERMITTED BY APPLICABLE LAW. +EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR +OTHER PARTIES PROVIDE THE LIBRARY "AS IS" WITHOUT WARRANTY OF ANY +KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE +LIBRARY IS WITH YOU. SHOULD THE LIBRARY PROVE DEFECTIVE, YOU ASSUME +THE COST OF ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN +WRITING WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY +AND/OR REDISTRIBUTE THE LIBRARY AS PERMITTED ABOVE, BE LIABLE TO YOU +FOR DAMAGES, INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR +CONSEQUENTIAL DAMAGES ARISING OUT OF THE USE OR INABILITY TO USE THE +LIBRARY (INCLUDING BUT NOT LIMITED TO LOSS OF DATA OR DATA BEING +RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD PARTIES OR A +FAILURE OF THE LIBRARY TO OPERATE WITH ANY OTHER SOFTWARE), EVEN IF +SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH +DAMAGES. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Libraries + + If you develop a new library, and you want it to be of the greatest +possible use to the public, we recommend making it free software that +everyone can redistribute and change. You can do so by permitting +redistribution under these terms (or, alternatively, under the terms of the +ordinary General Public License). + + To apply these terms, attach the following notices to the library. It is +safest to attach them to the start of each source file to most effectively +convey the exclusion of warranty; and each file should have at least the +"copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + This library is free software; you can redistribute it and/or + modify it under the terms of the GNU Lesser General Public + License as published by the Free Software Foundation; either + version 2.1 of the License, or (at your option) any later version. + + This library is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + Lesser General Public License for more details. + + You should have received a copy of the GNU Lesser General Public + License along with this library; if not, write to the Free Software + Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA + +Also add information on how to contact you by electronic and paper mail. + +You should also get your employer (if you work as a programmer) or your +school, if any, to sign a "copyright disclaimer" for the library, if +necessary. Here is a sample; alter the names: + + Yoyodyne, Inc., hereby disclaims all copyright interest in the + library `Frob' (a library for tweaking knobs) written by James Random Hacker. + + , 1 April 1990 + Ty Coon, President of Vice + +That's all there is to it! diff --git a/windows/daemon/vendor/ffmpeg/README.md b/windows/daemon/vendor/ffmpeg/README.md new file mode 100644 index 000000000..483cde53f --- /dev/null +++ b/windows/daemon/vendor/ffmpeg/README.md @@ -0,0 +1,19 @@ +# Vendored FFmpeg (fetched, not committed) + +The daemon links a tiny slice of **FFmpeg 7.1** (`avcodec` / `avutil` / +`swresample`) to decode the AirPods' hi-res **AAC-ELD** microphone stream. + +These libraries are **fetched at build time**, not committed — the FFmpeg headers +alone are ~28k lines, which is pure noise in the repo/PR. Only this note and the +license live here. + +- **Fetch:** run `../fetch-ffmpeg.sh` (CI does it automatically before building the + daemon). It downloads a pinned FFmpeg 7.1 LGPL **shared** build from + [BtbN/FFmpeg-Builds](https://github.com/BtbN/FFmpeg-Builds), verifies its + SHA256, and drops the headers + import libs (both MSVC `.lib` and MinGW + `.dll.a`) + DLLs into `include/`, `lib/`, `bin/` here. +- **License:** FFmpeg is used under the **LGPL v2.1+** — see `LICENSE-ffmpeg.txt`. + The `-lgpl-shared` build ships the runtime DLLs (`avcodec-61.dll`, + `avutil-59.dll`, `swresample-5.dll`) alongside the app. +- **Updating:** bump `URL` / `URL_SHA256` in `fetch-ffmpeg.sh` when moving versions + (keep the `av*-NN.dll` SONAMEs in sync with what the app loads). diff --git a/windows/docs/README.md b/windows/docs/README.md new file mode 100644 index 000000000..446489bea --- /dev/null +++ b/windows/docs/README.md @@ -0,0 +1,29 @@ +# LibrePods — Windows docs + +Docs are grouped **by operating system**, because the Windows stack shares +one codebase but each OS has its own integration story, drivers, and gotchas. + +## [`windows/`](windows/) 🪟 +The Windows port (the bulk of the porting effort — Linux already worked). +- **[`windows/HANDOFF.md`](windows/HANDOFF.md)** — the technical handoff/log: + daemon + IPC architecture, drivers, hi-res mic, features, TODOs, and the + reverse-engineering notes. +- **[`windows/daemon-ipc/PLAN.md`](windows/daemon-ipc/PLAN.md)** — the daemon + + named-pipe IPC design (why `librepodsd` owns the driver and the UIs are clients). +- **[`windows/hires-mic/PLAN.md`](windows/hires-mic/PLAN.md)** — the AAC-ELD + virtual-microphone plan (protocol, decode, driver). + +## Linux 🐧 +Linux is the original desktop target and needs no port-specific driver docs — it +uses BlueZ/PulseAudio directly. See the **[`linux/` README](../../linux/README.md)** +at the repo root for setup and usage. + +## Android 🤖 +See the repo-root **[README](../../README.md)** (Android section) and the Android +app sources. + +## Protocol (OS-agnostic) +The AAP/AACP protocol notes live at the repo root and apply to every platform: +**[`AAP Definitions.md`](../../AAP%20Definitions.md)**, +**[`docs/control_commands.md`](../../docs/control_commands.md)**, +**[`Proximity Pairing Message.md`](../../Proximity%20Pairing%20Message.md)**. diff --git a/windows/docs/aap-packet-discovery.md b/windows/docs/aap-packet-discovery.md new file mode 100644 index 000000000..2f7207c93 --- /dev/null +++ b/windows/docs/aap-packet-discovery.md @@ -0,0 +1,184 @@ +# AAP Packet Discovery — field guide (macOS / iOS) + +**Goal:** capture the *ground-truth* AirPods AAP protocol traffic so we stop guessing. +We reverse-engineer AAP (the AirPods control protocol over classic-Bluetooth +L2CAP **PSM `0x1001`**) from the Android app and community PRs. macOS talks AAP +**natively**, so sniffing its Bluetooth traffic shows us the **exact bytes Apple +sends** — for confirming what we know and discovering what we don't. + +**What this best unblocks** +1. 🫀 **Heart-rate enable sequence** (the stubborn one — capture on *iOS*, see Part B). +2. 🔋 **Charging bit** in the battery packet (confirm `0x01`/`0x05`/`0x02`). +3. ❓ Any **unknown opcodes** not yet in `AAP Definitions.md`. + +> This guide lives in the repo on purpose — do the git import on the Mac, open +> Claude Code there, and point it at this file. Everything below is runnable on +> the Mac. + +--- + +## 0. What you need + +- **This repo** (git import done ✅). +- A **Mac** with Bluetooth + your AirPods paired. +- **PacketLogger** — ships in Apple's *Additional Tools for Xcode*: + [developer.apple.com/download/all](https://developer.apple.com/download/all/) → + search **"Additional Tools for Xcode"** → download the DMG → `PacketLogger.app` + is inside the **Hardware/** folder. (Free Apple ID is enough.) +- **(HR only)** an **iPhone** + Apple's **Bluetooth logging profile** (Part B). +- **Optional but handy:** Wireshark/tshark to parse `.pklg` — `brew install wireshark`. + +--- + +## 1. The method: one action, one diff + +The whole trick is **isolation**. Capture with a clean log, do **one** action, +stop, and see which bytes appeared/changed. That single-action delta *is* the +opcode for that action. Never toggle three things at once — you won't know which +bytes belong to which. + +Keep a scratch note like: + +``` +t+3.2s toggled ANC Off → Noise Cancellation +t+7.8s put left bud in ear +t+12.0s put both in case, closed lid +``` + +Then line the timestamps up against the capture. + +--- + +## 2. Part A — capture macOS ↔ AirPods + +Covers **battery, charging, ANC, ear-detection, Conversational Awareness, +Adaptive Volume, features**. + +1. Open **PacketLogger** → it starts a live capture (or `File → New → Live Capture`). +2. `Edit → Clear` to zero it. +3. Do **one action at a time**, noting each timestamp: + - Connect the AirPods. + - Cycle **ANC**: Off → Noise Cancellation → Transparency → Adaptive (one step at a time). + - **Ear detection**: take one bud out, put it back. + - **Case**: put both in the case, then **open the lid**, then close it — watch for the **charging** transition (this is the `0x01`/`0x05` bit we want to confirm). + - In *System Settings → your AirPods*: toggle **Conversational Awareness**, **Adaptive/Personalized Volume**. +4. `File → Save…` → `airpods-macos.pklg`. + +--- + +## 3. Part B — capture iOS ↔ AirPods (the HR sequence) + +macOS almost certainly **doesn't** drive heart-rate — the iPhone's Fitness / +Workout flow does. So HR must be captured on the **iPhone**: + +1. On the iPhone, install Apple's **Bluetooth** logging profile: + [developer.apple.com/bug-reporting/profiles-and-logs](https://developer.apple.com/bug-reporting/profiles-and-logs/) + → **Bluetooth** → install. It lands in **Settings → General → VPN & Device Management**. +2. **Reboot** the iPhone (logging starts on boot). +3. Connect the **AirPods Pro 3** to the iPhone, wear them, open **Fitness** and start + a **Workout** that reads heart rate. Let it read for ~30 s. +4. Trigger a **sysdiagnose**: press **Vol-Up + Vol-Down + Side** together briefly (feel a short buzz). Wait ~5 min while it builds. +5. Retrieve it: **Settings → Privacy & Security → Analytics & Improvements → + Analytics Data → `sysdiagnose_…`** → share/**AirDrop** to the Mac. +6. Inside the sysdiagnose `.tar.gz` the Bluetooth trace is a **PacketLogger `.pklg`** + (under `bluetooth/` or `system_logs`). That's your HR capture. + +> Alternative: some Xcode/PacketLogger versions can capture a **connected iOS +> device** directly (`PacketLogger → File → New iOS Trace`). If yours offers it, +> skip the sysdiagnose dance. + +--- + +## 4. Part C — read & filter + +AAP rides L2CAP SDUs on **PSM `0x1001`**. Framing cheatsheet (from our code — +`windows/daemon/src/aap.rs`): + +| Direction | Looks like | Meaning | +|---|---|---| +| host → device | `04 00 04 00 09 00 00 00 00` | control command (ANC `id=0D`, CA `28`, AdaptiveVol `26`, AllowOff `34`, AdaptiveNoise `2E`, HRM `30`) | +| device → host | `04 00 04 00 04 …` | battery (status byte per component: `01`=charging, `02`=not, `04`=disconnected, **`05`=charging in case**) | +| device → host | `04 00 04 00 06 …` | ear detection (`00`=in-ear, `02`=in-case, `03`=disconnected) | +| device → host | `04 00 04 00 4B … ` | Conversational Awareness event | +| either | `04 00 04 00 17 00 …` (RTBuddy) | **heart-rate** frames (SensorDataWX) | + +**Parse it two ways:** + +- **tshark** (quick grep): + ```bash + tshark -r airpods-macos.pklg -Y btl2cap \ + -T fields -e frame.time_relative -e btl2cap.cid -e data \ + | grep -Ei '0400 0400|04000400' + ``` +- **Claude on the Mac** (richer): open the `.pklg` in **Wireshark**, then + `File → Export Packet Dissections → As JSON` and hand the JSON to Claude to + parse + diff. Or use the helper below. + +--- + +## 5. Part D — the helper parser + +`windows/docs/aap_extract.py` — feed it a text/hex export (from tshark, a +Wireshark "Export as plain text", or a copy-paste hex dump) and it pulls out the +AAP-looking packets and annotates the opcode: + +```bash +tshark -r airpods-macos.pklg -Y btl2cap -T fields -e frame.time_relative -e data \ + | python3 windows/docs/aap_extract.py +``` + +It's deliberately simple/forgiving about input format — adapt it on the Mac with +Claude if your export looks different. + +--- + +## 6. Part E — what to hunt (priorities) + +1. **HR compute trigger** — the enable *mechanism* is solved: streams are driven + by `sensor_stream` (`0x17 … 42 0B`, stream id + period µs; see `AAP + Definitions.md` → "Starting and Stopping Sensor Streams"). On Windows this + already brings up **raw PPG (type 16) standalone**, but the AirPods never emit + the **computed heart rate (type 19)**. Every capture began mid-session, so the + trigger is believed to be in the setup. **Do a fresh capture from a + disconnected state**: start `idevicebtlogger` → connect the AirPods → start a + Fitness workout, and extract the full ordered host→device sequence up to the + first `08 13 1a 12` (type-19) frame. +2. **Unknown opcodes** — anything with header `04 00 04 00` and an opcode not in + the table above / `AAP Definitions.md`. + +## 6b. Part E-2 — case & lid sensors (open questions) + +`AAP Definitions.md` → "Case and Charging Transitions" leaves three things open; +these targeted captures would settle them. Filter battery `…04` packets (the +per-component status byte) and watch device→host opcodes + control id `0x3B`. + +1. **What selects charging `0x05` vs `0x01`.** Refuted so far: not elapsed time, + not the number of buds, not case detection. Remaining candidate: physically + handling the case/lid. Two runs, same buds: + - **Run A (hands-off):** seat both buds, lid open, do **not** touch the case or + lid for ~3 min. Count `0x05` occurrences. + - **Run B (handled):** seat both buds, then open/close the lid and handle the + case a few times. Count `0x05`. + - If `0x05` appears only in Run B → lid/case interaction is the selector. +2. **The lid open/close signal** (never located). Buds already seated, hands-off, + start capture; **open** the lid (note t), wait, **close** it (note t). Diff the + packets at those two instants — look for a device→host opcode or a control id + that toggles with the lid. +3. **Control id `0x3B`** fires 260 ms before the `0x05 → 0x01` flip — watch + whether it consistently precedes the flip (cause) or just co-occurs. + +Bring findings back to `AAP Definitions.md`; a clean lid signal would let the +daemon fire real "Case opened/closed" events instead of inferring them from +whether the case battery is being reported. + +--- + +## 7. Part F — bring it back + +- Protocol findings → append to **`AAP Definitions.md`** (the protocol doc). +- HR frames → **`windows/daemon/src/aap.rs`** (the `HR_*` consts). +- **Keep the per-action diff notes** — "these bytes changed when I did X" is the + most valuable artifact; paste them into the PR / commit message so the + provenance is clear. + +Happy hunting. 🎯 diff --git a/windows/docs/aap_extract.py b/windows/docs/aap_extract.py new file mode 100755 index 000000000..3d4abcf7a --- /dev/null +++ b/windows/docs/aap_extract.py @@ -0,0 +1,105 @@ +#!/usr/bin/env python3 +"""Extract & annotate AirPods AAP packets from a hex/text export. + +Feed it anything that contains hex byte runs — tshark `-e data` output, a +Wireshark "Export as plain text", or a copy-pasted hex dump. It reconstructs the +byte arrays, keeps the ones that look like AAP (header `04 00 04 00` or a bare +control/notify opcode) and prints them one per line with the opcode named. + + tshark -r cap.pklg -Y btl2cap -T fields -e frame.time_relative -e data \ + | python3 aap_extract.py + +Deliberately forgiving about input format — tweak on the Mac if your export +differs. This is a starting point, not a full dissector. +""" +import re +import sys + +AAP_HEADER = bytes([0x04, 0x00, 0x04, 0x00]) + +# opcode (byte after the 04 00 04 00 header) -> human name +OPCODES = { + 0x04: "battery", + 0x06: "ear-detection", + 0x09: "control-command", + 0x0F: "request-notifications", + 0x1A: "rename", + 0x4B: "conversational-awareness", + 0x4D: "set-features", + 0x58: "hi-res-audio", + 0x17: "rtbuddy (heart-rate)", +} + +# control-command id (byte after opcode 0x09) -> human name +CONTROL_IDS = { + 0x0D: "ANC (1=off 2=NC 3=transparency 4=adaptive)", + 0x26: "adaptive/personalized volume", + 0x28: "conversational awareness", + 0x2E: "adaptive-noise strength (0..100)", + 0x30: "HRM state", + 0x34: "allow-off", +} + +# battery component id -> name; status byte -> meaning +BATT_COMPONENT = {0x01: "headphone", 0x02: "right", 0x04: "left", 0x08: "case"} +BATT_STATUS = {0x01: "charging", 0x02: "not-charging", 0x04: "disconnected", 0x05: "charging-in-case"} + +# grab runs of hex bytes: "04 00 04 00", "04:00:04:00", or "04000400" +HEX_RUN = re.compile(r"(?:[0-9a-fA-F]{2}[\s:]*){4,}") + + +def to_bytes(token: str) -> bytes: + h = re.sub(r"[^0-9a-fA-F]", "", token) + if len(h) % 2: + h = h[:-1] + try: + return bytes.fromhex(h) + except ValueError: + return b"" + + +def annotate(b: bytes) -> str: + if len(b) >= 5 and b[:4] == AAP_HEADER: + op = b[4] + name = OPCODES.get(op, f"opcode 0x{op:02x} (UNKNOWN)") + extra = "" + if op == 0x09 and len(b) >= 8: + cid = b[6] + extra = f" id=0x{cid:02x} {CONTROL_IDS.get(cid, 'UNKNOWN')} value=0x{b[7]:02x}" + elif op == 0x04 and len(b) >= 7: + # battery: count at b[6], then 5-byte records (id, ?, level, status, ?) + count = b[6] + parts = [] + base = 7 + for _ in range(count): + if base + 3 >= len(b): + break + comp = BATT_COMPONENT.get(b[base], f"0x{b[base]:02x}") + level = b[base + 2] + status = BATT_STATUS.get(b[base + 3], f"0x{b[base + 3]:02x}") + parts.append(f"{comp}={level}%/{status}") + base += 5 + extra = " " + " ".join(parts) + elif op == 0x06 and len(b) >= 7: + ear = {0x00: "in-ear", 0x02: "in-case", 0x03: "disconnected"} + extra = f" primary={ear.get(b[5], hex(b[5]))} secondary={ear.get(b[6], hex(b[6]))}" + return f"{name}{extra}" + return "(non-AAP)" + + +def main() -> None: + seen = 0 + for line in sys.stdin: + for m in HEX_RUN.finditer(line): + b = to_bytes(m.group()) + if len(b) >= 5 and b[:4] == AAP_HEADER: + seen += 1 + hexs = " ".join(f"{x:02x}" for x in b) + print(f"{annotate(b):<48} {hexs}") + if seen == 0: + print("no AAP packets found — check the export format " + "(need hex byte runs starting 04 00 04 00)", file=sys.stderr) + + +if __name__ == "__main__": + main() diff --git a/windows/docs/daemon-ipc/PLAN.md b/windows/docs/daemon-ipc/PLAN.md new file mode 100644 index 000000000..a1977b3a1 --- /dev/null +++ b/windows/docs/daemon-ipc/PLAN.md @@ -0,0 +1,156 @@ +# LibrePods Windows daemon + IPC — plan + +**Goal:** kill the exclusive-driver tug-of-war. Today both `librepods-tray.exe` +and `librepods.exe` want the single, **exclusive** driver handle, so only one can +run — hence the fragile "Open App" handoff, lingering daemon/zombie processes, +and duplicated AAP code in two binaries. + +**Fix (the Gemini architecture):** one headless **daemon owns the driver + the +AAP session + the mic pipeline**; the tray and the full app become thin **IPC +clients**. They can run **at the same time**, nobody fights over the handle, and +battery/mic keep working even with no UI open. + +``` + ┌─ librepods-tray.exe (UI client, light) +librepodsd.exe ──┤ IPC: \\.\pipe\LibrePods (NDJSON) +(owns the driver)└─ librepods.exe (UI client, iced GUI) +``` + +## Components + +- **`librepodsd`** (new, `windows/daemon/`) — headless. Owns the driver + handle + AAP session (today's tray `run_receiver`), the mic pipeline (decode + AAC-ELD → feed `\\.\LibrePodsMic`), the auto-activate poll + A2DP reset, and + the dynamic-rename trigger. Holds the **authoritative state**. Runs an IPC + server. Single-instance (named mutex). +- **`librepods-ipc`** (new lib crate) — the shared `Command` / `Event` serde + types + a tiny NDJSON framing helper, so daemon and clients agree on the wire. +- **`librepods-tray`** (refactored) — pure UI client: connects to the pipe + (spawns the daemon if absent), renders the icon/menu/overlay from daemon + events, sends commands. Its `driver`/`aap`/`eld`/`micpipe`/`a2dp` modules + **move into the daemon**. +- **`librepods.exe`** (app, Windows) — becomes an IPC client too + (Phase 3): its `platform/windows` backend talks to the daemon instead of the + driver directly. On **Linux nothing changes** (still `bluer`, no daemon). + +## IPC protocol + +- **Transport:** Windows **named pipe** `\\.\pipe\LibrePods`, duplex, message + mode, **multi-instance** (one pipe instance per connected client). Access + restricted to the current user. +- **Framing:** newline-delimited JSON (NDJSON) — one `serde_json` value per line. +- **Client → Daemon `Command`:** + - `Hello { kind: "tray" | "app" }` — sent on connect; daemon replies with a + full `State` snapshot. + - `SetAnc(u8)` (1..=4) + - `SetMicMode { auto: bool, manual: bool }` + - `GetState` + - (volume stays **client-side** via WASAPI — it's not the exclusive resource, + so no need to route it through the daemon.) +- **Daemon → Client `Event`:** + - `State(Snapshot { connected, battery{l,r,case,headphone}, anc, dev_name, + mic_recording, auto_mode })` — pushed on every change, and once on connect. + - `Overlay { title, body }` — a notification for the client to render (the + daemon decides *when*; the tray/app draw it with their overlay UI). + +## Lifecycle + +- **Start:** the tray autostarts at login (as now) and **ensures the daemon is + running** — if the pipe isn't there, it spawns `librepodsd.exe`, then connects. + Client-spawns-daemon = no separate autostart entry, robust. +- **Single-instance:** the daemon holds a named mutex; a second spawn exits. +- **Death/restart:** a client that sees the pipe drop retries/reconnects (and + re-spawns the daemon if needed). The daemon keeps running when UIs close. +- **Shutdown:** closing a UI just disconnects its pipe; the daemon lives on. + (A tray "Quit LibrePods" can send a `Shutdown` that stops the daemon too.) + +## Migration — incremental, nothing breaks between phases + +1. **Daemon core.** New `librepodsd` + `librepods-ipc`. Move `run_receiver` + + the auto-activate poll + the mic pipeline + `State` out of the tray into the + daemon. Add the NDJSON named-pipe server; broadcast `State`/`Overlay`. Test + the daemon standalone (it logs; the mic + battery work with no UI). +2. **Tray → client.** Strip the tray's driver/aap/eld/micpipe/a2dp; it connects + to the daemon, renders from `Event`s, sends `Command`s, spawns the daemon if + absent. **This alone ends the handle conflict for the tray** and deletes the + "Open App handoff" hack. +3. **App → client.** Route app's `platform/windows` L2CAP/session + backend through the daemon IPC. Now tray + full app coexist. (Heaviest phase — + touches the shared cross-platform code; Linux path untouched.) +4. **Polish.** Daemon single-instance + autostart-on-demand + reconnect; installer + ships `librepodsd.exe` and drops the exclusive-handoff shortcut logic. + +## Status — Phases 1–2 DONE ✅ (validated on hardware) + +- **`librepodsd`** owns the driver + AAP session + hi-res mic; the **tray is a + thin IPC client** and they coexist. Confirmed on hardware: battery / ANC / + volume / mic all shown + controlled over IPC, auto-reconnect, overlay cards. +- **IPC = two one-directional named pipes** (`PIPE_EVENTS` daemon→client, + `PIPE_CMDS` client→daemon), NDJSON, **async** (per-connection queue + writer + thread each side). This was forced by two bugs hit on the way: + 1. **Sync-handle serialization / deadlock** — a single *duplex* pipe deadlocked: + a Windows synchronous handle serializes I/O, so the daemon's blocking + ReadFile (commands) stalled its WriteFile (events) on the same handle (it + wrote 2 messages then hung). Split into two one-directional pipes. + 2. **UI freeze** — a synchronous blocking WriteFile on the tray's UI thread + froze the menu. Both sides now decouple I/O onto their own threads. + - Also: the named-pipe DACL must grant the same-user client (`D:(A;;GA;;;AU)…`); + the default null descriptor denied it. +- **BLE 'connect?' prompt** (beyond the plan): `le.rs` watches (passive, and only + while disconnected) for the AirPods proximity advertisement and sends + `Event::ConnectPrompt`; the tray shows a clickable, accent-themed overlay card; + a click → `Command::Connect`. +- **Never-steal policy**: the daemon never auto-connects — it waits for the + prompt/Connect, and **releases on any drop** (never reconnects on its own, + which would steal the AirPods back from the iPhone). Connect retries ~18 s for + resilience. +- **Phase 3 (full app as a client)** — NOT done; the app still uses the + Open-App handoff. The daemon's own session vs the app's session over one L2CAP + channel is the open design question (state-client vs raw-L2CAP-proxy). + +## Phase 3 — the "web-app" model (approach A, user's decision) + +The user's framing: **tray and app are both clients; the daemon is the single +server/arbiter** — like a web app where many clients interact but every action is +**atomic, serialized through the server**. So the app does NOT run its own AAP +session on Windows: it renders its iced GUI from the daemon's state and sends +commands, exactly like the tray. One connection, one owner, no dual sessions. + +Implementation sketch (to do together, with hardware testing): +1. **Extend the protocol** (`librepods-ipc`) with everything the full app shows/ + controls that the tray doesn't: device info (model / serials / firmware), the + available ANC modes, ear-detection state, conversational awareness, etc. + (add `#[serde(default)]` to new `Snapshot` fields). New `Command`s for the + extra controls. The daemon already serializes commands (single `apply_command`). +2. **Daemon**: parse + publish the extra data in the `Snapshot` (its session + already parses battery/ANC/ear; add the rest). +3. **App (`app`, Windows only)**: source the GUI's state from the + daemon IPC instead of a live session. The clean shape: abstract the app's + "state source" — Linux = the `bluer` session (unchanged), Windows = an IPC + client of the daemon. The iced GUI renders from the state stream either way. + This is the big, riskier change — do it behind `#[cfg(windows)]`, test each + step, keep Linux untouched. +- **Do NOT** rewrite the app's core blindly (it would break the working app); + implement + validate on hardware with the user present. + +## Mic aligns with PR #655 ✅ + +Our Windows hi-res mic uses the **same** AAP commands (`START_AUDIO`/`STOP_AUDIO`, +0x58), AAC-ELD params (64 kHz true → resample 48 kHz, 480-sample/7.5 ms mono, ASC +`F8 E6 30 00`), 0x58 framing (22-byte AU header), and decoder (FFmpeg libavcodec, +LGPL) as Linux [PR #655](https://github.com/librepods-org/librepods/pull/655) — +so the decode/protocol is shareable. Deltas: we add a ×3 make-up gain (mic ran +quiet); PR #655 has a **missing-SDU watchdog** we don't (follow-up). When both +land in `cross-platform`, unify the decode into the shared crate. + +## Risks / notes + +- **Multi-client pipe:** the daemon must serve several pipe instances at once + (tray + app) and broadcast to all — one reader thread per client + a shared + broadcast channel (or a small async runtime). +- **Mic ownership:** the daemon becomes the single owner of both the driver and + `\\.\LibrePodsMic` — cleaner than today (the tray owned them). +- **Phase 3 is the big one** (shared code); Phases 1–2 already remove the pain + and can ship on their own. +- **Shared modules:** move `aap`/`driver`/`eld`/`micpipe`/`a2dp` into the daemon + (or a small `librepods-win-core` lib if the app later needs them in-proc too). diff --git a/windows/docs/heart-rate.md b/windows/docs/heart-rate.md new file mode 100644 index 000000000..1d8e9c0ea --- /dev/null +++ b/windows/docs/heart-rate.md @@ -0,0 +1,138 @@ +# Heart rate on Windows — why it doesn't work + +**Short version:** AirPods Pro 3 will *accept* the heart-rate request on Windows +and *acknowledge* it, but they never send any readings. This is an **intentional +Apple-ecosystem restriction**, not a bug in LibrePods or in the Windows driver. +The feature is therefore **off by default** and hidden behind a warning in +**Settings ▸ Experimental**. + +--- + +## What we send (byte-identical to the working clients) + +The daemon drives the exact same AAP sequence, byte for byte, that the working +Android/iOS clients use — same opcodes, same constants, same ordering: + +| Step | Frame | +|------|-------| +| connect service 0 | `00 00 00 00 01 00 03 …` | +| capabilities 0 | `04 00 00 00 01 00 00` | +| connect service 4 | `00 00 04 00 01 00 03 …` | +| capabilities 4 | `04 00 04 00 01 00 00` | +| `HRM_STATE` (0x30) enable | `04 00 04 00 09 00 30 01 00 00 00` | +| `HEART_RATE_START_1S` | `04 00 04 00 17 00 00 00 10 00 10 00 08 e3 46 42 0b 08 13 10 02 1a 05 01 40 42 0f 00` | + +(The `e3 46` in the START frame is Android's exact varint constant for the 1 s +period; we pinned it to rule out a value mismatch.) + +The AirPods reply with the ACK `4a 02 08 13` — i.e. they *understood and +accepted* the enable — but the data frame that carries a reading +(`08 13 1A 12 <18-byte payload>`, BPM = `payload[1]`) **never arrives**. + +## What we ruled out (this is not our code) + +Every plausible transport/host cause was investigated and eliminated: + +- **Not the sequence / timing.** Byte-identical to the working clients, with the + same inter-step delays and quiet period. Both buds in-ear, re-paired, + rebooted. +- **Not L2CAP ERTM (Enhanced Retransmission Mode).** We tried opening the AAP + channel with `BRB_L2CA_OPEN_ENHANCED_CHANNEL` + `CM_RETRANSMISSION_AND_FLOW`. + Windows `bthport` does **not** serialize the ERTM RFC option to the wire for a + client profile driver (proven across two driver builds). And it wouldn't have + mattered: the working Android runs AAP over **Basic mode** anyway + (`l2c_link_adjust_chnl_allocation: FCR Mode:0`), which is what our driver uses. +- **Not encryption.** We opened the AAP channel with encryption *required* — + `CF_LINK_ENCRYPTED` in the BRB `ChannelFlags` — and confirmed it connects, + encrypted; the AirPods still ACK service 19 and stream **zero** readings. (An + earlier attempt also put `CF_LINK_ENCRYPTED` in `ConfigOut/In.Flags`, which are + a `CFG_*` option bitmask — not link flags — and that broke every connect with + `STATUS_INVALID_PARAMETER 0xC000000D`. That was a field-placement bug, not + encryption; with it in the correct field the encrypted channel changes nothing.) +- **Not a missing capability.** The AirPods themselves advertise the `HRM_STATE` + (0x30) capability to us and ACK the enable — they simply withhold the data. +- **Not descriptor enumeration.** Proper protobuf parsing of + `request_all_descriptors` confirmed our unit returns only `devmotion6`, never + the `HEARTRATE` descriptor — even on a unit that a working host *does* get HR + from. The gate is applied by the AirPods, per-host. + +Even the LibrePods author gets no data from his unit on a non-Apple host. + +## Why it's a host gate — identity ruled out, privilege is the real line + +AirPods restrict biometric (heart-rate) data, but the restriction is **not on the +host's *identity*** — it's on being the buds' **primary, privileged host**. Two +independent lines of evidence settle this. + +**All three possible Device-ID records were tested — none unlock HR.** The one +vendor knob Windows exposes is the local **Device ID (PnP/SDP `0x1200`)** record +under `BTHPORT\Parameters`. We set each value, rebooted to republish, and re-tested +with the iPhone off and both buds in-ear: + +| Host DID | Identity it presents | Result | +|---|---|---| +| `004C:2027:0100` | a real AirPod's own record | ACK, no readings | +| `004C:0000:0000` | Android's spoof (vendor only) | ACK, no readings | +| `004C:7805:1A50` | **an iPhone's exact record** | ACK, no readings | + +The channel connects identically in all three, and HR stays blocked in all three. +`0x7805` means "Apple host", not specifically "iPhone". + +**macOS — whose DID is byte-identical to an iPhone's — behaves the same.** A Mac +presents the exact `004C:7805:1A50` record (all 54 Host-Identification bytes match +an iPhone). Opening the AAP channel from a Mac (user-space IOBluetooth, no driver) +and replaying the same enable gets the same `4a 02 08 13` ACK and **zero** readings +(see `extras/macos-hr-probe/` on the `macos-hr-probe` branch). So a host already +carrying the iPhone DID, on a completely different OS, is blocked too — identity is +not the gate. + +**What actually separates the working hosts from us is privilege / owning the host +session:** + +- **iPhone** — is the buds' real primary host *and* runs Apple's own on-device + software (the iPhone computes BPM from the raw PPG + motion stream; see Apple + support HT `123184`). It works because it is the privileged system host, not + merely because it "is an iPhone". +- **Android (the working LibrePods)** — is *not* an ordinary app. It ships a Magisk + module (a privileged system app) and reaches the channel by reflection *inside* + the open Bluetooth stack (Fluoride). It effectively **becomes the host** from + within the stack. +- **Windows / macOS (us)** — open a **second, unprivileged AAP session** while the + OS stays the buds' real host. The buds ACK, then withhold the biometric stream. + +Seen this way the gate is **consistent on every platform: the raw AAP biometric +stream goes only to the privileged, primary-host system software.** On iOS that is +Apple's own stack — it receives the raw PPG, computes the BPM, and exposes the +*result* through **HealthKit**, a permission-gated system API. Third-party iOS apps +(Strava, etc.) read that already-computed value **from HealthKit**; they never open +the AAP biometric channel themselves. So iOS apps aren't "beating" the gate — they +consume the value over the sanctioned, system-mediated path, one step removed from +the buds. + +What LibrePods does on Windows/macOS is the **direct** path — a secondary AAP +session asking the buds for the stream — and that is exactly what the buds withhold +from a non-primary host. Windows has **neither** option Apple's platform offers: it +can't be the primary-host system (`bthport` is closed — no in-stack hook point like +Fluoride's, so LibrePods can only run a secondary AAP session via our profile driver +alongside the OS's real host relationship, never inside it), and there is no +HealthKit-equivalent system component ingesting the AAP biometric stream for us to +read a computed value from. So the request goes through, the AirPods acknowledge it, +and they return no readings. + +## What the toggle does + +**Settings ▸ Experimental ▸ "Show heart-rate monitoring (experimental)"** only +un-hides the heart-rate card on the device page. It does **not** make the feature +work. Expect the card to stay empty. It's kept for the day the Apple-host gate +can actually be bypassed on Windows. + +## References + +- `windows/drivers/aap/L2cap.c` — the driver runs the AAP channel + in L2CAP **Basic** mode (the ERTM/encryption experiments are documented in the + comments there). +- `windows/daemon/src/main.rs` — `hr_retry_campaign` / the HR + enable + start sequence and constants (`HR_START_SEQ`, `HR_INIT_QUIET_MS`, …). +- `extras/macos-hr-probe/` (branch `macos-hr-probe`) — the independent macOS + user-space probe that reaches the same conclusion from a host whose Device ID is + byte-identical to an iPhone's (buds ACK `4a 02 08 13`, then send nothing). diff --git a/windows/docs/hires-mic/PLAN.md b/windows/docs/hires-mic/PLAN.md new file mode 100644 index 000000000..9504c0960 --- /dev/null +++ b/windows/docs/hires-mic/PLAN.md @@ -0,0 +1,118 @@ +# Hi-res AirPods microphone on Windows — feature branch + +**Branch:** `windows-hires-mic` (off `cross-platform`; merges back into it when done). + +**Goal:** expose the AirPods' hi-res (AAC-ELD) microphone as a **native Windows +microphone**, so any app (Teams, Zoom, Discord, OBS…) can use it — the same +feature the Linux side is adding in +[PR #655](https://github.com/librepods-org/librepods/pull/655), but with a +self-contained **virtual audio driver** instead of PipeWire. + +## Why a driver (not VB-Cable) + +Windows has no API to "create a virtual microphone" — it needs a virtual audio +device driver. We already ship a signed kernel driver (`LibrePodsAAP`), so a +second one is the clean, dependency-free path (no third-party VB-Cable). Base it +on Microsoft's **SYSVAD** sample (a virtual audio device with capture + render +endpoints, no hardware). NOTE: audio drivers use **PortCls/AVStream** or the +newer **ACX** framework — different from the KMDF+BRB approach of `LibrePodsAAP`. + +## Architecture + +``` +AirPods ──AAP / L2CAP──▶ LibrePodsAAP driver ──IOCTL──▶ app + │ decode AAC-ELD (FFmpeg / libavcodec) + │ → PCM + ▼ + LibrePodsMic virtual audio driver + │ + ▼ + Windows sees a "LibrePods Microphone" + (Teams / Zoom / Discord / OBS / …) +``` + +The protocol (enable-mic control command, uplink packet framing, AAC-ELD params) +is platform-neutral and comes from PR #655 — it lands in the shared +`app` crate (`aacp`/`media_controller`), gated per platform for +the sink. + +## Phases (incremental, each independently testable) + +1. **Virtual-mic driver base** — build + test-sign + install SYSVAD (or a trimmed + fork) so Windows shows a "LibrePods Microphone" capture endpoint. Prove it + appears in Sound settings and apps. *(driver: `windows/drivers/mic/`)* +2. **PCM bridge** — an IOCTL/shared-ring for user mode to push PCM samples into + the driver; feed a test tone / a WAV → verify it's audible on the virtual mic + (record in Voice Recorder / Audacity). +3. **Protocol port** — from PR #655: the AAP command that enables the hi-res mic, + the uplink packet framing + watchdog, and AAC-ELD decoding via FFmpeg + (libavcodec) — in the shared crate, `platform::MicSink` for the OS sink. +4. **Integration** — talk into the AirPods → decoded audio reaches the virtual + mic. Conversation-awareness pause during capture, AGC toggle, settings + persistence (mirror #655). + +## Risks / open questions + +- **Audio driver complexity** — WaveRT buffering, formats, timing. SYSVAD is a + large sample; getting a stable capture endpoint is the bulk of the work. +- **AAC-ELD patents** — the decoder (FFmpeg) is patent-encumbered; distribution + implications (PR #655 raises the same). Keep the decoder optional / documented. +- **Latency** — L2CAP → decode → driver ring; needs a small, steady buffer. +- **Test-signing** — same Test Mode requirement as the AAP driver. + +## References + +- Microsoft SYSVAD (virtual audio device sample), and the ACX audio samples. +- LibrePods PR #655 (Linux hi-res mic: AAC-ELD + PipeWire) — the protocol RE. +- `../windows/drivers/aap/` — our existing driver + build/sign/install loop. + +## Status + +- **Phase 1 — DONE** ✅ (`windows/drivers/mic/`, based on the MS ACX + AudioCodec sample). Builds with WDK 28000, installs via `install.ps1`, and + Windows shows a virtual **"Microphone (AudioCodec Device)"** (confirmed on + hardware). The audio source is still the sample's dummy; feeding real audio is + next. +- **Phase 1b — DONE** ✅ (`522f9e8`): trimmed to **capture-only** — dropped the + render (speaker) circuit in `Device.cpp` (create/add/remove) so only a mic + endpoint exists (no phantom speaker grabbing default output). Builds clean. + `install.ps1` now `devcon remove`s any prior device before installing so + re-running updates in place. +- **Phase 2 — DONE** ✅ (`87ed69f`, validated on hardware): `Common/MicPipe.{h,cpp}` + — a spin-locked global PCM ring buffer + a control device `\\.\LibrePodsMic` + exposing `IOCTL_LIBREPODS_MIC_WRITE_PCM` (0x0022A000). `StreamEngine.cpp` + `ProcessPacket` drains the ring instead of the WAV/tone dummy. Proven end to + end: `lp-mic-test` (a user-mode tone feeder, `windows/tools/mic-test/`) + pushed a 440 Hz sine and it was **recorded and audible** on "Microphone + (AudioCodec Device)". The audio-driver de-risk is complete. +- **Phase 3a — DONE** ✅ (`c843d32`, validated on hardware): the AAP enable + command works. The tray's "Hi-res microphone (test)" toggle sends `START_AUDIO` + (`04 00 04 00 58 …`, from PR #655); the AirPods enter mic mode (A2DP playback + drops to right-only mono, as expected — needs an A2DP reset like #655), and the + receive loop confirmed the **0x58 uplink audio packets flow** ("receiving + audio" card). Constants + `is_audio_packet` in `aap.rs`; protocol in + [[hires-mic-protocol]]. +- **Phase 3b — DONE** ✅ (validated on hardware, user's voice recorded clean & + in tune). The tray decodes the 0x58 AUs (AAC-ELD) via an FFmpeg libavcodec + shim (LGPL, `eld_shim.c`), resamples, and streams to `\\.\LibrePodsMic`. Key + fixes: mic frames are **480 samples @ 64 kHz** (not 48 kHz — the 4-byte ASC + lies; confirmed by the +180/AU timestamp = a 24 kHz clock over 7.5 ms frames), + so resample **64000 → 48000**; capture circuit restricted to **48 kHz only**; + and a **~150 ms silence cushion** on the ring to absorb the bursty per-packet + feed. Pitch went ~105 → ~122 Hz (ref ~148), zero click/gap artifacts. Audio is + a bit quiet (a gain stage would help) and mono (single mic capsule). +- **Phase 4 — essentially COMPLETE** ✅ (plug-and-play). Done: + - **A2DP auto-reset** — toggle the AirPods' AudioSink (0x110B) service to + restore stereo after the mic stops (0x110D gave ERROR 1060; found via + BluetoothEnumerateInstalledServices), with a persistent "Restoring stereo…" + card through the reconnect. + - **Auto-enable** — the driver's capture-activity counter + (IOCTL_LIBREPODS_MIC_STATUS) lets the tray auto-start the hi-res stream when + an app records and auto-stop (debounced) when it finishes. + a manual mode. + - **Make-up gain** (×3, tanh soft-limit) so the mic isn't quiet. + - **Minimal FFmpeg** (7.1, aac-only) — avcodec 69 MB → 0.7 MB. + - **Name** "LibrePods" (device-agnostic). + - Single-instance guard. + - Follow-ups: exact per-device dynamic name (IPolicyConfig, needs a debugger), + VendorID spoofing (Apple DID), 2 s stall watchdog, apple-wireshark RE. +- Phase 3 (protocol from PR #655 + AAC-ELD) and Phase 4 (integration) to follow. diff --git a/windows/drivers/aap/.gitignore b/windows/drivers/aap/.gitignore new file mode 100644 index 000000000..3caa04417 --- /dev/null +++ b/windows/drivers/aap/.gitignore @@ -0,0 +1,16 @@ +# Build outputs +x64/ +ARM64/ +Debug/ +Release/ +package/ +*.sys +*.cat +*.pdb +*.obj +*.log +*.tlog +*.lastbuildstate +*.cache +mkcat.cmd +cat.cmd diff --git a/windows/drivers/aap/Device.c b/windows/drivers/aap/Device.c new file mode 100755 index 000000000..ae730472b --- /dev/null +++ b/windows/drivers/aap/Device.c @@ -0,0 +1,100 @@ +/*++ + Device.c - PnP: obtain the Bluetooth profile driver interface + I/O target + from our BTHENUM parent, and tear down the connection on removal. +--*/ + +#include "LibrePodsAAP.h" + +NTSTATUS +LpEvtDevicePrepareHardware( + _In_ WDFDEVICE Device, + _In_ WDFCMRESLIST ResourcesRaw, + _In_ WDFCMRESLIST ResourcesTranslated +) +{ + NTSTATUS status; + PDEVICE_CONTEXT ctx; + + UNREFERENCED_PARAMETER(ResourcesRaw); + UNREFERENCED_PARAMETER(ResourcesTranslated); + + ctx = DeviceGetContext(Device); + + // Default I/O target = our parent = the Bluetooth stack. BRBs submitted here + // reach bthport (this is exactly what the Root\ install of other drivers + // gets wrong). + ctx->IoTarget = WdfDeviceGetIoTarget(Device); + + // Ask the BTHENUM parent for BthAllocateBrb/BthFreeBrb/... Because we are a + // real profile driver bound to the AAP service PDO, this succeeds. + ctx->BthInterface.Interface.Size = sizeof(BTH_PROFILE_DRIVER_INTERFACE); + ctx->BthInterface.Interface.Version = BTHDDI_PROFILE_DRIVER_INTERFACE_VERSION_FOR_QI; + + status = WdfFdoQueryForInterface( + Device, + &GUID_BTHDDI_PROFILE_DRIVER_INTERFACE, + (PINTERFACE)&ctx->BthInterface.Interface, + sizeof(BTH_PROFILE_DRIVER_INTERFACE), + BTHDDI_PROFILE_DRIVER_INTERFACE_VERSION_FOR_QI, + NULL); + + if (!NT_SUCCESS(status)) { + KdPrint(("LibrePodsAAP: QueryInterface(PROFILE_DRIVER_INTERFACE) failed 0x%08X\n", status)); + ctx->HasBthInterface = FALSE; + return status; // fatal: without it we cannot allocate/submit BRBs + } + + ctx->HasBthInterface = TRUE; + KdPrint(("LibrePodsAAP: acquired BTH profile interface\n")); + + // NB: the ATT (PSM 0x001F) server is registered later, from LpConnect, once we + // know the AirPods' address (registering with BtAddress=0 here returned + // STATUS_INVALID_PARAMETER 0xC000000D). + + return STATUS_SUCCESS; +} + +NTSTATUS +LpEvtDeviceReleaseHardware( + _In_ WDFDEVICE Device, + _In_ WDFCMRESLIST ResourcesTranslated +) +{ + PDEVICE_CONTEXT ctx = DeviceGetContext(Device); + UNREFERENCED_PARAMETER(ResourcesTranslated); + + if (ctx->State != LpDisconnected) { + LpDisconnect(ctx); + } + + // Close the accepted ATT channel, then unregister the server, before dropping + // the interface (both use it). + LpCloseAttChannel(ctx); + LpUnregisterAttServer(ctx); + + // Release the Bluetooth profile driver interface we took in + // PrepareHardware. WdfFdoQueryForInterface increments the interface's + // reference count; not dereferencing it leaks a reference to our BTHENUM + // parent, so the device never tears down cleanly — the old driver instance + // stays resident and the next load fails with Code 38 ("a previous instance + // is still in memory"), which the AirPods reconnect can't recover without a + // reboot. Dereferencing here lets it unload and rebind on every reconnect. + if (ctx->HasBthInterface) { + ctx->BthInterface.Interface.InterfaceDereference( + ctx->BthInterface.Interface.Context); + ctx->HasBthInterface = FALSE; + } + return STATUS_SUCCESS; +} + +VOID +LpEvtDeviceContextCleanup( + _In_ WDFOBJECT Object +) +{ + PDEVICE_CONTEXT ctx = DeviceGetContext((WDFDEVICE)Object); + + if (ctx->State != LpDisconnected) { + LpDisconnect(ctx); + } +} diff --git a/windows/drivers/aap/Driver.c b/windows/drivers/aap/Driver.c new file mode 100755 index 000000000..a7dfada23 --- /dev/null +++ b/windows/drivers/aap/Driver.c @@ -0,0 +1,129 @@ +/*++ + Driver.c - driver entry and device creation. +--*/ + +// INITGUID (via initguid.h) must precede the headers so DEFINE_GUID emits the +// GUID *data* here (exactly one TU). Other .c files get extern declarations. +#include +#include "LibrePodsAAP.h" + +NTSTATUS +DriverEntry( + _In_ PDRIVER_OBJECT DriverObject, + _In_ PUNICODE_STRING RegistryPath +) +{ + WDF_DRIVER_CONFIG config; + NTSTATUS status; + + KdPrint(("LibrePodsAAP: DriverEntry\n")); + + WDF_DRIVER_CONFIG_INIT(&config, LpEvtDeviceAdd); + + status = WdfDriverCreate( + DriverObject, + RegistryPath, + WDF_NO_OBJECT_ATTRIBUTES, + &config, + WDF_NO_HANDLE + ); + + if (!NT_SUCCESS(status)) { + KdPrint(("LibrePodsAAP: WdfDriverCreate failed 0x%08X\n", status)); + } + return status; +} + +NTSTATUS +LpEvtDeviceAdd( + _In_ WDFDRIVER Driver, + _Inout_ PWDFDEVICE_INIT DeviceInit +) +{ + NTSTATUS status; + WDF_PNPPOWER_EVENT_CALLBACKS pnpPower; + WDF_OBJECT_ATTRIBUTES deviceAttrs; + WDF_FILEOBJECT_CONFIG fileConfig; + WDFDEVICE device; + PDEVICE_CONTEXT ctx; + WDF_IO_QUEUE_CONFIG queueConfig; + + UNREFERENCED_PARAMETER(Driver); + + KdPrint(("LibrePodsAAP: EvtDeviceAdd\n")); + + // We are the function driver for the AAP service PDO exposed by BTHENUM. + WDF_PNPPOWER_EVENT_CALLBACKS_INIT(&pnpPower); + pnpPower.EvtDevicePrepareHardware = LpEvtDevicePrepareHardware; + pnpPower.EvtDeviceReleaseHardware = LpEvtDeviceReleaseHardware; + WdfDeviceInitSetPnpPowerEventCallbacks(DeviceInit, &pnpPower); + + // Track the user-mode handle lifetime so we can release the L2CAP channel + // when the app exits. Only EvtFileClose is needed (create/cleanup default). + // AutoForwardCleanupClose = WdfFalse: these CREATE/CLOSE IRPs belong to our + // user-mode device interface, not the Bluetooth stack below us, so WDF must + // complete them here rather than forwarding them down to bthport. + WDF_FILEOBJECT_CONFIG_INIT( + &fileConfig, WDF_NO_EVENT_CALLBACK, LpEvtFileClose, WDF_NO_EVENT_CALLBACK); + fileConfig.AutoForwardCleanupClose = WdfFalse; + WdfDeviceInitSetFileObjectConfig(DeviceInit, &fileConfig, WDF_NO_OBJECT_ATTRIBUTES); + + WdfDeviceInitSetDeviceType(DeviceInit, FILE_DEVICE_BLUETOOTH); + WdfDeviceInitSetExclusive(DeviceInit, TRUE); + + WDF_OBJECT_ATTRIBUTES_INIT_CONTEXT_TYPE(&deviceAttrs, DEVICE_CONTEXT); + deviceAttrs.EvtCleanupCallback = LpEvtDeviceContextCleanup; + + status = WdfDeviceCreate(&DeviceInit, &deviceAttrs, &device); + if (!NT_SUCCESS(status)) { + KdPrint(("LibrePodsAAP: WdfDeviceCreate failed 0x%08X\n", status)); + return status; + } + + ctx = DeviceGetContext(device); + RtlZeroMemory(ctx, sizeof(*ctx)); + ctx->State = LpDisconnected; + ctx->AttAcceptStatus = STATUS_PENDING; // 0x00000103 = accept not yet attempted + ctx->AttRegisterStatus = STATUS_PENDING; // 0x00000103 = register not yet attempted + ctx->WdmDeviceObject = WdfDeviceWdmGetDeviceObject(device); + + status = WdfSpinLockCreate(WDF_NO_OBJECT_ATTRIBUTES, &ctx->Lock); + if (!NT_SUCCESS(status)) { + KdPrint(("LibrePodsAAP: WdfSpinLockCreate failed 0x%08X\n", status)); + return status; + } + + // Work item that accepts the AirPods' inbound ATT (PSM 0x001F) connection at + // PASSIVE_LEVEL (the connect indication may run at DISPATCH_LEVEL). + { + WDF_WORKITEM_CONFIG wiConfig; + WDF_OBJECT_ATTRIBUTES wiAttrs; + WDF_WORKITEM_CONFIG_INIT(&wiConfig, LpAttAcceptWorkItem); + WDF_OBJECT_ATTRIBUTES_INIT(&wiAttrs); + wiAttrs.ParentObject = device; + status = WdfWorkItemCreate(&wiConfig, &wiAttrs, &ctx->AttAcceptWorkItem); + if (!NT_SUCCESS(status)) { + KdPrint(("LibrePodsAAP: WdfWorkItemCreate failed 0x%08X\n", status)); + return status; + } + } + + // Single sequential IOCTL queue (connect/send/receive are serialized). + WDF_IO_QUEUE_CONFIG_INIT_DEFAULT_QUEUE(&queueConfig, WdfIoQueueDispatchSequential); + queueConfig.EvtIoDeviceControl = LpEvtIoDeviceControl; + + status = WdfIoQueueCreate(device, &queueConfig, WDF_NO_OBJECT_ATTRIBUTES, WDF_NO_HANDLE); + if (!NT_SUCCESS(status)) { + KdPrint(("LibrePodsAAP: WdfIoQueueCreate failed 0x%08X\n", status)); + return status; + } + + status = WdfDeviceCreateDeviceInterface(device, &GUID_DEVINTERFACE_LIBREPODSAAP, NULL); + if (!NT_SUCCESS(status)) { + KdPrint(("LibrePodsAAP: CreateDeviceInterface failed 0x%08X\n", status)); + return status; + } + + KdPrint(("LibrePodsAAP: device created\n")); + return STATUS_SUCCESS; +} diff --git a/windows/drivers/aap/Ioctl.c b/windows/drivers/aap/Ioctl.c new file mode 100755 index 000000000..f6cf427a7 --- /dev/null +++ b/windows/drivers/aap/Ioctl.c @@ -0,0 +1,169 @@ +/*++ + Ioctl.c - user-mode bridge. Translates DeviceIoControl calls from the + LibrePods app into L2CAP operations. +--*/ + +#include "LibrePodsAAP.h" + +// +// The app closed its handle (clean exit OR crash -> the OS closes it for us). +// Release the L2CAP channel so it doesn't leak. LpDisconnect is idempotent and +// runs at PASSIVE_LEVEL, which is where EvtFileClose is called. +// +VOID +LpEvtFileClose( + _In_ WDFFILEOBJECT FileObject +) +{ + PDEVICE_CONTEXT ctx = DeviceGetContext(WdfFileObjectGetDevice(FileObject)); + + if (ctx->State != LpDisconnected) { + KdPrint(("LibrePodsAAP: app handle closed -> releasing L2CAP channel\n")); + LpDisconnect(ctx); + } +} + +VOID +LpEvtIoDeviceControl( + _In_ WDFQUEUE Queue, + _In_ WDFREQUEST Request, + _In_ size_t OutputBufferLength, + _In_ size_t InputBufferLength, + _In_ ULONG IoControlCode +) +{ + NTSTATUS status = STATUS_INVALID_DEVICE_REQUEST; + ULONG_PTR information = 0; + WDFDEVICE device = WdfIoQueueGetDevice(Queue); + PDEVICE_CONTEXT ctx = DeviceGetContext(device); + PVOID inBuf, outBuf; + size_t sz; + + switch (IoControlCode) { + + case IOCTL_LP_CONNECT: { + PLP_CONNECT_INPUT in; + PLP_CONNECT_OUTPUT out; + + if (InputBufferLength < sizeof(LP_CONNECT_INPUT) || + OutputBufferLength < sizeof(LP_CONNECT_OUTPUT)) { + status = STATUS_BUFFER_TOO_SMALL; + break; + } + status = WdfRequestRetrieveInputBuffer(Request, sizeof(LP_CONNECT_INPUT), &inBuf, &sz); + if (!NT_SUCCESS(status)) break; + status = WdfRequestRetrieveOutputBuffer(Request, sizeof(LP_CONNECT_OUTPUT), &outBuf, &sz); + if (!NT_SUCCESS(status)) break; + + in = (PLP_CONNECT_INPUT)inBuf; + out = (PLP_CONNECT_OUTPUT)outBuf; + + status = LpConnect(ctx, in->BluetoothAddress, in->Psm); + out->Status = (LONG)status; + out->Success = NT_SUCCESS(status) ? 1u : 0u; + information = sizeof(LP_CONNECT_OUTPUT); + status = STATUS_SUCCESS; // the connect result is inside the struct + break; + } + + case IOCTL_LP_DISCONNECT: + status = LpDisconnect(ctx); + break; + + case IOCTL_LP_SEND: { + if (InputBufferLength == 0) { + status = STATUS_INVALID_PARAMETER; + break; + } + status = WdfRequestRetrieveInputBuffer(Request, 1, &inBuf, &sz); + if (!NT_SUCCESS(status)) break; + status = LpSend(ctx, inBuf, (ULONG)sz); + break; + } + + case IOCTL_LP_RECEIVE: { + ULONG timeoutMs = 0; + ULONG bytesRead = 0; + + if (InputBufferLength >= sizeof(LP_RECEIVE_INPUT)) { + status = WdfRequestRetrieveInputBuffer(Request, sizeof(LP_RECEIVE_INPUT), &inBuf, &sz); + if (NT_SUCCESS(status)) { + timeoutMs = ((PLP_RECEIVE_INPUT)inBuf)->TimeoutMs; + } + } + if (OutputBufferLength == 0) { + status = STATUS_BUFFER_TOO_SMALL; + break; + } + status = WdfRequestRetrieveOutputBuffer(Request, 1, &outBuf, &sz); + if (!NT_SUCCESS(status)) break; + + status = LpReceive(ctx, outBuf, (ULONG)sz, &bytesRead, timeoutMs); + if (NT_SUCCESS(status)) { + information = bytesRead; + } + break; + } + + case IOCTL_LP_GET_STATUS: { + PLP_STATUS_OUTPUT out; + if (OutputBufferLength < sizeof(LP_STATUS_OUTPUT)) { + status = STATUS_BUFFER_TOO_SMALL; + break; + } + status = WdfRequestRetrieveOutputBuffer(Request, sizeof(LP_STATUS_OUTPUT), &outBuf, &sz); + if (!NT_SUCCESS(status)) break; + + out = (PLP_STATUS_OUTPUT)outBuf; + out->State = (ULONG)ctx->State; + out->ConnectedAddress = ctx->RemoteAddress; + out->AttServerRegistered = ctx->AttServerRegistered ? 1u : 0u; + out->AttIndicationCount = ctx->AttIndicationCount; + out->AttAcceptStatus = ctx->AttAcceptStatus; + out->AttChannelOpen = ctx->AttConnected ? 1u : 0u; + out->AttRegisterStatus = ctx->AttRegisterStatus; + information = sizeof(LP_STATUS_OUTPUT); + break; + } + + case IOCTL_LP_ATT_SEND: { + if (InputBufferLength == 0) { + status = STATUS_INVALID_PARAMETER; + break; + } + status = WdfRequestRetrieveInputBuffer(Request, 1, &inBuf, &sz); + if (!NT_SUCCESS(status)) break; + status = LpAttSend(ctx, inBuf, (ULONG)sz); + break; + } + + case IOCTL_LP_ATT_RECEIVE: { + ULONG timeoutMs = 0; + ULONG bytesRead = 0; + + if (InputBufferLength >= sizeof(LP_RECEIVE_INPUT)) { + status = WdfRequestRetrieveInputBuffer(Request, sizeof(LP_RECEIVE_INPUT), &inBuf, &sz); + if (NT_SUCCESS(status)) { + timeoutMs = ((PLP_RECEIVE_INPUT)inBuf)->TimeoutMs; + } + } + if (OutputBufferLength == 0) { + status = STATUS_BUFFER_TOO_SMALL; + break; + } + status = WdfRequestRetrieveOutputBuffer(Request, 1, &outBuf, &sz); + if (!NT_SUCCESS(status)) break; + + status = LpAttReceive(ctx, outBuf, (ULONG)sz, &bytesRead, timeoutMs); + if (NT_SUCCESS(status)) { + information = bytesRead; + } + break; + } + + default: + break; + } + + WdfRequestCompleteWithInformation(Request, status, information); +} diff --git a/windows/drivers/aap/L2cap.c b/windows/drivers/aap/L2cap.c new file mode 100755 index 000000000..7fdaeb5e6 --- /dev/null +++ b/windows/drivers/aap/L2cap.c @@ -0,0 +1,672 @@ +/*++ + L2cap.c - the L2CAP channel operations, implemented with Bluetooth Request + Blocks (BRBs) submitted to the stack via IOCTL_INTERNAL_BTH_SUBMIT_BRB. +--*/ + +#include "LibrePodsAAP.h" + +// +// Submit a BRB synchronously to the Bluetooth stack (our parent I/O target). +// +NTSTATUS +LpSubmitBrbSync( + _In_ PDEVICE_CONTEXT Ctx, + _Inout_ PBRB Brb +) +{ + NTSTATUS status; + WDFREQUEST request; + PIRP irp; + PIO_STACK_LOCATION stack; + WDF_REQUEST_SEND_OPTIONS options; + + if (Ctx->IoTarget == NULL) { + return STATUS_DEVICE_NOT_READY; + } + + status = WdfRequestCreate(WDF_NO_OBJECT_ATTRIBUTES, Ctx->IoTarget, &request); + if (!NT_SUCCESS(status)) { + return status; + } + + // bthport reads the BRB pointer from Parameters.Others.Argument1 of the + // IOCTL_INTERNAL_BTH_SUBMIT_BRB IRP. Set it explicitly on the next stack + // location (the WDF memory-descriptor path left Argument1 NULL, so bthport + // dereferenced a NULL BRB -> bugcheck 0x3B in BTHport!IOTracing::TraceStart). + irp = WdfRequestWdmGetIrp(request); + stack = IoGetNextIrpStackLocation(irp); + stack->MajorFunction = IRP_MJ_INTERNAL_DEVICE_CONTROL; + stack->MinorFunction = 0; + stack->Parameters.DeviceIoControl.IoControlCode = IOCTL_INTERNAL_BTH_SUBMIT_BRB; + stack->Parameters.Others.Argument1 = Brb; + + WDF_REQUEST_SEND_OPTIONS_INIT( + &options, WDF_REQUEST_SEND_OPTION_SYNCHRONOUS | WDF_REQUEST_SEND_OPTION_TIMEOUT); + WDF_REQUEST_SEND_OPTIONS_SET_TIMEOUT(&options, WDF_REL_TIMEOUT_IN_SEC(10)); + + if (WdfRequestSend(request, Ctx->IoTarget, &options)) { + status = WdfRequestGetStatus(request); + } else { + status = WdfRequestGetStatus(request); + } + + WdfObjectDelete(request); + return status; +} + +// +// Open an outbound L2CAP channel to the remote device on the given PSM. +// +NTSTATUS +LpConnect( + _In_ PDEVICE_CONTEXT Ctx, + _In_ BTH_ADDR Address, + _In_ USHORT Psm +) +{ + NTSTATUS status; + struct _BRB_L2CA_OPEN_CHANNEL* brb; + + if (!Ctx->HasBthInterface) { + return STATUS_DEVICE_NOT_READY; + } + + if (Ctx->State == LpConnected) { + if (Ctx->RemoteAddress == Address && Ctx->Psm == Psm) { + return STATUS_SUCCESS; + } + LpDisconnect(Ctx); + } + + // Basic-mode L2CAP open. ERTM was investigated exhaustively and ruled out: the + // Windows bthport stack does NOT serialize a Retransmission-and-Flow option to + // the wire when a profile driver requests ERTM via BRB_L2CA_OPEN_ENHANCED_CHANNEL + // (CM_RETRANSMISSION_AND_FLOW). Two builds — timeouts 0, then 2000/12000 with + // RtlZeroMemory + MPS>0 — both produced Configure Requests carrying only MTU + + // FlushTO (btvs-confirmed); the ERTM option never left the host. The AirPods run + // AAP over Basic regardless (they counter-propose Basic), as does Android (whose + // ERTM request is likewise rejected and falls back). So ERTM is not the heart- + // rate differentiator, and requesting it just breaks the connect on Windows. + brb = (struct _BRB_L2CA_OPEN_CHANNEL*) + Ctx->BthInterface.BthAllocateBrb(BRB_L2CA_OPEN_CHANNEL, LP_POOL_TAG); + if (brb == NULL) { + return STATUS_INSUFFICIENT_RESOURCES; + } + + brb->BtAddress = Address; + brb->Psm = Psm; + // CF_ROLE_EITHER only. Tried adding CF_LINK_ENCRYPTED (the AACP socket is opened + // auth/encrypt on Android) — it triggers a re-authentication at open time that + // the controller rejects (HCI status 0x27), failing the connect, exactly the + // race a LibrePods dev described. The paired link is already encrypted de-facto + // (battery/ANC work), so requiring it explicitly only breaks the open; it is not + // the heart-rate blocker. + brb->ChannelFlags = CF_ROLE_EITHER; + + // Flags == 0 => let the stack negotiate default MTU/flush/QoS. + brb->ConfigOut.Flags = 0; + brb->ConfigIn.Flags = 0; + brb->IncomingQueueDepth = 10; // MS-recommended default + + // Be notified when the remote tears the channel down. + brb->CallbackFlags = CALLBACK_DISCONNECT; + brb->Callback = LpIndicationCallback; + brb->CallbackContext = Ctx; + brb->ReferenceObject = Ctx->WdmDeviceObject; + + WdfSpinLockAcquire(Ctx->Lock); + Ctx->State = LpConnecting; + Ctx->RemoteAddress = Address; + Ctx->Psm = Psm; + WdfSpinLockRelease(Ctx->Lock); + + status = LpSubmitBrbSync(Ctx, (PBRB)brb); + + if (NT_SUCCESS(status)) { + WdfSpinLockAcquire(Ctx->Lock); + Ctx->ChannelHandle = brb->ChannelHandle; + Ctx->State = LpConnected; + WdfSpinLockRelease(Ctx->Lock); + KdPrint(("LibrePodsAAP: L2CAP connected (handle=%p)\n", brb->ChannelHandle)); + // NB: the ATT (PSM 0x001F) channel is opened LAZILY — on the first hearing-aid + // ATT write (see LpAttSend), NOT here. The buds' ATT server is dormant until + // hearing-assist is enabled, so opening a second L2CAP channel to it on every + // connect only churns the shared ACL and destabilises the AAP channel / A2DP + // (it opened, idled, and dropped ~30 s later on every session). + // + // NB: we deliberately do NOT register an ATT *server* here to accept the + // buds' inbound PSM-0x001F connection. bthport rejects registering a server + // on the reserved ATT PSM with STATUS_INVALID_PARAMETER (0xC000000D) — a + // profile driver may be an ATT client but not a server (tested 2026-08-12). + // So the AirPods' inbound GATT connection is unavoidably refused on Windows. + } else { + WdfSpinLockAcquire(Ctx->Lock); + Ctx->State = LpDisconnected; + Ctx->ChannelHandle = NULL; + WdfSpinLockRelease(Ctx->Lock); + KdPrint(("LibrePodsAAP: L2CAP connect failed 0x%08X\n", status)); + } + + Ctx->BthInterface.BthFreeBrb((PBRB)brb); + return status; +} + +// +// Open the ATT (PSM 0x001F) channel to the AirPods as a CLIENT — the same +// outbound BRB_L2CA_OPEN_CHANNEL we use for the AAP channel (0x1001), just a second +// channel on the reserved ATT PSM. This is exactly what Android's ATTManager does +// (createL2capChannel(0x1F)); connecting as a client to a reserved PSM is allowed, +// unlike registering a SERVER on it. The buds accept it (their end is the ATT +// server) and we then read/write the hearing-aid audiogram over handle 0x2A. +// +NTSTATUS +LpConnectAtt( + _In_ PDEVICE_CONTEXT Ctx +) +{ + NTSTATUS status; + struct _BRB_L2CA_OPEN_CHANNEL* brb; + + if (!Ctx->HasBthInterface) { + return STATUS_DEVICE_NOT_READY; + } + if (Ctx->AttConnected) { + return STATUS_SUCCESS; + } + + brb = (struct _BRB_L2CA_OPEN_CHANNEL*) + Ctx->BthInterface.BthAllocateBrb(BRB_L2CA_OPEN_CHANNEL, LP_POOL_TAG); + if (brb == NULL) { + return STATUS_INSUFFICIENT_RESOURCES; + } + + brb->BtAddress = Ctx->RemoteAddress; + brb->Psm = PSM_ATT; // 0x001F, connect to the buds' ATT server + brb->ChannelFlags = CF_ROLE_EITHER; + brb->ConfigOut.Flags = 0; + brb->ConfigIn.Flags = 0; + brb->IncomingQueueDepth = 10; + brb->CallbackFlags = CALLBACK_DISCONNECT; + brb->Callback = LpAttServerIndication; // reused for the disconnect event + brb->CallbackContext = Ctx; + brb->ReferenceObject = Ctx->WdmDeviceObject; + + status = LpSubmitBrbSync(Ctx, (PBRB)brb); + WdfSpinLockAcquire(Ctx->Lock); + Ctx->AttAcceptStatus = status; // reuse the accept-status field for the open result + if (NT_SUCCESS(status)) { + Ctx->AttChannelHandle = brb->ChannelHandle; + Ctx->AttConnected = TRUE; + } + WdfSpinLockRelease(Ctx->Lock); + if (NT_SUCCESS(status)) { + KdPrint(("LibrePodsAAP: *** ATT client channel OPEN (handle=%p) ***\n", brb->ChannelHandle)); + } else { + KdPrint(("LibrePodsAAP: ATT client open FAILED 0x%08X\n", status)); + } + + Ctx->BthInterface.BthFreeBrb((PBRB)brb); + return status; +} + +// +// Write raw ATT PDU bytes to the ATT (PSM 0x001F) channel. +// +NTSTATUS +LpAttSend( + _In_ PDEVICE_CONTEXT Ctx, + _In_ PVOID Buffer, + _In_ ULONG Length +) +{ + NTSTATUS status; + struct _BRB_L2CA_ACL_TRANSFER* brb; + + // Lazily open the ATT (PSM 0x001F) client channel on first use, instead of on + // every AAP connect: the buds' ATT server is dormant until hearing-assist is + // enabled (the AAP 0x2C/0x33 enable wakes it just before the first write), and + // opening it eagerly churns the shared ACL and destabilises the AAP channel / + // A2DP. Reconnects transparently after an idle close — the remote-disconnect + // indication clears AttConnected. + if (!Ctx->AttConnected) { + (VOID)LpConnectAtt(Ctx); + } + if (!Ctx->AttConnected || Ctx->AttChannelHandle == NULL) { + return STATUS_DEVICE_NOT_CONNECTED; + } + if (Buffer == NULL || Length == 0) { + return STATUS_INVALID_PARAMETER; + } + + brb = (struct _BRB_L2CA_ACL_TRANSFER*) + Ctx->BthInterface.BthAllocateBrb(BRB_L2CA_ACL_TRANSFER, LP_POOL_TAG); + if (brb == NULL) { + return STATUS_INSUFFICIENT_RESOURCES; + } + + brb->BtAddress = Ctx->RemoteAddress; + brb->ChannelHandle = Ctx->AttChannelHandle; + brb->TransferFlags = ACL_TRANSFER_DIRECTION_OUT; + brb->Buffer = Buffer; + brb->BufferMDL = NULL; + brb->BufferSize = Length; + brb->Timeout = 0; + + status = LpSubmitBrbSync(Ctx, (PBRB)brb); + Ctx->BthInterface.BthFreeBrb((PBRB)brb); + return status; +} + +// +// Read raw ATT PDU bytes from the ATT channel (blocking up to TimeoutMs). +// +NTSTATUS +LpAttReceive( + _In_ PDEVICE_CONTEXT Ctx, + _Out_ PVOID Buffer, + _In_ ULONG BufferLen, + _Out_ PULONG BytesRead, + _In_ ULONG TimeoutMs +) +{ + NTSTATUS status; + struct _BRB_L2CA_ACL_TRANSFER* brb; + + *BytesRead = 0; + + if (!Ctx->AttConnected || Ctx->AttChannelHandle == NULL) { + return STATUS_DEVICE_NOT_CONNECTED; + } + if (Buffer == NULL || BufferLen == 0) { + return STATUS_INVALID_PARAMETER; + } + + brb = (struct _BRB_L2CA_ACL_TRANSFER*) + Ctx->BthInterface.BthAllocateBrb(BRB_L2CA_ACL_TRANSFER, LP_POOL_TAG); + if (brb == NULL) { + return STATUS_INSUFFICIENT_RESOURCES; + } + + brb->BtAddress = Ctx->RemoteAddress; + brb->ChannelHandle = Ctx->AttChannelHandle; + brb->TransferFlags = ACL_TRANSFER_DIRECTION_IN | ACL_SHORT_TRANSFER_OK | ACL_TRANSFER_TIMEOUT; + brb->Buffer = Buffer; + brb->BufferMDL = NULL; + brb->BufferSize = BufferLen; + brb->Timeout = (LONGLONG)(TimeoutMs ? TimeoutMs : LP_RECV_TIMEOUT_MS); + + status = LpSubmitBrbSync(Ctx, (PBRB)brb); + if (NT_SUCCESS(status)) { + *BytesRead = brb->BufferSize; + } + + Ctx->BthInterface.BthFreeBrb((PBRB)brb); + return status; +} + +// +// Close the channel (best-effort). +// +NTSTATUS +LpDisconnect( + _In_ PDEVICE_CONTEXT Ctx +) +{ + struct _BRB_L2CA_CLOSE_CHANNEL* brb; + L2CAP_CHANNEL_HANDLE handle; + BTH_ADDR addr; + + WdfSpinLockAcquire(Ctx->Lock); + handle = Ctx->ChannelHandle; + addr = Ctx->RemoteAddress; + Ctx->State = LpDisconnected; + Ctx->ChannelHandle = NULL; + WdfSpinLockRelease(Ctx->Lock); + + if (handle == NULL || !Ctx->HasBthInterface) { + return STATUS_SUCCESS; + } + + brb = (struct _BRB_L2CA_CLOSE_CHANNEL*) + Ctx->BthInterface.BthAllocateBrb(BRB_L2CA_CLOSE_CHANNEL, LP_POOL_TAG); + if (brb == NULL) { + return STATUS_INSUFFICIENT_RESOURCES; + } + + brb->BtAddress = addr; + brb->ChannelHandle = handle; + (VOID)LpSubmitBrbSync(Ctx, (PBRB)brb); + Ctx->BthInterface.BthFreeBrb((PBRB)brb); + + KdPrint(("LibrePodsAAP: disconnected\n")); + return STATUS_SUCCESS; +} + +// +// Write bytes to the channel. +// +NTSTATUS +LpSend( + _In_ PDEVICE_CONTEXT Ctx, + _In_ PVOID Buffer, + _In_ ULONG Length +) +{ + NTSTATUS status; + struct _BRB_L2CA_ACL_TRANSFER* brb; + + if (Ctx->State != LpConnected || Ctx->ChannelHandle == NULL) { + return STATUS_DEVICE_NOT_CONNECTED; + } + if (Buffer == NULL || Length == 0) { + return STATUS_INVALID_PARAMETER; + } + + brb = (struct _BRB_L2CA_ACL_TRANSFER*) + Ctx->BthInterface.BthAllocateBrb(BRB_L2CA_ACL_TRANSFER, LP_POOL_TAG); + if (brb == NULL) { + return STATUS_INSUFFICIENT_RESOURCES; + } + + brb->BtAddress = Ctx->RemoteAddress; + brb->ChannelHandle = Ctx->ChannelHandle; + brb->TransferFlags = ACL_TRANSFER_DIRECTION_OUT; + brb->Buffer = Buffer; + brb->BufferMDL = NULL; + brb->BufferSize = Length; + brb->Timeout = 0; + + status = LpSubmitBrbSync(Ctx, (PBRB)brb); + Ctx->BthInterface.BthFreeBrb((PBRB)brb); + return status; +} + +// +// Read bytes from the channel (blocking up to TimeoutMs). +// +NTSTATUS +LpReceive( + _In_ PDEVICE_CONTEXT Ctx, + _Out_ PVOID Buffer, + _In_ ULONG BufferLen, + _Out_ PULONG BytesRead, + _In_ ULONG TimeoutMs +) +{ + NTSTATUS status; + struct _BRB_L2CA_ACL_TRANSFER* brb; + + *BytesRead = 0; + + if (Ctx->State != LpConnected || Ctx->ChannelHandle == NULL) { + return STATUS_DEVICE_NOT_CONNECTED; + } + if (Buffer == NULL || BufferLen == 0) { + return STATUS_INVALID_PARAMETER; + } + + brb = (struct _BRB_L2CA_ACL_TRANSFER*) + Ctx->BthInterface.BthAllocateBrb(BRB_L2CA_ACL_TRANSFER, LP_POOL_TAG); + if (brb == NULL) { + return STATUS_INSUFFICIENT_RESOURCES; + } + + brb->BtAddress = Ctx->RemoteAddress; + brb->ChannelHandle = Ctx->ChannelHandle; + brb->TransferFlags = ACL_TRANSFER_DIRECTION_IN | ACL_SHORT_TRANSFER_OK | ACL_TRANSFER_TIMEOUT; + brb->Buffer = Buffer; + brb->BufferMDL = NULL; + brb->BufferSize = BufferLen; + brb->Timeout = (LONGLONG)(TimeoutMs ? TimeoutMs : LP_RECV_TIMEOUT_MS); + + status = LpSubmitBrbSync(Ctx, (PBRB)brb); + if (NT_SUCCESS(status)) { + *BytesRead = brb->BufferSize; // updated with bytes actually read + } + + Ctx->BthInterface.BthFreeBrb((PBRB)brb); + return status; +} + +// +// Notifications from the stack (we only care about remote disconnect). +// +VOID +LpIndicationCallback( + _In_opt_ PVOID Context, + _In_ INDICATION_CODE Indication, + _In_ PINDICATION_PARAMETERS Parameters +) +{ + PDEVICE_CONTEXT ctx = (PDEVICE_CONTEXT)Context; + + UNREFERENCED_PARAMETER(Parameters); + + if (ctx == NULL) { + return; + } + + switch (Indication) { + case IndicationRemoteDisconnect: + WdfSpinLockAcquire(ctx->Lock); + ctx->State = LpDisconnected; + ctx->ChannelHandle = NULL; + WdfSpinLockRelease(ctx->Lock); + KdPrint(("LibrePodsAAP: remote disconnected the channel\n")); + break; + default: + break; + } +} + +// +// Register an L2CAP server on PSM 0x001F (ATT). The AirPods connect INBOUND here +// for hearing-aid configuration; without a registered server bthport answers their +// Connection Request with "PSM not supported" and the config channel never opens. +// STEP 1: register + log the connect indication (proves the path). Accepting the +// channel (BRB_L2CA_OPEN_CHANNEL_RESPONSE) is Step 2. Non-fatal to the AAP channel. +// +NTSTATUS +LpRegisterAttServer( + _In_ PDEVICE_CONTEXT Ctx +) +{ + NTSTATUS status; + struct _BRB_L2CA_REGISTER_SERVER* brb; + + if (!Ctx->HasBthInterface) { + return STATUS_DEVICE_NOT_READY; + } + if (Ctx->AttServerRegistered) { + return STATUS_SUCCESS; + } + + brb = (struct _BRB_L2CA_REGISTER_SERVER*) + Ctx->BthInterface.BthAllocateBrb(BRB_L2CA_REGISTER_SERVER, LP_POOL_TAG); + if (brb == NULL) { + return STATUS_INSUFFICIENT_RESOURCES; + } + + brb->BtAddress = Ctx->RemoteAddress; // the connected AirPods + brb->PSM = PSM_ATT; + brb->IndicationFlags = 0; + brb->IndicationCallback = LpAttServerIndication; + brb->IndicationCallbackContext = Ctx; + brb->ReferenceObject = Ctx->WdmDeviceObject; + + status = LpSubmitBrbSync(Ctx, (PBRB)brb); + Ctx->AttRegisterStatus = status; // surfaced via IOCTL_LP_GET_STATUS + if (NT_SUCCESS(status)) { + Ctx->AttServerHandle = brb->ServerHandle; + Ctx->AttServerRegistered = TRUE; + KdPrint(("LibrePodsAAP: ATT server registered on PSM 0x%04X\n", PSM_ATT)); + } else { + KdPrint(("LibrePodsAAP: ATT server register FAILED 0x%08X\n", status)); + } + + Ctx->BthInterface.BthFreeBrb((PBRB)brb); + return status; +} + +// +// Unregister the ATT server (on device removal). Best-effort. +// +VOID +LpUnregisterAttServer( + _In_ PDEVICE_CONTEXT Ctx +) +{ + struct _BRB_L2CA_UNREGISTER_SERVER* brb; + + if (!Ctx->AttServerRegistered || !Ctx->HasBthInterface) { + return; + } + + brb = (struct _BRB_L2CA_UNREGISTER_SERVER*) + Ctx->BthInterface.BthAllocateBrb(BRB_L2CA_UNREGISTER_SERVER, LP_POOL_TAG); + if (brb != NULL) { + brb->BtAddress = 0; + brb->ServerHandle = Ctx->AttServerHandle; + brb->Psm = PSM_ATT; + (VOID)LpSubmitBrbSync(Ctx, (PBRB)brb); + Ctx->BthInterface.BthFreeBrb((PBRB)brb); + } + Ctx->AttServerRegistered = FALSE; + KdPrint(("LibrePodsAAP: ATT server unregistered\n")); +} + +// +// Server indication: bthport calls this when the AirPods connect to our PSM 0x001F +// server (and, once accepted, on the channel's disconnect). The connect can arrive +// at DISPATCH_LEVEL, so we stash the params and defer the accept to a work item. +// +VOID +LpAttServerIndication( + _In_opt_ PVOID Context, + _In_ INDICATION_CODE Indication, + _In_ PINDICATION_PARAMETERS Parameters +) +{ + PDEVICE_CONTEXT ctx = (PDEVICE_CONTEXT)Context; + + if (ctx == NULL) { + return; + } + + switch (Indication) { + case IndicationRemoteConnect: + KdPrint(("LibrePodsAAP: *** ATT connect indication from 0x%012I64X on PSM 0x001F " + "-- accepting ***\n", Parameters->BtAddress)); + WdfSpinLockAcquire(ctx->Lock); + ctx->PendingAttConn = Parameters->ConnectionHandle; + ctx->PendingAttAddr = Parameters->BtAddress; + ctx->AttIndicationCount++; + WdfSpinLockRelease(ctx->Lock); + WdfWorkItemEnqueue(ctx->AttAcceptWorkItem); + break; + case IndicationRemoteDisconnect: + WdfSpinLockAcquire(ctx->Lock); + ctx->AttConnected = FALSE; + ctx->AttChannelHandle = NULL; + WdfSpinLockRelease(ctx->Lock); + KdPrint(("LibrePodsAAP: ATT channel disconnected by remote\n")); + break; + default: + break; + } +} + +// +// Deferred accept (PASSIVE_LEVEL): respond SUCCESS to the AirPods' inbound ATT +// connect, opening the channel we bridge the hearing-aid config over. +// +VOID +LpAttAcceptWorkItem( + _In_ WDFWORKITEM WorkItem +) +{ + PDEVICE_CONTEXT ctx; + NTSTATUS status; + struct _BRB_L2CA_OPEN_CHANNEL* brb; + L2CAP_CHANNEL_HANDLE conn; + BTH_ADDR addr; + + ctx = DeviceGetContext((WDFDEVICE)WdfWorkItemGetParentObject(WorkItem)); + + WdfSpinLockAcquire(ctx->Lock); + conn = ctx->PendingAttConn; + addr = ctx->PendingAttAddr; + WdfSpinLockRelease(ctx->Lock); + + if (!ctx->HasBthInterface || conn == NULL) { + return; + } + + brb = (struct _BRB_L2CA_OPEN_CHANNEL*) + ctx->BthInterface.BthAllocateBrb(BRB_L2CA_OPEN_CHANNEL_RESPONSE, LP_POOL_TAG); + if (brb == NULL) { + return; + } + + brb->ChannelHandle = conn; // from the connect indication + brb->Response = CONNECT_RSP_RESULT_SUCCESS; // accept + brb->ChannelFlags = CF_ROLE_EITHER; + brb->BtAddress = addr; + brb->ConfigOut.Flags = 0; + brb->ConfigIn.Flags = 0; + brb->IncomingQueueDepth = 10; + brb->CallbackFlags = CALLBACK_DISCONNECT; + brb->Callback = LpAttServerIndication; // reused for the channel's disconnect + brb->CallbackContext = ctx; + brb->ReferenceObject = ctx->WdmDeviceObject; + + status = LpSubmitBrbSync(ctx, (PBRB)brb); + WdfSpinLockAcquire(ctx->Lock); + ctx->AttAcceptStatus = status; // surfaced via IOCTL_LP_GET_STATUS + if (NT_SUCCESS(status)) { + ctx->AttChannelHandle = brb->ChannelHandle; + ctx->AttConnected = TRUE; + } + WdfSpinLockRelease(ctx->Lock); + if (NT_SUCCESS(status)) { + KdPrint(("LibrePodsAAP: *** ATT channel ACCEPTED (handle=%p) ***\n", brb->ChannelHandle)); + } else { + KdPrint(("LibrePodsAAP: ATT accept FAILED 0x%08X\n", status)); + } + + ctx->BthInterface.BthFreeBrb((PBRB)brb); +} + +// +// Close the accepted ATT channel (best-effort, on device removal). +// +VOID +LpCloseAttChannel( + _In_ PDEVICE_CONTEXT Ctx +) +{ + struct _BRB_L2CA_CLOSE_CHANNEL* brb; + L2CAP_CHANNEL_HANDLE handle; + BTH_ADDR addr; + + WdfSpinLockAcquire(Ctx->Lock); + handle = Ctx->AttChannelHandle; + addr = Ctx->PendingAttAddr; + Ctx->AttConnected = FALSE; + Ctx->AttChannelHandle = NULL; + WdfSpinLockRelease(Ctx->Lock); + + if (handle == NULL || !Ctx->HasBthInterface) { + return; + } + + brb = (struct _BRB_L2CA_CLOSE_CHANNEL*) + Ctx->BthInterface.BthAllocateBrb(BRB_L2CA_CLOSE_CHANNEL, LP_POOL_TAG); + if (brb != NULL) { + brb->BtAddress = addr; + brb->ChannelHandle = handle; + (VOID)LpSubmitBrbSync(Ctx, (PBRB)brb); + Ctx->BthInterface.BthFreeBrb((PBRB)brb); + } + KdPrint(("LibrePodsAAP: ATT channel closed\n")); +} diff --git a/windows/drivers/aap/LibrePodsAAP.h b/windows/drivers/aap/LibrePodsAAP.h new file mode 100755 index 000000000..06e114708 --- /dev/null +++ b/windows/drivers/aap/LibrePodsAAP.h @@ -0,0 +1,182 @@ +/*++ + LibrePodsAAP - open-source KMDF Bluetooth L2CAP profile driver for the + Apple Accessory Protocol (AAP) on Windows. + + Binds to the AAP SDP service the AirPods advertise + (BTHENUM\{74ec2172-0bad-4d01-8f77-997b2be0722a}), opens an L2CAP channel to + PSM 0x1001 in kernel mode (which user-mode Winsock cannot), and bridges it + to user space via DeviceIoControl. The LibrePods app talks to this driver + through the IOCTL contract below. + + Architecture reference: Microsoft bthecho sample + nefarius/BthPS3. + License: same as LibrePods. +--*/ + +#ifndef _LIBREPODSAAP_H_ +#define _LIBREPODSAAP_H_ + +#include +#include +// Order matters: bthdef/bthguid define the types bthddi.h consumes. +#include +#include +#include +#include // IOCTL_INTERNAL_BTH_SUBMIT_BRB + +#define LP_POOL_TAG 'PbiL' // "LibP" +#define LP_RECV_TIMEOUT_MS 5000 +// PSM_ATT (0x001F) — the AirPods open THIS to us (inbound) for hearing-aid config. +// Already defined by the WDK's bthdef.h, so we just use that. + +// +// User-mode device interface: the LibrePods app enumerates this GUID to find +// the driver, then opens it and issues the IOCTLs below. +// {C0FFEE00-1337-4A5B-9E6F-A1B2C3D4E5F6} +// +DEFINE_GUID(GUID_DEVINTERFACE_LIBREPODSAAP, + 0xc0ffee00, 0x1337, 0x4a5b, 0x9e, 0x6f, 0xa1, 0xb2, 0xc3, 0xd4, 0xe5, 0xf6); + +#define FILE_DEVICE_LIBREPODS 0x8000 +#define IOCTL_LP_CONNECT CTL_CODE(FILE_DEVICE_LIBREPODS, 0x800, METHOD_BUFFERED, FILE_ANY_ACCESS) +#define IOCTL_LP_DISCONNECT CTL_CODE(FILE_DEVICE_LIBREPODS, 0x801, METHOD_BUFFERED, FILE_ANY_ACCESS) +#define IOCTL_LP_SEND CTL_CODE(FILE_DEVICE_LIBREPODS, 0x802, METHOD_BUFFERED, FILE_ANY_ACCESS) +#define IOCTL_LP_RECEIVE CTL_CODE(FILE_DEVICE_LIBREPODS, 0x803, METHOD_BUFFERED, FILE_ANY_ACCESS) +#define IOCTL_LP_GET_STATUS CTL_CODE(FILE_DEVICE_LIBREPODS, 0x804, METHOD_BUFFERED, FILE_ANY_ACCESS) +// ATT (PSM 0x001F) channel I/O — raw ATT PDUs to/from the AirPods' hearing-aid GATT. +#define IOCTL_LP_ATT_SEND CTL_CODE(FILE_DEVICE_LIBREPODS, 0x805, METHOD_BUFFERED, FILE_ANY_ACCESS) +#define IOCTL_LP_ATT_RECEIVE CTL_CODE(FILE_DEVICE_LIBREPODS, 0x806, METHOD_BUFFERED, FILE_ANY_ACCESS) + +typedef enum _LP_STATE { + LpDisconnected = 0, + LpConnecting = 1, + LpConnected = 2 +} LP_STATE; + +// +// IOCTL payload layout (packed so it matches the Rust transport byte-for-byte). +// +#include + +typedef struct _LP_CONNECT_INPUT { + ULONGLONG BluetoothAddress; // 48-bit BTH_ADDR of the AirPods + USHORT Psm; // 0x1001 for AAP +} LP_CONNECT_INPUT, *PLP_CONNECT_INPUT; + +typedef struct _LP_CONNECT_OUTPUT { + ULONG Success; // non-zero on success + LONG Status; // NTSTATUS from the open-channel BRB +} LP_CONNECT_OUTPUT, *PLP_CONNECT_OUTPUT; + +typedef struct _LP_RECEIVE_INPUT { + ULONG TimeoutMs; // 0 => LP_RECV_TIMEOUT_MS +} LP_RECEIVE_INPUT, *PLP_RECEIVE_INPUT; + +typedef struct _LP_STATUS_OUTPUT { + ULONG State; // LP_STATE + ULONGLONG ConnectedAddress; + // ATT (PSM 0x001F) server diagnostics — so user mode can see the hearing-aid + // channel progress without a kernel debugger (DebugView is unreliable here). + ULONG AttServerRegistered; // 0/1 + ULONG AttIndicationCount; // # of inbound ATT connect indications seen + LONG AttAcceptStatus; // NTSTATUS of the last accept (0x00000103 = not tried) + ULONG AttChannelOpen; // 0/1 (the accepted ATT channel is up) + LONG AttRegisterStatus; // NTSTATUS of the server register (0x00000103 = not tried) +} LP_STATUS_OUTPUT, *PLP_STATUS_OUTPUT; + +#include + +// IOCTL_LP_SEND: input buffer = raw AAP bytes to write to the L2CAP channel. +// IOCTL_LP_RECEIVE: output buffer = raw AAP bytes read; Information = byte count. + +// +// Per-device state. One device = one AAP service PDO = one AirPods. +// +typedef struct _DEVICE_CONTEXT { + // Bluetooth profile driver interface (BthAllocateBrb/BthFreeBrb/...), + // obtained from the BTHENUM parent via QueryInterface. + BTH_PROFILE_DRIVER_INTERFACE BthInterface; + BOOLEAN HasBthInterface; + + // I/O target used to submit BRBs to the Bluetooth stack (our parent). + WDFIOTARGET IoTarget; + + // WDM device object, passed as BRB ReferenceObject when a callback is set. + PDEVICE_OBJECT WdmDeviceObject; + + // L2CAP connection state. + WDFSPINLOCK Lock; + LP_STATE State; + BTH_ADDR RemoteAddress; + USHORT Psm; + L2CAP_CHANNEL_HANDLE ChannelHandle; + + // ATT (PSM 0x001F) server. The AirPods don't wait for us to connect — right + // after a hearing-aid enable they open an L2CAP channel INBOUND to this PSM, + // which bthport refuses ("PSM not supported") unless we register a server. + L2CAP_SERVER_HANDLE AttServerHandle; + BOOLEAN AttServerRegistered; + + // The connect indication can fire at DISPATCH_LEVEL, where we cannot make the + // blocking BRB submit that accepts the channel. So we stash the connect params + // and defer the accept (BRB_L2CA_OPEN_CHANNEL_RESPONSE) to this PASSIVE-level + // work item. + WDFWORKITEM AttAcceptWorkItem; + L2CAP_CHANNEL_HANDLE PendingAttConn; // connection handle from the indication + BTH_ADDR PendingAttAddr; + L2CAP_CHANNEL_HANDLE AttChannelHandle; // the accepted ATT channel + BOOLEAN AttConnected; + ULONG AttIndicationCount; // # inbound ATT connect indications + LONG AttAcceptStatus; // NTSTATUS of the last accept attempt + LONG AttRegisterStatus; // NTSTATUS of the server register +} DEVICE_CONTEXT, *PDEVICE_CONTEXT; + +WDF_DECLARE_CONTEXT_TYPE_WITH_NAME(DEVICE_CONTEXT, DeviceGetContext) + +// +// Callbacks / helpers, split across Driver.c / Device.c / L2cap.c / Ioctl.c. +// +DRIVER_INITIALIZE DriverEntry; +EVT_WDF_DRIVER_DEVICE_ADD LpEvtDeviceAdd; + +EVT_WDF_DEVICE_PREPARE_HARDWARE LpEvtDevicePrepareHardware; +EVT_WDF_DEVICE_RELEASE_HARDWARE LpEvtDeviceReleaseHardware; +EVT_WDF_OBJECT_CONTEXT_CLEANUP LpEvtDeviceContextCleanup; + +EVT_WDF_IO_QUEUE_IO_DEVICE_CONTROL LpEvtIoDeviceControl; + +// Fires when the LibrePods app closes its handle to our device interface -> we +// tear down the L2CAP channel so it doesn't leak (which left Windows unable to +// disconnect the AirPods until a manual disconnect/reboot). +EVT_WDF_FILE_CLOSE LpEvtFileClose; + +// L2cap.c +NTSTATUS LpSubmitBrbSync(_In_ PDEVICE_CONTEXT Ctx, _Inout_ PBRB Brb); +NTSTATUS LpConnect(_In_ PDEVICE_CONTEXT Ctx, _In_ BTH_ADDR Address, _In_ USHORT Psm); +NTSTATUS LpDisconnect(_In_ PDEVICE_CONTEXT Ctx); +NTSTATUS LpSend(_In_ PDEVICE_CONTEXT Ctx, _In_reads_bytes_(Length) PVOID Buffer, _In_ ULONG Length); +NTSTATUS LpReceive(_In_ PDEVICE_CONTEXT Ctx, _Out_writes_bytes_(BufferLen) PVOID Buffer, + _In_ ULONG BufferLen, _Out_ PULONG BytesRead, _In_ ULONG TimeoutMs); + +_Function_class_(PFNBTHPORT_INDICATION_CALLBACK) +VOID LpIndicationCallback(_In_opt_ PVOID Context, _In_ INDICATION_CODE Indication, + _In_ PINDICATION_PARAMETERS Parameters); + +// ATT (PSM 0x001F) — we OPEN it as a CLIENT to the AirPods (like Android's +// ATTManager), which sidesteps the bthport rule that forbids registering a SERVER +// on the reserved ATT PSM. LpConnectAtt opens it; LpCloseAttChannel tears it down. +// (The server-register path below is kept but unused — it returned 0xC000000D.) +NTSTATUS LpConnectAtt(_In_ PDEVICE_CONTEXT Ctx); +NTSTATUS LpAttSend(_In_ PDEVICE_CONTEXT Ctx, _In_reads_bytes_(Length) PVOID Buffer, _In_ ULONG Length); +NTSTATUS LpAttReceive(_In_ PDEVICE_CONTEXT Ctx, _Out_writes_bytes_(BufferLen) PVOID Buffer, + _In_ ULONG BufferLen, _Out_ PULONG BytesRead, _In_ ULONG TimeoutMs); +NTSTATUS LpRegisterAttServer(_In_ PDEVICE_CONTEXT Ctx); +VOID LpUnregisterAttServer(_In_ PDEVICE_CONTEXT Ctx); +VOID LpCloseAttChannel(_In_ PDEVICE_CONTEXT Ctx); + +_Function_class_(PFNBTHPORT_INDICATION_CALLBACK) +VOID LpAttServerIndication(_In_opt_ PVOID Context, _In_ INDICATION_CODE Indication, + _In_ PINDICATION_PARAMETERS Parameters); + +EVT_WDF_WORKITEM LpAttAcceptWorkItem; + +#endif // _LIBREPODSAAP_H_ diff --git a/windows/drivers/aap/LibrePodsAAP.inf b/windows/drivers/aap/LibrePodsAAP.inf new file mode 100755 index 000000000..4e1687e28 --- /dev/null +++ b/windows/drivers/aap/LibrePodsAAP.inf @@ -0,0 +1,61 @@ +;/*++ +; LibrePodsAAP.inf +; +; Installs the LibrePods AAP L2CAP profile driver as the function driver for the +; Apple Accessory Protocol service the AirPods advertise: +; BTHENUM\{74ec2172-0bad-4d01-8f77-997b2be0722a} +;--*/ + +[Version] +Signature = "$WINDOWS NT$" +Class = Bluetooth +ClassGuid = {e0cbf06c-cd8b-4647-bb8a-263b43f0f974} +Provider = %ProviderString% +CatalogFile = LibrePodsAAP.cat +DriverVer = 08/08/2026,1.0.4.0 +PnpLockdown = 1 + +[DestinationDirs] +DefaultDestDir = 13 + +[SourceDisksNames] +1 = %DiskId1%,,,"" + +[SourceDisksFiles] +LibrePodsAAP.sys = 1,, + +[Manufacturer] +%ManufacturerString% = LibrePodsAAP,NTamd64.10.0.1..16299 + +[LibrePodsAAP.NTamd64.10.0.1..16299] +%DeviceDesc% = LibrePodsAAP_Inst, BTHENUM\{74ec2172-0bad-4d01-8f77-997b2be0722a} + +[LibrePodsAAP_Inst.NT] +CopyFiles = DriverStore_Dir + +[DriverStore_Dir] +LibrePodsAAP.sys + +[LibrePodsAAP_Inst.NT.Services] +AddService = LibrePodsAAP,%SPSVCINST_ASSOCSERVICE%, LibrePodsAAP_Service_Inst + +[LibrePodsAAP_Service_Inst] +DisplayName = %ServiceDesc% +ServiceType = 1 ; SERVICE_KERNEL_DRIVER +StartType = 3 ; SERVICE_DEMAND_START +ErrorControl = 1 ; SERVICE_ERROR_NORMAL +ServiceBinary = %13%\LibrePodsAAP.sys + +[LibrePodsAAP_Inst.NT.Wdf] +KmdfService = LibrePodsAAP, LibrePodsAAP_wdfsect + +[LibrePodsAAP_wdfsect] +KmdfLibraryVersion = 1.15 + +[Strings] +SPSVCINST_ASSOCSERVICE = 0x00000002 +ProviderString = "LibrePods" +ManufacturerString = "LibrePods" +DiskId1 = "LibrePodsAAP Installation Disk" +DeviceDesc = "LibrePods AAP (AirPods)" +ServiceDesc = "LibrePods AAP L2CAP driver for AirPods" diff --git a/windows/drivers/aap/LibrePodsAAP.vcxproj b/windows/drivers/aap/LibrePodsAAP.vcxproj new file mode 100755 index 000000000..e8537a552 --- /dev/null +++ b/windows/drivers/aap/LibrePodsAAP.vcxproj @@ -0,0 +1,103 @@ + + + + + Debug + x64 + + + Release + x64 + + + + + {B2C3D4E5-F6A7-4B5C-8D9E-0F1A2B3C4D5E} + {1bc93793-1164-437d-8b74-0f9a4e06f6e9} + v4.5 + 12.0 + Release + x64 + LibrePodsAAP + $(LatestTargetPlatformVersion) + + + + + + Windows10 + true + WindowsKernelModeDriver10.0 + Driver + KMDF + Universal + Unicode + false + Off + + + + Windows10 + false + WindowsKernelModeDriver10.0 + Driver + KMDF + Universal + Unicode + false + Off + + + + + + + + + + $(SolutionDir)$(Platform)\$(Configuration)\ + $(Platform)\$(Configuration)\ + + + $(SolutionDir)$(Platform)\$(Configuration)\ + $(Platform)\$(Configuration)\ + + + + + Level3 + false + Disabled + _DEBUG;_AMD64_;_KERNEL_MODE;%(PreprocessorDefinitions) + + + * + + + + + + Level3 + false + NDEBUG;_AMD64_;_KERNEL_MODE;%(PreprocessorDefinitions) + + + * + + + + + + + + + + + + + + + + + + diff --git a/windows/drivers/aap/README.md b/windows/drivers/aap/README.md new file mode 100644 index 000000000..766ea9d12 --- /dev/null +++ b/windows/drivers/aap/README.md @@ -0,0 +1,77 @@ +# LibrePodsAAP — Windows AAP L2CAP driver + +Open-source KMDF Bluetooth **profile driver** that lets Windows talk to AirPods +over Apple's Accessory Protocol (AAP). AAP runs on a classic-Bluetooth **L2CAP +channel at PSM `0x1001`**, which Windows user-mode Winsock cannot open (only +RFCOMM is exposed; raw L2CAP `connect()` fails with `WSAENETDOWN`). This driver +opens that channel in kernel mode and bridges it to user space via +`DeviceIoControl`, so the LibrePods app can read battery, toggle ANC, etc. + +## How it binds (the key trick) + +AirPods advertise an SDP service with UUID `{74ec2172-0bad-4d01-8f77-997b2be0722a}` +(the same one LibrePods uses on Linux). Windows enumerates a devnode +`BTHENUM\{74ec2172-...}_VID&0001004c_PID&2027` for it. Our INF matches that +hardware ID, so Windows loads this driver as the **function driver for the AAP +service PDO** — with the Bluetooth stack as its parent I/O target. From there we +`BthAllocateBrb` + submit `BRB_L2CA_OPEN_CHANNEL` to open PSM `0x1001` outbound. +No filter driver is needed (unlike PS3/BthPS3), because AirPods advertise the +service. Architecture reference: MS `bthecho` sample + `nefarius/BthPS3`. + +## Files + +| File | Role | +|------|------| +| `LibrePodsAAP.h` | IOCTL contract, device context, prototypes | +| `Driver.c` | `DriverEntry`, device creation, IOCTL queue | +| `Device.c` | PnP: query `BTH_PROFILE_DRIVER_INTERFACE`, get I/O target | +| `L2cap.c` | connect / disconnect / send / receive via BRBs | +| `Ioctl.c` | user-mode bridge (DeviceIoControl → L2CAP) | +| `LibrePodsAAP.inf` | binds to the AAP service, installs KMDF service | +| `LibrePodsAAP.vcxproj` | KMDF x64 project | + +## IOCTL contract (for the user-mode transport) + +Device interface GUID `{C0FFEE00-1337-4A5B-9E6F-A1B2C3D4E5F6}`, `FILE_DEVICE 0x8000`: +`CONNECT 0x800 {u64 addr; u16 psm}`, `DISCONNECT 0x801`, `SEND 0x802` (raw bytes), +`RECEIVE 0x803` (raw bytes out), `GET_STATUS 0x804`. + +## Build + +Needs VS2022/2026 with the C++ workload + Windows SDK/WDK (matching build numbers, +e.g. 28000). From a Developer prompt: + +``` +msbuild LibrePodsAAP.vcxproj /p:Configuration=Release /p:Platform=x64 +``` + +Then generate the catalog from a folder holding `LibrePodsAAP.sys` + `.inf`: + +``` +inf2cat /driver: /os:10_X64 +``` + +## Install (test-signed — requires Test Mode) + +The driver is not attestation-signed, so Windows must be in test mode. **Advanced +users only; back up your BitLocker recovery key and make a restore point first.** + +```powershell +# 1. test cert + sign +$c = New-SelfSignedCertificate -Type CodeSigningCert -Subject "CN=LibrePods Test" -CertStoreLocation Cert:\LocalMachine\My +signtool sign /fd SHA256 /sha1 $c.Thumbprint LibrePodsAAP.sys +signtool sign /fd SHA256 /sha1 $c.Thumbprint librepodsaap.cat +# 2. trust the cert (Trusted Root + Trusted Publishers, LocalMachine) +# 3. enable test signing, disable Secure Boot in firmware, reboot +bcdedit /set testsigning on +# 4. install (binds to the AirPods AAP devnode) +pnputil /add-driver LibrePodsAAP.inf /install +``` + +Uninstall: `pnputil /delete-driver LibrePodsAAP.inf /uninstall`, then +`bcdedit /set testsigning off` and re-enable Secure Boot. + +## Status + +Compiles, links, and passes the inf2cat signability test. On-hardware +integration testing (open channel + AAP battery/ANC round-trip) is the next step. diff --git a/windows/drivers/aap/install.ps1 b/windows/drivers/aap/install.ps1 new file mode 100644 index 000000000..04a41fd34 --- /dev/null +++ b/windows/drivers/aap/install.ps1 @@ -0,0 +1,69 @@ +<# + install.ps1 - test-sign, trust and install the LibrePodsAAP driver. + + RUN AS ADMINISTRATOR, and only AFTER you have: + 1. Backed up your BitLocker recovery key. + 2. Created a system restore point. + 3. Disabled Secure Boot in your firmware/BIOS. + 4. Enabled test signing: bcdedit /set testsigning on (then rebooted). + + Pass the folder that holds LibrePodsAAP.sys + LibrePodsAAP.inf (+ .cat). + Example: .\install.ps1 -PackageDir "C:\Users\Pedro Lopes\LibrePodsAAP\package" +#> +param( + [Parameter(Mandatory = $true)] + [string]$PackageDir +) + +$ErrorActionPreference = 'Stop' +$sys = Join-Path $PackageDir 'LibrePodsAAP.sys' +$cat = Join-Path $PackageDir 'librepodsaap.cat' +$inf = Join-Path $PackageDir 'LibrePodsAAP.inf' +foreach ($f in @($sys, $cat, $inf)) { + if (-not (Test-Path $f)) { throw "Missing $f" } +} + +Write-Host "==> Creating test code-signing certificate..." +$cert = New-SelfSignedCertificate -Type CodeSigningCert ` + -Subject "CN=LibrePods Test Cert" ` + -CertStoreLocation Cert:\LocalMachine\My ` + -KeyUsage DigitalSignature -KeyExportPolicy Exportable + +Write-Host "==> Trusting the cert (Root + TrustedPublisher, LocalMachine)..." +$store = Get-Item "Cert:\LocalMachine\My\$($cert.Thumbprint)" +foreach ($name in 'Root', 'TrustedPublisher') { + $s = New-Object System.Security.Cryptography.X509Certificates.X509Store($name, 'LocalMachine') + $s.Open('ReadWrite'); $s.Add($store); $s.Close() +} + +$signtool = (Get-ChildItem "C:\Program Files (x86)\Windows Kits\10\bin" -Recurse -Filter signtool.exe | + Where-Object { $_.FullName -match 'x64' } | Select-Object -First 1).FullName +Write-Host "==> Signing with $signtool" +& $signtool sign /v /fd SHA256 /sm /s My /sha1 $cert.Thumbprint $sys + +# Regenerate the catalog over the *signed* .sys so its hash matches (signing the +# .sys changes the file), then sign the catalog. +Write-Host "==> Regenerating catalog over the signed .sys..." +$inf2cat = (Get-ChildItem "C:\Program Files (x86)\Windows Kits\10\bin" -Recurse -Filter inf2cat.exe | + Where-Object { $_.FullName -match '\\x86\\' } | Sort-Object FullName | Select-Object -Last 1).FullName +& $inf2cat /driver:$PackageDir /os:10_X64 +if ($LASTEXITCODE -ne 0) { throw "inf2cat failed ($LASTEXITCODE)" } + +& $signtool sign /v /fd SHA256 /sm /s My /sha1 $cert.Thumbprint $cat + +Write-Host "==> Removing any previously installed LibrePodsAAP package..." +$oem = $null +pnputil /enum-drivers | ForEach-Object { + if ($_ -match 'Published Name\s*:\s*(oem\d+\.inf)') { $oem = $matches[1] } + if ($_ -match 'Original Name\s*:\s*LibrePodsAAP\.inf' -and $oem) { + Write-Host " deleting $oem" + pnputil /delete-driver $oem /uninstall /force | Out-Null + } +} + +Write-Host "==> Installing driver package..." +pnputil /add-driver $inf /install + +Write-Host "`n==> Done. Check binding with:" +Write-Host ' pnputil /enum-devices /class Bluetooth' +Write-Host ' (look for the {74ec2172-...} AAP service now driven by LibrePodsAAP)' diff --git a/windows/drivers/aap/prebuilt/LibrePodsAAP.inf b/windows/drivers/aap/prebuilt/LibrePodsAAP.inf new file mode 100755 index 000000000..66e61df0c --- /dev/null +++ b/windows/drivers/aap/prebuilt/LibrePodsAAP.inf @@ -0,0 +1,61 @@ +;/*++ +; LibrePodsAAP.inf +; +; Installs the LibrePods AAP L2CAP profile driver as the function driver for the +; Apple Accessory Protocol service the AirPods advertise: +; BTHENUM\{74ec2172-0bad-4d01-8f77-997b2be0722a} +;--*/ + +[Version] +Signature = "$WINDOWS NT$" +Class = Bluetooth +ClassGuid = {e0cbf06c-cd8b-4647-bb8a-263b43f0f974} +Provider = %ProviderString% +CatalogFile = LibrePodsAAP.cat +DriverVer = 01/01/2026,1.0.0.0 +PnpLockdown = 1 + +[DestinationDirs] +DefaultDestDir = 13 + +[SourceDisksNames] +1 = %DiskId1%,,,"" + +[SourceDisksFiles] +LibrePodsAAP.sys = 1,, + +[Manufacturer] +%ManufacturerString% = LibrePodsAAP,NTamd64.10.0.1..16299 + +[LibrePodsAAP.NTamd64.10.0.1..16299] +%DeviceDesc% = LibrePodsAAP_Inst, BTHENUM\{74ec2172-0bad-4d01-8f77-997b2be0722a} + +[LibrePodsAAP_Inst.NT] +CopyFiles = DriverStore_Dir + +[DriverStore_Dir] +LibrePodsAAP.sys + +[LibrePodsAAP_Inst.NT.Services] +AddService = LibrePodsAAP,%SPSVCINST_ASSOCSERVICE%, LibrePodsAAP_Service_Inst + +[LibrePodsAAP_Service_Inst] +DisplayName = %ServiceDesc% +ServiceType = 1 ; SERVICE_KERNEL_DRIVER +StartType = 3 ; SERVICE_DEMAND_START +ErrorControl = 1 ; SERVICE_ERROR_NORMAL +ServiceBinary = %13%\LibrePodsAAP.sys + +[LibrePodsAAP_Inst.NT.Wdf] +KmdfService = LibrePodsAAP, LibrePodsAAP_wdfsect + +[LibrePodsAAP_wdfsect] +KmdfLibraryVersion = 1.15 + +[Strings] +SPSVCINST_ASSOCSERVICE = 0x00000002 +ProviderString = "LibrePods" +ManufacturerString = "LibrePods" +DiskId1 = "LibrePodsAAP Installation Disk" +DeviceDesc = "LibrePods AAP (AirPods)" +ServiceDesc = "LibrePods AAP L2CAP driver for AirPods" diff --git a/windows/drivers/aap/prebuilt/LibrePodsAAP.sys b/windows/drivers/aap/prebuilt/LibrePodsAAP.sys new file mode 100755 index 000000000..a9dbe0f51 Binary files /dev/null and b/windows/drivers/aap/prebuilt/LibrePodsAAP.sys differ diff --git a/windows/drivers/aap/prebuilt/README.md b/windows/drivers/aap/prebuilt/README.md new file mode 100644 index 000000000..2e8f21a90 --- /dev/null +++ b/windows/drivers/aap/prebuilt/README.md @@ -0,0 +1,13 @@ +# Prebuilt LibrePodsAAP driver package + +The compiled driver (`LibrePodsAAP.sys` + `.inf` + `.cat`) so you can install +**without building it** — no Visual Studio / C++ / WDK required. + +Install (admin PowerShell, Test Mode — see ../../windows/README.md): +```powershell +& "..\install.ps1" -PackageDir ".\" +``` +`install.ps1` creates a test certificate, signs these files, trusts the cert +and installs the driver. Then run the app (`librepods-tray` or `librepods-ui`). + +The app binaries are pure Rust `.exe`s and never need C++ either. diff --git a/windows/drivers/aap/prebuilt/librepodsaap.cat b/windows/drivers/aap/prebuilt/librepodsaap.cat new file mode 100755 index 000000000..c87a209b4 Binary files /dev/null and b/windows/drivers/aap/prebuilt/librepodsaap.cat differ diff --git a/windows/drivers/mic/AudioCodec/Driver/AudioCodec.inf b/windows/drivers/mic/AudioCodec/Driver/AudioCodec.inf new file mode 100644 index 000000000..cf2c276d4 Binary files /dev/null and b/windows/drivers/mic/AudioCodec/Driver/AudioCodec.inf differ diff --git a/windows/drivers/mic/AudioCodec/Driver/AudioCodec.sln b/windows/drivers/mic/AudioCodec/Driver/AudioCodec.sln new file mode 100644 index 000000000..8e953b60e --- /dev/null +++ b/windows/drivers/mic/AudioCodec/Driver/AudioCodec.sln @@ -0,0 +1,60 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 16 +VisualStudioVersion = 16.0.31409.214 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "SamplesCommon", "SamplesCommon", "{0DF6D5E6-3B78-414B-B37B-85D9CA454487}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "AudioCodec", "AudioCodec", "{0D3B6287-146E-4700-B2EE-11D520014FEA}" +EndProject +Project("{8BC9CEB8-8B4A-11D0-8D11-00A0C91BC942}") = "SamplesCommon", "..\..\Common\SamplesCommon.vcxproj", "{6A946D59-6690-4ED0-A77D-7D4C3D4B3241}" +EndProject +Project("{8BC9CEB8-8B4A-11D0-8D11-00A0C91BC942}") = "AudioCodec", "AudioCodec.vcxproj", "{C575002D-5FDE-43C7-AB19-EE846A20083A}" + ProjectSection(ProjectDependencies) = postProject + {6A946D59-6690-4ED0-A77D-7D4C3D4B3241} = {6A946D59-6690-4ED0-A77D-7D4C3D4B3241} + EndProjectSection +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|ARM64 = Debug|ARM64 + Debug|x64 = Debug|x64 + Release|ARM64 = Release|ARM64 + Release|x64 = Release|x64 + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {C575002D-5FDE-43C7-AB19-EE846A20083A}.Debug|ARM64.ActiveCfg = Debug|ARM64 + {C575002D-5FDE-43C7-AB19-EE846A20083A}.Debug|ARM64.Build.0 = Debug|ARM64 + {C575002D-5FDE-43C7-AB19-EE846A20083A}.Debug|ARM64.Deploy.0 = Debug|ARM64 + {C575002D-5FDE-43C7-AB19-EE846A20083A}.Debug|x64.ActiveCfg = Debug|x64 + {C575002D-5FDE-43C7-AB19-EE846A20083A}.Debug|x64.Build.0 = Debug|x64 + {C575002D-5FDE-43C7-AB19-EE846A20083A}.Debug|x64.Deploy.0 = Debug|x64 + {C575002D-5FDE-43C7-AB19-EE846A20083A}.Release|ARM64.ActiveCfg = Release|ARM64 + {C575002D-5FDE-43C7-AB19-EE846A20083A}.Release|ARM64.Build.0 = Release|ARM64 + {C575002D-5FDE-43C7-AB19-EE846A20083A}.Release|ARM64.Deploy.0 = Release|ARM64 + {C575002D-5FDE-43C7-AB19-EE846A20083A}.Release|x64.ActiveCfg = Release|x64 + {C575002D-5FDE-43C7-AB19-EE846A20083A}.Release|x64.Build.0 = Release|x64 + {C575002D-5FDE-43C7-AB19-EE846A20083A}.Release|x64.Deploy.0 = Release|x64 + {6A946D59-6690-4ED0-A77D-7D4C3D4B3241}.Debug|ARM64.ActiveCfg = Debug|ARM64 + {6A946D59-6690-4ED0-A77D-7D4C3D4B3241}.Debug|ARM64.Build.0 = Debug|ARM64 + {6A946D59-6690-4ED0-A77D-7D4C3D4B3241}.Debug|ARM64.Deploy.0 = Debug|ARM64 + {6A946D59-6690-4ED0-A77D-7D4C3D4B3241}.Debug|x64.ActiveCfg = Debug|x64 + {6A946D59-6690-4ED0-A77D-7D4C3D4B3241}.Debug|x64.Build.0 = Debug|x64 + {6A946D59-6690-4ED0-A77D-7D4C3D4B3241}.Debug|x64.Deploy.0 = Debug|x64 + {6A946D59-6690-4ED0-A77D-7D4C3D4B3241}.Release|ARM64.ActiveCfg = Release|ARM64 + {6A946D59-6690-4ED0-A77D-7D4C3D4B3241}.Release|ARM64.Build.0 = Release|ARM64 + {6A946D59-6690-4ED0-A77D-7D4C3D4B3241}.Release|ARM64.Deploy.0 = Release|ARM64 + {6A946D59-6690-4ED0-A77D-7D4C3D4B3241}.Release|x64.ActiveCfg = Release|x64 + {6A946D59-6690-4ED0-A77D-7D4C3D4B3241}.Release|x64.Build.0 = Release|x64 + {6A946D59-6690-4ED0-A77D-7D4C3D4B3241}.Release|x64.Deploy.0 = Release|x64 + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(NestedProjects) = preSolution + {6A946D59-6690-4ED0-A77D-7D4C3D4B3241} = {0DF6D5E6-3B78-414B-B37B-85D9CA454487} + {C575002D-5FDE-43C7-AB19-EE846A20083A} = {0D3B6287-146E-4700-B2EE-11D520014FEA} + EndGlobalSection + GlobalSection(ExtensibilityGlobals) = postSolution + SolutionGuid = {8E3043F2-1C76-4A4A-9C60-A645BD6CBF24} + EndGlobalSection +EndGlobal diff --git a/windows/drivers/mic/AudioCodec/Driver/AudioCodec.vcxproj b/windows/drivers/mic/AudioCodec/Driver/AudioCodec.vcxproj new file mode 100644 index 000000000..e4370b5d7 --- /dev/null +++ b/windows/drivers/mic/AudioCodec/Driver/AudioCodec.vcxproj @@ -0,0 +1,174 @@ + + + + + Debug + x64 + + + Release + x64 + + + Debug + ARM64 + + + Release + ARM64 + + + + + + + + + + + + + + + + + 1 + 1 + 1 + 31 + {C575002D-5FDE-43C7-AB19-EE846A20083A} + {497e31cb-056b-4f31-abb8-447fd55ee5a5} + v4.5 + 12.0 + Debug + AudioCodec + $(LatestTargetPlatformVersion) + + + + Windows10 + true + WindowsKernelModeDriver10.0 + Driver + KMDF + Windows Driver + + + Windows10 + false + WindowsKernelModeDriver10.0 + Driver + KMDF + Windows Driver + + + Windows10 + true + WindowsKernelModeDriver10.0 + Driver + KMDF + Windows Driver + + + Windows10 + false + WindowsKernelModeDriver10.0 + Driver + KMDF + Windows Driver + + + + + + + + + + + DbgengKernelDebugger + $(IntDir) + + + DbgengKernelDebugger + $(IntDir) + + + DbgengKernelDebugger + $(IntDir) + + + DbgengKernelDebugger + $(IntDir) + + + + true + true + ..\..\common\trace_macros.h + true + $(SDK_INC_PATH);$(DDK_INC_PATH)\acx\km\$(ACX_VERSION_MAJOR).$(ACX_VERSION_MINOR);..\..\common;..\..\inc;..\..\shared;%(AdditionalIncludeDirectories) + ACX_VERSION_MAJOR=1;ACX_VERSION_MINOR=1;KMDF_VERSION_MAJOR=1;KMDF_VERSION_MINOR=31;%(PreprocessorDefinitions) + + + sha256 + + + $(DDK_LIB_PATH)\libcntpr.lib;wpprecorder.lib;acx\km\$(ACX_VERSION_MAJOR).$(ACX_VERSION_MINOR)\acxstub.lib;..\..\Common\$(IntDir)\SamplesCommon.lib;%(AdditionalDependencies) + + + + + true + true + ..\..\common\trace_macros.h + true + $(SDK_INC_PATH);$(DDK_INC_PATH)\acx\km\$(ACX_VERSION_MAJOR).$(ACX_VERSION_MINOR);..\..\common;..\..\inc;..\..\shared;%(AdditionalIncludeDirectories) + ACX_VERSION_MAJOR=1;ACX_VERSION_MINOR=1;KMDF_VERSION_MAJOR=1;KMDF_VERSION_MINOR=31;%(PreprocessorDefinitions) + + + sha256 + + + $(DDK_LIB_PATH)\libcntpr.lib;wpprecorder.lib;acx\km\$(ACX_VERSION_MAJOR).$(ACX_VERSION_MINOR)\acxstub.lib;..\..\Common\$(IntDir)\SamplesCommon.lib;%(AdditionalDependencies) + + + + + true + true + ..\..\common\trace_macros.h + true + $(SDK_INC_PATH);$(DDK_INC_PATH)\acx\km\$(ACX_VERSION_MAJOR).$(ACX_VERSION_MINOR);..\..\common;..\..\inc;..\..\shared;%(AdditionalIncludeDirectories) + ACX_VERSION_MAJOR=1;ACX_VERSION_MINOR=1;KMDF_VERSION_MAJOR=1;KMDF_VERSION_MINOR=31;%(PreprocessorDefinitions) + + + sha256 + + + $(DDK_LIB_PATH)\libcntpr.lib;wpprecorder.lib;acx\km\$(ACX_VERSION_MAJOR).$(ACX_VERSION_MINOR)\acxstub.lib;..\..\Common\$(IntDir)\SamplesCommon.lib;%(AdditionalDependencies) + + + + + true + true + ..\..\common\trace_macros.h + true + $(SDK_INC_PATH);$(DDK_INC_PATH)\acx\km\$(ACX_VERSION_MAJOR).$(ACX_VERSION_MINOR);..\..\common;..\..\inc;..\..\shared;%(AdditionalIncludeDirectories) + ACX_VERSION_MAJOR=1;ACX_VERSION_MINOR=1;KMDF_VERSION_MAJOR=1;KMDF_VERSION_MINOR=31;%(PreprocessorDefinitions) + + + sha256 + + + $(DDK_LIB_PATH)\libcntpr.lib;wpprecorder.lib;acx\km\$(ACX_VERSION_MAJOR).$(ACX_VERSION_MINOR)\acxstub.lib;..\..\Common\$(IntDir)\SamplesCommon.lib;%(AdditionalDependencies) + + + + + + + + + diff --git a/windows/drivers/mic/AudioCodec/Driver/AudioCodec.vcxproj.Filters b/windows/drivers/mic/AudioCodec/Driver/AudioCodec.vcxproj.Filters new file mode 100644 index 000000000..0b882019e --- /dev/null +++ b/windows/drivers/mic/AudioCodec/Driver/AudioCodec.vcxproj.Filters @@ -0,0 +1,42 @@ + + + + + {4FC737F1-C7A5-4376-A066-2A32D752A2FF} + cpp;c;cc;cxx;def;odl;idl;hpj;bat;asm;asmx + + + {93995380-89BD-4b04-88EB-625FBE52EBFB} + h;hpp;hxx;hm;inl;inc;xsd + + + {67DA6AB6-F800-4c08-8B7A-83BB121AAD01} + rc;ico;cur;bmp;dlg;rc2;rct;bin;rgs;gif;jpg;jpeg;jpe;resx;tiff;tif;png;wav;mfcribbon-ms + + + {8E41214B-6785-4CFE-B992-037D68949A14} + inf;inv;inx;mof;mc; + + + + + + + + Driver Files + + + + + Header Files + + + + + Source Files + + + Source Files + + + \ No newline at end of file diff --git a/windows/drivers/mic/AudioCodec/Driver/Device.cpp b/windows/drivers/mic/AudioCodec/Driver/Device.cpp new file mode 100644 index 000000000..59bcee287 --- /dev/null +++ b/windows/drivers/mic/AudioCodec/Driver/Device.cpp @@ -0,0 +1,433 @@ +/*++ + +Copyright (c) Microsoft Corporation. All rights reserved. + +Module Name: + + Device.cpp - Device handling events for example driver. + +Abstract: + + This file contains the device entry points and callbacks. + +Environment: + + Kernel-mode Driver Framework + +--*/ + +#include +#include +#include "public.h" +#include +#include +#include +#include +#include "streamengine.h" +#include "MicPipe.h" +#include "DriverSettings.h" + +#ifndef __INTELLISENSE__ +#include "device.tmh" +#endif + +UNICODE_STRING g_RegistryPath = { 0 }; // This is used to store the registry settings path for the driver + +ULONG DeviceDriverTag = DRIVER_TAG; + +ULONG IdleTimeoutMsec = IDLE_TIMEOUT_MSEC; + +__drv_requiresIRQL(PASSIVE_LEVEL) +PAGED_CODE_SEG +NTSTATUS +CopyRegistrySettingsPath( + _In_ PUNICODE_STRING RegistryPath +) +/*++ + +Routine Description: + +Copies the following registry path to a global variable. + +\REGISTRY\MACHINE\SYSTEM\ControlSetxxx\Services\\Parameters + +Arguments: + +RegistryPath - Registry path passed to DriverEntry + +Returns: + +NTSTATUS - SUCCESS if able to configure the framework + +--*/ + +{ + PAGED_CODE(); + + // + // Initializing the unicode string, so that if it is not allocated it will not be deallocated too. + // + RtlInitUnicodeString(&g_RegistryPath, nullptr); + + g_RegistryPath.MaximumLength = RegistryPath->Length + sizeof(WCHAR); + + g_RegistryPath.Buffer = (PWCH)ExAllocatePool2(POOL_FLAG_PAGED, g_RegistryPath.MaximumLength, DRIVER_TAG); + + if (g_RegistryPath.Buffer == nullptr) + { + return STATUS_INSUFFICIENT_RESOURCES; + } + + RtlAppendUnicodeToString(&g_RegistryPath, RegistryPath->Buffer); + + return STATUS_SUCCESS; +} + +PAGED_CODE_SEG +NTSTATUS +Codec_EvtBusDeviceAdd( + _In_ WDFDRIVER Driver, + _Inout_ PWDFDEVICE_INIT DeviceInit +) +/*++ + +Routine Description: + + EvtDeviceAdd is called by the framework in response to AddDevice + call from the PnP manager. We create and initialize a device object to + represent a new instance of the device. All the software resources + should be allocated in this callback. + +Arguments: + Driver - Handle to a framework driver object created in DriverEntry + + DeviceInit - Pointer to a framework-allocated WDFDEVICE_INIT structure. + +Return Value: + + NTSTATUS + +--*/ +{ + NTSTATUS status = STATUS_SUCCESS; + WDF_OBJECT_ATTRIBUTES attributes; + WDF_DEVICE_PNP_CAPABILITIES pnpCaps; + ACX_DEVICEINIT_CONFIG devInitCfg; + ACX_DEVICE_CONFIG devCfg; + WDFDEVICE device = nullptr; + PCODEC_DEVICE_CONTEXT devCtx; + WDF_PNPPOWER_EVENT_CALLBACKS pnpPowerCallbacks; + + PAGED_CODE(); + + UNREFERENCED_PARAMETER(Driver); + + // + // The driver calls this DDI in its AddDevice callback before creating the PnP device. + // ACX uses this call to add default/standard settings for the device to be created. + // + ACX_DEVICEINIT_CONFIG_INIT(&devInitCfg); + RETURN_IF_FAILED(AcxDeviceInitInitialize(DeviceInit, &devInitCfg)); + + // + // Initialize the pnpPowerCallbacks structure. Callback events for PNP + // and Power are specified here. If you don't supply any callbacks, + // the Framework will take appropriate default actions based on whether + // DeviceInit is initialized to be an FDO, a PDO or a filter device + // object. + // + WDF_PNPPOWER_EVENT_CALLBACKS_INIT(&pnpPowerCallbacks); + pnpPowerCallbacks.EvtDevicePrepareHardware = Codec_EvtDevicePrepareHardware; + pnpPowerCallbacks.EvtDeviceReleaseHardware = Codec_EvtDeviceReleaseHardware; + pnpPowerCallbacks.EvtDeviceD0Entry = Codec_EvtDeviceD0Entry; + pnpPowerCallbacks.EvtDeviceD0Exit = Codec_EvtDeviceD0Exit; + WdfDeviceInitSetPnpPowerEventCallbacks(DeviceInit, &pnpPowerCallbacks); + + WDF_OBJECT_ATTRIBUTES_INIT_CONTEXT_TYPE(&attributes, CODEC_DEVICE_CONTEXT); + attributes.EvtCleanupCallback = Codec_EvtDeviceContextCleanup; + + RETURN_NTSTATUS_IF_FAILED(WdfDeviceCreate(&DeviceInit, &attributes, &device)); + + // + // Init Codec's device context. + // + devCtx = GetCodecDeviceContext(device); + ASSERT(devCtx != nullptr); + + devCtx->Render = nullptr; + devCtx->Capture = nullptr; + devCtx->ExcludeD3Cold = WdfFalse; + + // + // The driver calls this DDI in its AddDevice callback after creating the PnP + // device. ACX uses this call to apply any post device settings. + // + ACX_DEVICE_CONFIG_INIT(&devCfg); + RETURN_NTSTATUS_IF_FAILED(AcxDeviceInitialize(device, &devCfg)); + + // + // Tell the framework to set the SurpriseRemovalOK in the DeviceCaps so + // that you don't get the popup in usermode (on Win2K) when you surprise + // remove the device. + // + WDF_DEVICE_PNP_CAPABILITIES_INIT(&pnpCaps); + pnpCaps.SurpriseRemovalOK = WdfTrue; + WdfDeviceSetPnpCapabilities(device, &pnpCaps); + + // + // Create a render circuit and capture circuit and add them to the current + // device context. These circuits will be added to the device when the + // prepare hardware callback is called. + // + // LibrePodsMic is capture-only (a virtual microphone). No render/speaker + // circuit — so Windows never exposes a phantom output device that could grab + // the default output. + RETURN_NTSTATUS_IF_FAILED(CodecC_AddStaticCapture(device, &CODEC_CAPTURE_COMPONENT_GUID, &MIC_CUSTOM_NAME, &captureCircuitName)); + + // + // LibrePods mic bridge (Phase 2): init the PCM ring and expose the control + // device (\\.\LibrePodsMic) so user mode can push the decoded AirPods audio + // into the capture stream. Best-effort — a failure here must not fail device + // add (the mic still enumerates, just without a user-mode feed). + // + MicPipeInit(); + (VOID)MicPipeCreateControlDevice(device); + + return status; +} + +PAGED_CODE_SEG +NTSTATUS +Codec_EvtDevicePrepareHardware( + _In_ WDFDEVICE Device, + _In_ WDFCMRESLIST ResourceList, + _In_ WDFCMRESLIST ResourceListTranslated +) +/*++ + +Routine Description: + + In this callback, the driver does whatever is necessary to make the + hardware ready to use. + +Arguments: + + Device - handle to a device + +Return Value: + + NT status value + +--*/ +{ + NTSTATUS status = STATUS_SUCCESS; + PCODEC_DEVICE_CONTEXT devCtx; + + UNREFERENCED_PARAMETER(ResourceList); + UNREFERENCED_PARAMETER(ResourceListTranslated); + + PAGED_CODE(); + + devCtx = GetCodecDeviceContext(Device); + ASSERT(devCtx != nullptr); + + // NOTE: Download firmware here. + + // NOTE: Register streaming h/w resources here. + + // + // Set power policy data. + // + RETURN_NTSTATUS_IF_FAILED(Codec_SetPowerPolicy(Device)); + + // + // The driver uses this DDI to associate a circuit to a device. After + // this call the circuit is not visible until the device goes in D0. + // For a real driver there should be a check here to make sure the + // circuit has not been added already (there could be a situation where + // prepareHardware is called multiple times and releaseHardware is only + // called once). + // + + ASSERT(devCtx->Capture); + RETURN_NTSTATUS_IF_FAILED(AcxDeviceAddCircuit(Device, devCtx->Capture)); + + return status; +} + +PAGED_CODE_SEG +NTSTATUS +Codec_EvtDeviceReleaseHardware( + _In_ WDFDEVICE Device, + _In_ WDFCMRESLIST ResourceListTranslated +) +/*++ + +Routine Description: + + In this callback, the driver releases the h/w resources allocated in the + prepare h/w callback. + +Arguments: + + Device - handle to a device + +Return Value: + + NT status value + +--*/ +{ + NTSTATUS status; + PCODEC_DEVICE_CONTEXT devCtx; + + UNREFERENCED_PARAMETER(Device); + UNREFERENCED_PARAMETER(ResourceListTranslated); + + PAGED_CODE(); + + devCtx = GetCodecDeviceContext(Device); + ASSERT(devCtx != nullptr); + + // + // The driver uses this DDI to delete a circuit from the current device. + // + RETURN_NTSTATUS_IF_FAILED(AcxDeviceRemoveCircuit(Device, devCtx->Capture)); + + // NOTE: Release streaming h/w resources here. + + status = STATUS_SUCCESS; + + return status; +} + +PAGED_CODE_SEG +NTSTATUS +Codec_EvtDeviceD0Entry( + _In_ WDFDEVICE Device, + _In_ WDF_POWER_DEVICE_STATE PreviousState +) +{ + UNREFERENCED_PARAMETER(Device); + UNREFERENCED_PARAMETER(PreviousState); + + PAGED_CODE(); + + return STATUS_SUCCESS; +} + +NTSTATUS +Codec_EvtDeviceD0Exit( + _In_ WDFDEVICE Device, + _In_ WDF_POWER_DEVICE_STATE TargetState +) +{ + NTSTATUS status = STATUS_SUCCESS; + POWER_ACTION powerAction; + + PAGED_CODE(); + + powerAction = WdfDeviceGetSystemPowerAction(Device); + + // + // Update the power policy D3-cold info for Connected Standby. + // + if (TargetState == WdfPowerDeviceD3 && powerAction == PowerActionNone) + { + PCODEC_DEVICE_CONTEXT devCtx; + WDF_TRI_STATE excludeD3Cold = WdfTrue; + ACX_DX_EXIT_LATENCY latency; + + devCtx = GetCodecDeviceContext(Device); + ASSERT(devCtx != nullptr); + + // + // Get the current exit latency. + // + latency = AcxDeviceGetCurrentDxExitLatency(Device, + WdfDeviceGetSystemPowerAction(Device), + TargetState); + + // + // If the current exit latency for the ACX device is responsive + // (not instant or fast) then D3-cold does not need to be excluded. + // Otherwise, D3-cold should be excluded because if the hardware + // goes into this state it will take too long to go back into D0 + // and respond. + // + if (latency == AcxDxExitLatencyResponsive) + { + excludeD3Cold = WdfFalse; + } + + if (devCtx->ExcludeD3Cold != excludeD3Cold) + { + devCtx->ExcludeD3Cold = excludeD3Cold; + + RETURN_NTSTATUS_IF_FAILED(Codec_SetPowerPolicy(Device)); + } + } + + return status; +} + +PAGED_CODE_SEG +NTSTATUS +Codec_SetPowerPolicy( + _In_ WDFDEVICE Device +) +{ + NTSTATUS status = STATUS_SUCCESS; + PCODEC_DEVICE_CONTEXT devCtx; + + PAGED_CODE(); + + devCtx = GetCodecDeviceContext(Device); + ASSERT(devCtx != nullptr); + + // + // Init the idle policy structure. + // + WDF_DEVICE_POWER_POLICY_IDLE_SETTINGS idleSettings; + WDF_DEVICE_POWER_POLICY_IDLE_SETTINGS_INIT(&idleSettings, IdleCannotWakeFromS0); + idleSettings.IdleTimeout = IDLE_POWER_TIMEOUT; + idleSettings.IdleTimeoutType = SystemManagedIdleTimeoutWithHint; + idleSettings.ExcludeD3Cold = devCtx->ExcludeD3Cold; + + RETURN_NTSTATUS_IF_FAILED(WdfDeviceAssignS0IdleSettings(Device, &idleSettings)); + + return status; +} + +VOID +Codec_EvtDeviceContextCleanup( + _In_ WDFOBJECT WdfDevice +) +/*++ + +Routine Description: + + In this callback, it cleans up device context. + +Arguments: + + WdfDevice - WDF device object + +Return Value: + + nullptr + +--*/ +{ + WDFDEVICE device; + PCODEC_DEVICE_CONTEXT devCtx; + + device = (WDFDEVICE)WdfDevice; + devCtx = GetCodecDeviceContext(device); + ASSERT(devCtx != nullptr); + + if (devCtx->Capture) + { + CodecC_CircuitCleanup(devCtx->Capture); + } +} diff --git a/windows/drivers/mic/AudioCodec/Driver/Driver.cpp b/windows/drivers/mic/AudioCodec/Driver/Driver.cpp new file mode 100644 index 000000000..aca3e86f1 --- /dev/null +++ b/windows/drivers/mic/AudioCodec/Driver/Driver.cpp @@ -0,0 +1,129 @@ +/*++ + +Copyright (c) Microsoft Corporation. All rights reserved. + +Module Name: + + Driver.cpp + +Abstract: + + This file contains the driver entry points and callbacks. + +Environment: + + Kernel-mode Driver Framework + +--*/ + +#include "public.h" +#include "cpp_utils.h" + +#ifndef __INTELLISENSE__ +#include "driver.tmh" +#endif + +_Use_decl_annotations_ +void AudioCodecDriverUnload( + _In_ WDFDRIVER Driver +) +{ + PAGED_CODE(); + + if (!Driver) + { + ASSERT(FALSE); + return; + } + + WPP_CLEANUP(WdfDriverWdmGetDriverObject(Driver)); + + if (g_RegistryPath.Buffer != nullptr) + { + ExFreePool(g_RegistryPath.Buffer); + RtlZeroMemory(&g_RegistryPath, sizeof(g_RegistryPath)); + } + + return; +} + +INIT_CODE_SEG +NTSTATUS +DriverEntry( + _In_ PDRIVER_OBJECT DriverObject, + _In_ PUNICODE_STRING RegistryPath +) +/*++ + +Routine Description: + DriverEntry initializes the driver and is the first routine called by the + system after the driver is loaded. DriverEntry specifies the other entry + points in the function driver, such as EvtDevice and DriverUnload. + +Parameters Description: + + DriverObject - represents the instance of the function driver that is loaded + into memory. DriverEntry must initialize members of DriverObject before it + returns to the caller. DriverObject is allocated by the system before the + driver is loaded, and it is released by the system after the system unloads + the function driver from memory. + + RegistryPath - represents the driver specific path in the Registry. + The function driver can use the path to store driver related data between + reboots. The path does not store hardware instance specific data. + +Return Value: + + STATUS_SUCCESS if successful, + STATUS_UNSUCCESSFUL otherwise. + +--*/ +{ + WDF_DRIVER_CONFIG wdfCfg; + ACX_DRIVER_CONFIG acxCfg; + WDFDRIVER driver; + NTSTATUS status = STATUS_SUCCESS; + WDF_OBJECT_ATTRIBUTES attributes; + + PAGED_CODE(); + WPP_INIT_TRACING(DriverObject, RegistryPath); + + auto exit = scope_exit([&status, &DriverObject]() { + if (!NT_SUCCESS(status)) + { + WPP_CLEANUP(DriverObject); + + if (g_RegistryPath.Buffer != nullptr) + { + ExFreePool(g_RegistryPath.Buffer); + RtlZeroMemory(&g_RegistryPath, sizeof(g_RegistryPath)); + } + } + }); + + RETURN_NTSTATUS_IF_FAILED(CopyRegistrySettingsPath(RegistryPath)); + + WDF_OBJECT_ATTRIBUTES_INIT(&attributes); + + WDF_DRIVER_CONFIG_INIT(&wdfCfg, Codec_EvtBusDeviceAdd); + wdfCfg.EvtDriverUnload = AudioCodecDriverUnload; + + // + // Create a framework driver object to represent our driver. + // + RETURN_NTSTATUS_IF_FAILED(WdfDriverCreate(DriverObject, RegistryPath, &attributes, &wdfCfg, &driver)); + + // + // Initializing the ACX driver configuration struct which contains size and flags + // elements. + // + ACX_DRIVER_CONFIG_INIT(&acxCfg); + + // + // The driver calls this DDI in its DriverEntry callback after creating the WDF driver + // object. ACX uses this call to apply any post driver settings. + // + RETURN_NTSTATUS_IF_FAILED(AcxDriverInitialize(driver, &acxCfg)); + + return status; +} diff --git a/windows/drivers/mic/AudioCodec/Driver/DriverSettings.h b/windows/drivers/mic/AudioCodec/Driver/DriverSettings.h new file mode 100644 index 000000000..84ac730a5 --- /dev/null +++ b/windows/drivers/mic/AudioCodec/Driver/DriverSettings.h @@ -0,0 +1,54 @@ +/*++ + +Copyright (c) Microsoft Corporation. All rights reserved. + + THIS CODE AND INFORMATION IS PROVIDED "AS IS" WITHOUT WARRANTY OF ANY + KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE + IMPLIED WARRANTIES OF MERCHANTABILITY AND/OR FITNESS FOR A PARTICULAR + PURPOSE. + +Module Name: + + DriverSettings.h + +Abstract: + + Contains guid definitions and other definitions used by the render and capture circuits + for this specific driver. Driver developers should replace these definitions with their + own. + +Environment: + + Kernel mode + +--*/ + +// Defining the component ID for the capture circuit. This ID uniquely identifies the circuit instance (vendor specific): +DEFINE_GUID(CODEC_CAPTURE_COMPONENT_GUID, 0xc3ee9ec6, 0x8e8c, 0x49e9, 0xaf, 0x4b, 0xa7, 0xfc, 0x28, 0xe9, 0xd2, 0xe7); + +// Defines a custom name for the capture circuit bridge pin: +DEFINE_GUID(MIC_CUSTOM_NAME, 0xd5649dc4, 0x2fa2, 0x418b, 0xb2, 0x78, 0x39, 0x7, 0x64, 0x6b, 0x3, 0xe); + +// Defining the component ID for the render circuit. This ID uniquely identifies the circuit instance (vendor specific): +DEFINE_GUID(CODEC_RENDER_COMPONENT_GUID, 0xd03deb75, 0xe5b2, 0x45f7, 0x91, 0xfa, 0xf7, 0xae, 0x42, 0xdd, 0xf, 0xe0); + +// This is always the definition for the system container guid: +DEFINE_GUID(SYSTEM_CONTAINER_GUID, 0x00000000, 0x0000, 0x0000, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF); + +// Driver developers should update this guid if the container is a device rather than a +// system. Otherwise, this GUID should stay the same: +DEFINE_GUID(DEVICE_CONTAINER_GUID, 0x00000000, 0x0000, 0x0000, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF); + +// AudioCodec driver tag: +#define DRIVER_TAG (ULONG) 'CduA' + +// The idle timeout in msec for power policy structure: +#define IDLE_TIMEOUT_MSEC (ULONG) 10000 + +// The WPP control GUID defined in Trace.h should also be updated to be unique. + +// This string must match the string defined in AudioCodec.inf for the microphone name: +DECLARE_CONST_UNICODE_STRING(captureCircuitName, L"Microphone0"); + +// This string must match the string defined in AudioCodec.inf for the speaker name: +DECLARE_CONST_UNICODE_STRING(renderCircuitName, L"Speaker0"); diff --git a/windows/drivers/mic/AudioCodec/Driver/ReadMe.txt b/windows/drivers/mic/AudioCodec/Driver/ReadMe.txt new file mode 100644 index 000000000..ebc61c6da --- /dev/null +++ b/windows/drivers/mic/AudioCodec/Driver/ReadMe.txt @@ -0,0 +1,42 @@ +======================================================================== + AudioCodec Project Overview +======================================================================== + +This file contains a summary of what you will find in each of the files that make up your project. + +AudioCodec.vcxproj + This is the main project file for projects generated using an Application Wizard. + It contains information about the version of the product that generated the file, and + information about the platforms, configurations, and project features selected with the + Application Wizard. + +AudioCodec.vcxproj.filters + This is the filters file for VC++ projects generated using an Application Wizard. + It contains information about the association between the files in your project + and the filters. This association is used in the IDE to show grouping of files with + similar extensions under a specific node (for e.g. ".cpp" files are associated with the + "Source Files" filter). + +Driver.cpp & Driver.h + DriverEntry and WDFDRIVER related functionality and callbacks. Driver developers should + make changes to these files for their specific driver as necessary. + +Device.cpp & Device.h + WDFDEVICE related functionality and callbacks. Driver developers should make changes to + these files for their specific driver as necessary. + +Trace.h + Definitions for WPP tracing. + +DriverSettings.h + Contains guid definitions and other definitions used by the render and capture circuits + for this specific driver. Driver developers should replace these definitions with their + own. + +///////////////////////////////////////////////////////////////////////////// + +Learn more about Kernel Mode Driver Framework here: + +http://msdn.microsoft.com/en-us/library/ff544296(v=VS.85).aspx + +///////////////////////////////////////////////////////////////////////////// diff --git a/windows/drivers/mic/AudioCodec/Driver/Resources.rc b/windows/drivers/mic/AudioCodec/Driver/Resources.rc new file mode 100644 index 000000000..60cfd1939 --- /dev/null +++ b/windows/drivers/mic/AudioCodec/Driver/Resources.rc @@ -0,0 +1,12 @@ +#include + +#include + +#define VER_FILETYPE VFT_DRV +#define VER_FILESUBTYPE VFT2_DRV_SYSTEM +#define VER_FILEDESCRIPTION_STR "Audio Codec Acx Sample Driver" +#define VER_INTERNALNAME_STR "AudioCodec.sys" +#define VER_ORIGINALFILENAME_STR "AudioCodec.sys" + +#include "common.ver" + diff --git a/windows/drivers/mic/Common/CaptureCircuit.cpp b/windows/drivers/mic/Common/CaptureCircuit.cpp new file mode 100644 index 000000000..0d4346d12 --- /dev/null +++ b/windows/drivers/mic/Common/CaptureCircuit.cpp @@ -0,0 +1,799 @@ +/*++ + +Copyright (c) Microsoft Corporation. All rights reserved. + + THIS CODE AND INFORMATION IS PROVIDED "AS IS" WITHOUT WARRANTY OF ANY + KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE + IMPLIED WARRANTIES OF MERCHANTABILITY AND/OR FITNESS FOR A PARTICULAR + PURPOSE. + +Module Name: + + CaptureCircuit.cpp + +Abstract: + + Capture Circuit. This file contains routines to create and handle + capture circuit. + +Environment: + + Kernel mode + +--*/ + +#include "private.h" +#include "public.h" +#include +#include +#include +#include "AudioFormats.h" +#include "streamengine.h" +#include "cpp_utils.h" +#include "circuithelper.h" + +#ifndef __INTELLISENSE__ +#include "captureCircuit.tmh" +#endif + +// +// Controls how the custom name of the bridge pin is read. +// +BOOL g_UseCustomInfName = TRUE; + +PAGED_CODE_SEG +NTSTATUS +CodecC_EvtAcxPinSetDataFormat( + _In_ ACXPIN Pin, + _In_ ACXDATAFORMAT DataFormat +) +/*++ + +Routine Description: + + This ACX pin callback sets the device/mixed format. + +Return Value: + + NTSTATUS + +--*/ +{ + UNREFERENCED_PARAMETER(Pin); + UNREFERENCED_PARAMETER(DataFormat); + + PAGED_CODE(); + + // NOTE: update device/mixed format here. + + return STATUS_NOT_SUPPORTED; +} + +/////////////////////////////////////////////////////////// +// +// For more information on volume element see: https://docs.microsoft.com/en-us/windows-hardware/drivers/audio/ksnodetype-volume +// +_Use_decl_annotations_ +NTSTATUS +CodecC_EvtVolumeAssignLevelCallback( + _In_ ACXVOLUME Volume, + _In_ ULONG Channel, + _In_ LONG VolumeLevel +) +{ + PAGED_CODE(); + + ASSERT(Volume); + PVOLUME_ELEMENT_CONTEXT volumeCtx = GetVolumeElementContext(Volume); + ASSERT(volumeCtx); + + if (Channel != ALL_CHANNELS_ID) + { + volumeCtx->VolumeLevel[Channel] = VolumeLevel; + } + else + { + for (ULONG i = 0; i < MAX_CHANNELS; ++i) + { + volumeCtx->VolumeLevel[i] = VolumeLevel; + } + } + + return STATUS_SUCCESS; +} + +_Use_decl_annotations_ +NTSTATUS +CodecC_EvtVolumeRetrieveLevelCallback( + _In_ ACXVOLUME Volume, + _In_ ULONG Channel, + _Out_ LONG * VolumeLevel +) +{ + PAGED_CODE(); + + ASSERT(Volume); + PVOLUME_ELEMENT_CONTEXT volumeCtx = GetVolumeElementContext(Volume); + ASSERT(volumeCtx); + + if (Channel == ALL_CHANNELS_ID) + { + Channel = 0; + } + + *VolumeLevel = volumeCtx->VolumeLevel[Channel]; + + return STATUS_SUCCESS; +} + +PAGED_CODE_SEG +NTSTATUS +CodecC_CreateVolumeElement( + _In_ ACXCIRCUIT Circuit, + _Out_ ACXVOLUME* Element +) +/*++ + +Routine Description: + + This routine creates a volume element. + +Return Value: + + NT status value +--*/ +{ + NTSTATUS status = STATUS_SUCCESS; + WDF_OBJECT_ATTRIBUTES attributes; + ACX_VOLUME_CALLBACKS volumeCallbacks; + ACX_VOLUME_CONFIG volumeCfg; + VOLUME_ELEMENT_CONTEXT * volumeCtx; + + PAGED_CODE(); + + // + // The driver uses this DDI to assign its volume element callbacks. + // + ACX_VOLUME_CALLBACKS_INIT(&volumeCallbacks); + volumeCallbacks.EvtAcxVolumeAssignLevel = CodecC_EvtVolumeAssignLevelCallback; + volumeCallbacks.EvtAcxVolumeRetrieveLevel = CodecC_EvtVolumeRetrieveLevelCallback; + + // + // Create Volume element + // + ACX_VOLUME_CONFIG_INIT(&volumeCfg); + volumeCfg.ChannelsCount = MAX_CHANNELS; + volumeCfg.Minimum = VOLUME_LEVEL_MINIMUM; + volumeCfg.Maximum = VOLUME_LEVEL_MAXIMUM; + volumeCfg.SteppingDelta = VOLUME_STEPPING; + volumeCfg.Callbacks = &volumeCallbacks; + + WDF_OBJECT_ATTRIBUTES_INIT_CONTEXT_TYPE(&attributes, VOLUME_ELEMENT_CONTEXT); + attributes.ParentObject = Circuit; + + RETURN_NTSTATUS_IF_FAILED(AcxVolumeCreate(Circuit, &attributes, &volumeCfg, Element)); + + ASSERT(*Element != nullptr); + volumeCtx = GetVolumeElementContext(*Element); + ASSERT(volumeCtx); + + // + // (max + min)/2 puts it in the middle of the valid range, divide that by the stepping to get the nearest + // valid step, multiply that by stepping to put it back at a level value. + // + volumeCtx->VolumeLevel[0] = (VOLUME_LEVEL_MAXIMUM + VOLUME_LEVEL_MINIMUM) / 2 / VOLUME_STEPPING * VOLUME_STEPPING; + volumeCtx->VolumeLevel[1] = (VOLUME_LEVEL_MAXIMUM + VOLUME_LEVEL_MINIMUM) / 2 / VOLUME_STEPPING * VOLUME_STEPPING; + + return status; +} + +PAGED_CODE_SEG +NTSTATUS +CodecC_EvtAcxPinRetrieveName( + _In_ ACXPIN Pin, + _Out_ PUNICODE_STRING Name +) +/*++ + +Routine Description: + + If g_UseCustomInfName is false then the ACX + pin callback EvtAcxPinRetrieveName calls this + function in order to retrieve the pin name. + +Return Value: + + NTSTATUS + +--*/ +{ + UNREFERENCED_PARAMETER(Pin); + + PAGED_CODE(); + + return RtlUnicodeStringPrintf(Name, L"LibrePods"); +} + +VOID +CodecC_EvtPinContextCleanup( + _In_ WDFOBJECT WdfPin +) +/*++ + +Routine Description: + + In this callback, it cleans up pin context. + +Arguments: + + WdfDevice - WDF device object + +Return Value: + + nullptr + +--*/ +{ + UNREFERENCED_PARAMETER(WdfPin); +} + +PAGED_CODE_SEG +NTSTATUS +CodecC_CircuitCleanup( + _In_ ACXCIRCUIT Circuit +) +{ + PCODEC_CAPTURE_CIRCUIT_CONTEXT circuitCtx; + + PAGED_CODE(); + + // + // Remove the static capture circuit. + // + circuitCtx = GetCaptureCircuitContext(Circuit); + ASSERT(circuitCtx != nullptr); + + return STATUS_SUCCESS; +} + +PAGED_CODE_SEG +NTSTATUS +CodecC_AddStaticCapture( + _In_ WDFDEVICE Device, + _In_ const GUID * ComponentGuid, + _In_ const GUID * MicCustomName, + _In_ const UNICODE_STRING * CircuitName +) +/*++ + +Routine Description: + + Creates the static capture circuit (pictured below) + and adds it to the device context. This is called + when a new device is detected and the AddDevice + call is made by the pnp manager. + + ****************************************************** + * Capture Circuit * + * * + * +-----------------------+ * + * | | * + * | +-------------+ | * + * Host ------>| | Volume Node | |---> Bridge * + * Pin | +-------------+ | Pin * + * | | * + * +-----------------------+ * + * * + ****************************************************** + +Return Value: + + NTSTATUS + +--*/ +{ + NTSTATUS status = STATUS_SUCCESS; + PCODEC_DEVICE_CONTEXT devCtx; + PCAPTURE_DEVICE_CONTEXT captureDevCtx; + ACXCIRCUIT captureCircuit = nullptr; + WDF_OBJECT_ATTRIBUTES attributes; + + PAGED_CODE(); + + devCtx = GetCodecDeviceContext(Device); + ASSERT(devCtx != nullptr); + + // + // Alloc audio context to current device. + // + WDF_OBJECT_ATTRIBUTES_INIT_CONTEXT_TYPE(&attributes, CAPTURE_DEVICE_CONTEXT); + RETURN_NTSTATUS_IF_FAILED(WdfObjectAllocateContext(Device, &attributes, (PVOID*)&captureDevCtx)); + ASSERT(captureDevCtx); + + // + // Create a capture circuit associated with this child device. + // + RETURN_NTSTATUS_IF_FAILED(CodecC_CreateCaptureCircuit(Device, ComponentGuid, MicCustomName, CircuitName, &captureCircuit)); + + devCtx->Capture = captureCircuit; + + return status; +} + +PAGED_CODE_SEG +NTSTATUS +Capture_AllocateSupportedFormats( + _In_ WDFDEVICE Device, + _In_reads_bytes_(CodecCapturePinCount) ACXPIN Pin[], + _In_ ACXCIRCUIT Circuit, + _In_ size_t CodecCapturePinCount +) +{ + UNREFERENCED_PARAMETER(CodecCapturePinCount); + + NTSTATUS status = STATUS_SUCCESS; + ACXDATAFORMAT formatPcm48000c1; + ACXDATAFORMATLIST formatList; + + WDF_OBJECT_ATTRIBUTES attributes; + WDF_OBJECT_ATTRIBUTES_INIT(&attributes); + + /////////////////////////////////////////////////////////// + // + // Allocate the formats this circuit supports. + // + + // Offer 48000 Hz only: LibrePods always feeds decoded audio resampled to + // 48 kHz, so exposing 44100 too made apps (e.g. Voice Recorder) open at + // 44100 and play our 48 kHz samples ~8% slow (deep/robotic). + RETURN_NTSTATUS_IF_FAILED(AllocateFormat(Pcm48000c1, Circuit, Device, &formatPcm48000c1)); + + /////////////////////////////////////////////////////////// + // + // Define supported formats for the host pin. + // + + // + // The raw processing mode list is associated with each single circuit + // by ACX. A driver uses this DDI to retrieve the built-in raw + // data-format list. + // + RETURN_NTSTATUS_IF_TRUE(CodecCaptureHostPin >= CodecCapturePinCount, STATUS_INVALID_PARAMETER); + formatList = AcxPinGetRawDataFormatList(Pin[CodecCaptureHostPin]); + RETURN_NTSTATUS_IF_TRUE(formatList == nullptr, STATUS_INSUFFICIENT_RESOURCES); + + // + // The driver uses this DDI to add data formats to the raw + // processing mode list associated with the current circuit. + // + RETURN_NTSTATUS_IF_FAILED(AcxDataFormatListAddDataFormat(formatList, formatPcm48000c1)); + + return status; +} + +PAGED_CODE_SEG +NTSTATUS +CodecC_CreateCaptureCircuit( + _In_ WDFDEVICE Device, + _In_ const GUID * ComponentGuid, + _In_ const GUID * MicCustomName, + _In_ const UNICODE_STRING * CircuitName, + _Out_ ACXCIRCUIT* Circuit +) +/*++ + +Routine Description: + + This routine builds the CODEC capture circuit. + +Return Value: + + NT status value + +--*/ +{ + NTSTATUS status; + WDF_OBJECT_ATTRIBUTES attributes; + ACXCIRCUIT circuit; + CODEC_CAPTURE_CIRCUIT_CONTEXT* circuitCtx; + ACXPIN pin[CodecCapturePinCount]; + + PAGED_CODE(); + + // + // Init output value. + // + *Circuit = nullptr; + + /////////////////////////////////////////////////////////// + // + // Create a circuit. + // + { + PACXCIRCUIT_INIT circuitInit = nullptr; + ACX_CIRCUIT_PNPPOWER_CALLBACKS powerCallbacks; + + // + // The driver uses this DDI to allocate an ACXCIRCUIT_INIT + // structure. This opaque structure is used when creating + // a standalone audio circuit representing an audio device. + // + circuitInit = AcxCircuitInitAllocate(Device); + + // + // A driver uses this DDI to free the allocated + // ACXCIRCUIT_INIT structure when an error is detected. + // Normally the structures is deleted/cleared by ACX when + // an ACX circuit is created successfully. + // + auto circuitInitScope = scope_exit([&circuitInit]() { + if (circuitInit) { + AcxCircuitInitFree(circuitInit); + } + }); + + // + // The driver uses this DDI to specify the Component ID + // of the ACX circuit. This ID is a guid that uniquely + // identifies the circuit instance (vendor specific). + // + AcxCircuitInitSetComponentId(circuitInit, ComponentGuid); + + // + // The driver uses this DDI to specify the circuit name. + // For standalone circuits, this is the audio device name + // which is used by clients to open handles to the audio devices. + // + (VOID)AcxCircuitInitAssignName(circuitInit, CircuitName); + + // + // The driver uses this DDI to specify the circuit type. The + // circuit type can be AcxCircuitTypeRender, AcxCircuitTypeCapture, + // AcxCircuitTypeOther, or AcxCircuitTypeMaximum (for validation). + // + AcxCircuitInitSetCircuitType(circuitInit, AcxCircuitTypeCapture); + + // + // The driver uses this DDI to assign its (if any) power callbacks. + // + ACX_CIRCUIT_PNPPOWER_CALLBACKS_INIT(&powerCallbacks); + powerCallbacks.EvtAcxCircuitPowerUp = CodecC_EvtCircuitPowerUp; + powerCallbacks.EvtAcxCircuitPowerDown = CodecC_EvtCircuitPowerDown; + AcxCircuitInitSetAcxCircuitPnpPowerCallbacks(circuitInit, &powerCallbacks); + + // + // The driver uses this DDI to register for a stream-create callback. + // + RETURN_NTSTATUS_IF_FAILED(AcxCircuitInitAssignAcxCreateStreamCallback(circuitInit, CodecC_EvtCircuitCreateStream)); + + // + // The driver uses this DDI to create a new ACX circuit. + // + WDF_OBJECT_ATTRIBUTES_INIT_CONTEXT_TYPE(&attributes, CODEC_CAPTURE_CIRCUIT_CONTEXT); + RETURN_NTSTATUS_IF_FAILED(AcxCircuitCreate(Device, &attributes, &circuitInit, &circuit)); + + circuitInitScope.release(); + + circuitCtx = GetCaptureCircuitContext(circuit); + ASSERT(circuitCtx); + } + + // + // Post circuit creation initialization. + // + + /////////////////////////////////////////////////////////// + // + // Create volume element. + // + { + ACXELEMENT elements[CaptureElementCount] = { 0 }; + + RETURN_NTSTATUS_IF_FAILED(CodecC_CreateVolumeElement(circuit, (ACXVOLUME*)&elements[CaptureVolumeIndex])); + + // + // Saving the volume element in the circuit context. + // + circuitCtx->VolumeElement = (ACXVOLUME)elements[CaptureVolumeIndex]; + + // + // The driver uses this DDI post circuit creation to add ACXELEMENTs. + // + RETURN_NTSTATUS_IF_FAILED(AcxCircuitAddElements(circuit, elements, SIZEOF_ARRAY(elements))); + } + + /////////////////////////////////////////////////////////// + // + // Create the pins for the circuit. + // + { + ACX_PIN_CALLBACKS pinCallbacks; + ACX_PIN_CONFIG pinCfg; + CODEC_PIN_CONTEXT* pinCtx; + + /////////////////////////////////////////////////////////// + // + // Create capture streaming pin. + // + ACX_PIN_CALLBACKS_INIT(&pinCallbacks); + pinCallbacks.EvtAcxPinSetDataFormat = CodecC_EvtAcxPinSetDataFormat; + + ACX_PIN_CONFIG_INIT(&pinCfg); + pinCfg.Type = AcxPinTypeSource; + pinCfg.Communication = AcxPinCommunicationSink; + pinCfg.Category = &KSCATEGORY_AUDIO; + pinCfg.PinCallbacks = &pinCallbacks; + + WDF_OBJECT_ATTRIBUTES_INIT_CONTEXT_TYPE(&attributes, CODEC_PIN_CONTEXT); + attributes.EvtCleanupCallback = CodecC_EvtPinContextCleanup; + attributes.ParentObject = circuit; + + // + // The driver uses this DDI to create one or more pins on the circuits. + // + RETURN_NTSTATUS_IF_FAILED(AcxPinCreate(circuit, &attributes, &pinCfg, &(pin[CodecCaptureHostPin]))); + + ASSERT(pin[CodecCaptureHostPin] != nullptr); + pinCtx = GetCodecPinContext(pin[CodecCaptureHostPin]); + ASSERT(pinCtx); + pinCtx->CodecPinType = CodecPinTypeHost; + + /////////////////////////////////////////////////////////// + // + // Create capture endpoint pin. + // + ACX_PIN_CALLBACKS_INIT(&pinCallbacks); + ACX_PIN_CONFIG_INIT(&pinCfg); + + pinCfg.Type = AcxPinTypeSink; + pinCfg.Communication = AcxPinCommunicationNone; + pinCfg.Category = &KSNODETYPE_MICROPHONE; + pinCfg.PinCallbacks = &pinCallbacks; + + // Specify how to read the custom name. + if (g_UseCustomInfName) + { + pinCfg.Name = MicCustomName; + } + else + { + pinCallbacks.EvtAcxPinRetrieveName = CodecC_EvtAcxPinRetrieveName; + } + g_UseCustomInfName = !g_UseCustomInfName; + + WDF_OBJECT_ATTRIBUTES_INIT(&attributes); + attributes.ParentObject = circuit; + + // + // The driver uses this DDI to create one or more pins on the circuits. + // + RETURN_NTSTATUS_IF_FAILED(AcxPinCreate(circuit, &attributes, &pinCfg, &(pin[CodecCaptureBridgePin]))); + + ASSERT(pin[CodecCaptureBridgePin] != nullptr); + } + + /////////////////////////////////////////////////////////// + // + // Add audio jack to bridge pin. + // For more information on audio jack see: https://docs.microsoft.com/en-us/windows/win32/api/devicetopology/ns-devicetopology-ksjack_description + // + { + ACX_JACK_CONFIG jackCfg; + ACXJACK jack; + PJACK_CONTEXT jackCtx; + + ACX_JACK_CONFIG_INIT(&jackCfg); + jackCfg.Description.ChannelMapping = SPEAKER_FRONT_LEFT | SPEAKER_FRONT_RIGHT; + jackCfg.Description.Color = RGB(0, 0, 0); + jackCfg.Description.ConnectionType = AcxConnTypeAtapiInternal; + jackCfg.Description.GeoLocation = AcxGeoLocFront; + jackCfg.Description.GenLocation = AcxGenLocPrimaryBox; + jackCfg.Description.PortConnection = AcxPortConnIntegratedDevice; + + WDF_OBJECT_ATTRIBUTES_INIT_CONTEXT_TYPE(&attributes, JACK_CONTEXT); + attributes.ParentObject = pin[CodecCaptureBridgePin]; + + RETURN_NTSTATUS_IF_FAILED(AcxJackCreate(pin[CodecCaptureBridgePin], &attributes, &jackCfg, &jack)); + + ASSERT(jack != nullptr); + + jackCtx = GetJackContext(jack); + ASSERT(jackCtx); + jackCtx->Dummy = 0; + + RETURN_NTSTATUS_IF_FAILED(AcxPinAddJacks(pin[CodecCaptureBridgePin], &jack, 1)); + } + + RETURN_NTSTATUS_IF_FAILED(Capture_AllocateSupportedFormats(Device, pin, circuit, CodecCapturePinCount)); + + /////////////////////////////////////////////////////////// + // + // The driver uses this DDI post circuit creation to add ACXPINs. + // + RETURN_NTSTATUS_IF_FAILED(AcxCircuitAddPins(circuit, pin, CodecCapturePinCount)); + + // + // Set output value. + // + *Circuit = circuit; + + // + // Done. + // + status = STATUS_SUCCESS; + + return status; +} + +_Use_decl_annotations_ +NTSTATUS +CodecC_EvtCircuitPowerUp( + _In_ WDFDEVICE Device, + _In_ ACXCIRCUIT Circuit, + _In_ WDF_POWER_DEVICE_STATE PreviousState +) +{ + UNREFERENCED_PARAMETER(Device); + UNREFERENCED_PARAMETER(Circuit); + UNREFERENCED_PARAMETER(PreviousState); + + PAGED_CODE(); + + return STATUS_SUCCESS; +} + +_Use_decl_annotations_ +NTSTATUS +CodecC_EvtCircuitPowerDown( + _In_ WDFDEVICE Device, + _In_ ACXCIRCUIT Circuit, + _In_ WDF_POWER_DEVICE_STATE TargetState +) +{ + UNREFERENCED_PARAMETER(Device); + UNREFERENCED_PARAMETER(Circuit); + UNREFERENCED_PARAMETER(TargetState); + + PAGED_CODE(); + + return STATUS_SUCCESS; +} + +PAGED_CODE_SEG +NTSTATUS +CodecC_EvtCircuitCreateStream( + _In_ WDFDEVICE Device, + _In_ ACXCIRCUIT Circuit, + _In_ ACXPIN Pin, + _In_ PACXSTREAM_INIT StreamInit, + _In_ ACXDATAFORMAT StreamFormat, + _In_ const GUID * SignalProcessingMode, + _In_ ACXOBJECTBAG VarArguments +) +/*++ + +Routine Description: + + This routine creates a stream for the specified circuit. + +Return Value: + + NT status value + +--*/ +{ + NTSTATUS status; + PCAPTURE_DEVICE_CONTEXT devCtx; + WDF_OBJECT_ATTRIBUTES attributes; + ACXSTREAM stream; + STREAMENGINE_CONTEXT * streamCtx; + ACX_STREAM_CALLBACKS streamCallbacks; + ACX_RT_STREAM_CALLBACKS rtCallbacks; + CCaptureStreamEngine * streamEngine = nullptr; + CODEC_CAPTURE_CIRCUIT_CONTEXT * circuitCtx; + CODEC_PIN_CONTEXT * pinCtx; + + auto streamEngineScope = scope_exit([&streamEngine]() { + + if (streamEngine) + { + delete streamEngine; + } + + }); + + PAGED_CODE(); + UNREFERENCED_PARAMETER(SignalProcessingMode); + UNREFERENCED_PARAMETER(VarArguments); + + ASSERT(IsEqualGUID(*SignalProcessingMode, AUDIO_SIGNALPROCESSINGMODE_RAW)); + + devCtx = GetCaptureDeviceContext(Device); + ASSERT(devCtx != nullptr); + + circuitCtx = GetCaptureCircuitContext(Circuit); + ASSERT(circuitCtx != nullptr); + + pinCtx = GetCodecPinContext(Pin); + ASSERT(pinCtx != nullptr); + + // + // Init streaming callbacks. + // + ACX_STREAM_CALLBACKS_INIT(&streamCallbacks); + streamCallbacks.EvtAcxStreamPrepareHardware = EvtStreamPrepareHardware; + streamCallbacks.EvtAcxStreamReleaseHardware = EvtStreamReleaseHardware; + streamCallbacks.EvtAcxStreamRun = EvtStreamRun; + streamCallbacks.EvtAcxStreamPause = EvtStreamPause; + + RETURN_NTSTATUS_IF_FAILED(AcxStreamInitAssignAcxStreamCallbacks(StreamInit, &streamCallbacks)); + + // + // Init RT streaming callbacks. + // + ACX_RT_STREAM_CALLBACKS_INIT(&rtCallbacks); + rtCallbacks.EvtAcxStreamGetHwLatency = EvtStreamGetHwLatency; + rtCallbacks.EvtAcxStreamAllocateRtPackets = EvtStreamAllocateRtPackets; + rtCallbacks.EvtAcxStreamFreeRtPackets = EvtStreamFreeRtPackets; + rtCallbacks.EvtAcxStreamGetCapturePacket = CodecC_EvtStreamGetCapturePacket; + rtCallbacks.EvtAcxStreamGetCurrentPacket = EvtStreamGetCurrentPacket; + rtCallbacks.EvtAcxStreamGetPresentationPosition = EvtStreamGetPresentationPosition; + + RETURN_NTSTATUS_IF_FAILED(AcxStreamInitAssignAcxRtStreamCallbacks(StreamInit, &rtCallbacks)); + + // + // Buffer notifications are supported. + // + AcxStreamInitSetAcxRtStreamSupportsNotifications(StreamInit); + + // + // Create the stream. + // + WDF_OBJECT_ATTRIBUTES_INIT_CONTEXT_TYPE(&attributes, STREAMENGINE_CONTEXT); + attributes.EvtDestroyCallback = EvtStreamDestroy; + RETURN_NTSTATUS_IF_FAILED(AcxRtStreamCreate(Device, Circuit, &attributes, &StreamInit, &stream)); + + streamCtx = GetStreamEngineContext(stream); + ASSERT(streamCtx); + + // + // Create the virtual streaming engine which will control + // streaming logic for the capture circuit. + // + streamEngine = new (POOL_FLAG_NON_PAGED, DeviceDriverTag) CCaptureStreamEngine(stream, StreamFormat); + RETURN_NTSTATUS_IF_TRUE(streamEngine == nullptr, STATUS_INSUFFICIENT_RESOURCES); + + streamCtx->StreamEngine = (PVOID)streamEngine; + + streamEngine = nullptr; + + // + // Done. + // + status = STATUS_SUCCESS; + + return status; +} + +PAGED_CODE_SEG +NTSTATUS +CodecC_EvtStreamGetCapturePacket( + _In_ ACXSTREAM Stream, + _Out_ ULONG * LastCapturePacket, + _Out_ ULONGLONG * QPCPacketStart, + _Out_ BOOLEAN * MoreData +) +{ + PSTREAMENGINE_CONTEXT ctx; + CCaptureStreamEngine* streamEngine = nullptr; + + PAGED_CODE(); + + ctx = GetStreamEngineContext(Stream); + + streamEngine = static_cast(ctx->StreamEngine); + + return streamEngine->GetCapturePacket(LastCapturePacket, QPCPacketStart, MoreData); +} + + diff --git a/windows/drivers/mic/Common/CircuitHelper.cpp b/windows/drivers/mic/Common/CircuitHelper.cpp new file mode 100644 index 000000000..c005ff031 --- /dev/null +++ b/windows/drivers/mic/Common/CircuitHelper.cpp @@ -0,0 +1,66 @@ +/*++ + +Copyright (c) Microsoft Corporation. All rights reserved. + + THIS CODE AND INFORMATION IS PROVIDED "AS IS" WITHOUT WARRANTY OF ANY + KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE + IMPLIED WARRANTIES OF MERCHANTABILITY AND/OR FITNESS FOR A PARTICULAR + PURPOSE. + +Module Name: + + CircuitHelper.cpp + +Abstract: + + This module contains helper functions for circuits. + +Environment: + + Kernel mode + +--*/ + +#include "private.h" +#include "public.h" +#include "CircuitHelper.h" + +#ifndef __INTELLISENSE__ +#include "CircuitHelper.tmh" +#endif + + +PAGED_CODE_SEG +NTSTATUS AllocateFormat( + _In_ KSDATAFORMAT_WAVEFORMATEXTENSIBLE WaveFormat, + _In_ ACXCIRCUIT Circuit, + _In_ WDFDEVICE Device, + _Out_ ACXDATAFORMAT* Format +) +{ + PAGED_CODE(); + + NTSTATUS status = STATUS_SUCCESS; + + WDF_OBJECT_ATTRIBUTES attributes; + WDF_OBJECT_ATTRIBUTES_INIT(&attributes); + + ACX_DATAFORMAT_CONFIG formatCfg; + ACX_DATAFORMAT_CONFIG_INIT_KS(&formatCfg, &WaveFormat); + WDF_OBJECT_ATTRIBUTES_INIT_CONTEXT_TYPE(&attributes, FORMAT_CONTEXT); + attributes.ParentObject = Circuit; + + // + // Creates an ACXDATAFORMAT handle for the given wave format. + // + RETURN_NTSTATUS_IF_FAILED(AcxDataFormatCreate(Device, &attributes, &formatCfg, Format)); + + ASSERT((*Format) != NULL); + FORMAT_CONTEXT* formatCtx; + formatCtx = GetFormatContext(*Format); + ASSERT(formatCtx); + UNREFERENCED_PARAMETER(formatCtx); + + return status; +} + diff --git a/windows/drivers/mic/Common/CircuitHelper.h b/windows/drivers/mic/Common/CircuitHelper.h new file mode 100644 index 000000000..74bb021e4 --- /dev/null +++ b/windows/drivers/mic/Common/CircuitHelper.h @@ -0,0 +1,30 @@ +/*++ + +Copyright (c) Microsoft Corporation. All rights reserved. + + THIS CODE AND INFORMATION IS PROVIDED "AS IS" WITHOUT WARRANTY OF ANY + KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE + IMPLIED WARRANTIES OF MERCHANTABILITY AND/OR FITNESS FOR A PARTICULAR + PURPOSE. + +Module Name: + + CircuitHelper.h + +Abstract: + + This module contains helper functions for endpoints. + +Environment: + + Kernel mode + +--*/ + +PAGED_CODE_SEG +NTSTATUS AllocateFormat( + _In_ KSDATAFORMAT_WAVEFORMATEXTENSIBLE WaveFormat, + _In_ ACXCIRCUIT Circuit, + _In_ WDFDEVICE Device, + _Out_ ACXDATAFORMAT* Format +); diff --git a/windows/drivers/mic/Common/MicPipe.cpp b/windows/drivers/mic/Common/MicPipe.cpp new file mode 100644 index 000000000..fdeea4195 --- /dev/null +++ b/windows/drivers/mic/Common/MicPipe.cpp @@ -0,0 +1,226 @@ +/*++ + +Module Name: + + MicPipe.cpp + +Abstract: + + Implementation of the LibrePods hi-res microphone bridge. See MicPipe.h. + + Design: a single global byte ring buffer guarded by a spin lock (the virtual + mic is single-instance). User mode writes decoded PCM via the control device + IOCTL; the ACX capture stream engine reads a packet per tick. No WPP tracing + here (keeps the file self-contained). + +Environment: + + Kernel mode + +--*/ + +#include "MicPipe.h" + +// +// ~1.36 s of headroom @ 48 kHz mono 16-bit (96000 B/s). Lives in the driver's +// non-paged image data, so it's safe to touch at DISPATCH_LEVEL. +// +#define MIC_RING_BYTES 0x20000u + +static UCHAR g_Ring[MIC_RING_BYTES]; +static ULONG g_Head; // next write index +static ULONG g_Tail; // next read index +static ULONG g_Count; // bytes currently buffered +static KSPIN_LOCK g_Lock; +static BOOLEAN g_Inited = FALSE; + +static WDFDEVICE g_ControlDevice = NULL; + +// Advances on every capture pull (MicPipeRead). The tray polls it to tell when +// an app is actually recording from the mic, and auto-enables/disables the +// hi-res stream accordingly. +static volatile LONG g_ReadTick = 0; + +EXTERN_C_START + +VOID +MicPipeInit( + VOID +) +{ + if (g_Inited) { + return; + } + KeInitializeSpinLock(&g_Lock); + g_Head = g_Tail = g_Count = 0; + g_Inited = TRUE; +} + +VOID +MicPipeWrite( + _In_reads_bytes_(Len) PVOID Data, + _In_ ULONG Len +) +{ + KIRQL irql; + PUCHAR src = (PUCHAR)Data; + ULONG i; + + if (!g_Inited || Data == NULL || Len == 0) { + return; + } + + KeAcquireSpinLock(&g_Lock, &irql); + for (i = 0; i < Len; i++) { + if (g_Count == MIC_RING_BYTES) { + // Full: drop the oldest byte so the newest audio always wins. + g_Tail = (g_Tail + 1u) % MIC_RING_BYTES; + g_Count--; + } + g_Ring[g_Head] = src[i]; + g_Head = (g_Head + 1u) % MIC_RING_BYTES; + g_Count++; + } + KeReleaseSpinLock(&g_Lock, irql); +} + +VOID +MicPipeRead( + _Out_writes_bytes_(Len) PVOID Out, + _In_ ULONG Len +) +{ + KIRQL irql; + PUCHAR dst = (PUCHAR)Out; + ULONG i; + + if (Out == NULL || Len == 0) { + return; + } + // A capture is pulling data: mark activity so the tray can auto-enable. + InterlockedIncrement(&g_ReadTick); + if (!g_Inited) { + RtlZeroMemory(Out, Len); + return; + } + + KeAcquireSpinLock(&g_Lock, &irql); + for (i = 0; i < Len; i++) { + if (g_Count == 0) { + dst[i] = 0; // underrun -> silence + } else { + dst[i] = g_Ring[g_Tail]; + g_Tail = (g_Tail + 1u) % MIC_RING_BYTES; + g_Count--; + } + } + KeReleaseSpinLock(&g_Lock, irql); +} + +// +// IOCTL handler: copy the pushed PCM into the ring. +// +static VOID +MicPipe_EvtIoDeviceControl( + _In_ WDFQUEUE Queue, + _In_ WDFREQUEST Request, + _In_ size_t OutputBufferLength, + _In_ size_t InputBufferLength, + _In_ ULONG IoControlCode +) +{ + NTSTATUS status = STATUS_INVALID_DEVICE_REQUEST; + ULONG_PTR info = 0; + + UNREFERENCED_PARAMETER(Queue); + UNREFERENCED_PARAMETER(OutputBufferLength); + + if (IoControlCode == IOCTL_LIBREPODS_MIC_WRITE_PCM && InputBufferLength > 0) { + PVOID buf = NULL; + size_t len = 0; + status = WdfRequestRetrieveInputBuffer(Request, 1, &buf, &len); + if (NT_SUCCESS(status)) { + MicPipeWrite(buf, (ULONG)len); + info = len; + } + } else if (IoControlCode == IOCTL_LIBREPODS_MIC_STATUS) { + PVOID buf = NULL; + size_t len = 0; + status = WdfRequestRetrieveOutputBuffer(Request, sizeof(LONG), &buf, &len); + if (NT_SUCCESS(status)) { + *(LONG *)buf = InterlockedCompareExchange(&g_ReadTick, 0, 0); + info = sizeof(LONG); + } + } + + WdfRequestCompleteWithInformation(Request, status, info); +} + +NTSTATUS +MicPipeCreateControlDevice( + _In_ WDFDEVICE Parent +) +{ + NTSTATUS status; + PWDFDEVICE_INIT init = NULL; + WDFDEVICE ctl = NULL; + WDFQUEUE queue; + WDF_IO_QUEUE_CONFIG qCfg; + + // SYSTEM: all, Builtin Admins: RWX, Everyone: RW (so a non-elevated app can + // open \\.\LibrePodsMic and push audio). + DECLARE_CONST_UNICODE_STRING(sddl, + L"D:P(A;;GA;;;SY)(A;;GRGWGX;;;BA)(A;;GRGW;;;WD)"); + DECLARE_CONST_UNICODE_STRING(ntName, L"\\Device\\LibrePodsMic"); + DECLARE_CONST_UNICODE_STRING(symLink, L"\\DosDevices\\LibrePodsMic"); + + // Driver-scoped, created once. Survives PnP device remove/re-add. + if (g_ControlDevice != NULL) { + return STATUS_SUCCESS; + } + + init = WdfControlDeviceInitAllocate(WdfDeviceGetDriver(Parent), &sddl); + if (init == NULL) { + return STATUS_INSUFFICIENT_RESOURCES; + } + + WdfDeviceInitSetDeviceType(init, FILE_DEVICE_UNKNOWN); + WdfDeviceInitSetIoType(init, WdfDeviceIoBuffered); + + // Single audio source: only one process may feed the mic at a time. Two + // writers interleaving in the ring sound like static, so refuse a second + // open (a stuck feeder is freed on its process exit / a driver reload). + WdfDeviceInitSetExclusive(init, TRUE); + + status = WdfDeviceInitAssignName(init, &ntName); + if (!NT_SUCCESS(status)) { + WdfDeviceInitFree(init); + return status; + } + + status = WdfDeviceCreate(&init, WDF_NO_OBJECT_ATTRIBUTES, &ctl); + if (!NT_SUCCESS(status)) { + WdfDeviceInitFree(init); // WdfDeviceCreate only consumes init on success + return status; + } + + status = WdfDeviceCreateSymbolicLink(ctl, &symLink); + if (!NT_SUCCESS(status)) { + WdfObjectDelete(ctl); + return status; + } + + WDF_IO_QUEUE_CONFIG_INIT_DEFAULT_QUEUE(&qCfg, WdfIoQueueDispatchParallel); + qCfg.EvtIoDeviceControl = MicPipe_EvtIoDeviceControl; + status = WdfIoQueueCreate(ctl, &qCfg, WDF_NO_OBJECT_ATTRIBUTES, &queue); + if (!NT_SUCCESS(status)) { + WdfObjectDelete(ctl); + return status; + } + + WdfControlFinishInitializing(ctl); + g_ControlDevice = ctl; + return STATUS_SUCCESS; +} + +EXTERN_C_END diff --git a/windows/drivers/mic/Common/MicPipe.h b/windows/drivers/mic/Common/MicPipe.h new file mode 100644 index 000000000..aab435dc1 --- /dev/null +++ b/windows/drivers/mic/Common/MicPipe.h @@ -0,0 +1,90 @@ +/*++ + +Module Name: + + MicPipe.h + +Abstract: + + LibrePods hi-res microphone bridge (Phase 2). A global ring buffer that + user mode fills with decoded PCM over an IOCTL, and the ACX capture stream + engine drains one packet per notification tick (see StreamEngine.cpp + ProcessPacket). Exposed to user mode through a control device + (\\.\LibrePodsMic). + + PCM format: mono, 16-bit, 44100 or 48000 Hz (whatever the client opens the + capture endpoint with — see Capture_AllocateSupportedFormats). + +Environment: + + Kernel mode + +--*/ + +#pragma once + +#include +#include + +// +// IOCTL: user mode pushes raw PCM bytes into the mic ring buffer. +// Value (precomputed for the user-mode side): 0x0022A000. +// CTL_CODE(FILE_DEVICE_UNKNOWN, 0x800, METHOD_BUFFERED, FILE_WRITE_DATA) +// +#define IOCTL_LIBREPODS_MIC_WRITE_PCM \ + CTL_CODE(FILE_DEVICE_UNKNOWN, 0x800, METHOD_BUFFERED, FILE_WRITE_DATA) + +// +// IOCTL: user mode reads a capture-activity counter (ULONG). It advances every +// time an app pulls a capture packet, so the tray can tell when the mic is +// actually being recorded and auto-enable/disable the hi-res stream. +// Value (precomputed): 0x00226004. +// CTL_CODE(FILE_DEVICE_UNKNOWN, 0x801, METHOD_BUFFERED, FILE_READ_DATA) +// +#define IOCTL_LIBREPODS_MIC_STATUS \ + CTL_CODE(FILE_DEVICE_UNKNOWN, 0x801, METHOD_BUFFERED, FILE_READ_DATA) + +EXTERN_C_START + +// +// Initialize the global ring buffer. Call once, early (device add), before any +// read/write. Idempotent. +// +VOID +MicPipeInit( + VOID +); + +// +// Create the control device (\Device\LibrePodsMic + \DosDevices\LibrePodsMic) +// that exposes IOCTL_LIBREPODS_MIC_WRITE_PCM. Driver-scoped and created once; +// safe to call again (returns STATUS_SUCCESS if already created). Best-effort: +// a failure here must not fail device add (the mic still enumerates, just with +// no user-mode feed yet). +// +NTSTATUS +MicPipeCreateControlDevice( + _In_ WDFDEVICE Parent +); + +// +// Append PCM bytes to the ring (called from the IOCTL handler, PASSIVE_LEVEL). +// On overflow the oldest bytes are dropped so we always keep the newest audio. +// +VOID +MicPipeWrite( + _In_reads_bytes_(Len) PVOID Data, + _In_ ULONG Len +); + +// +// Fill Out with Len bytes from the ring (called from ProcessPacket, up to +// DISPATCH_LEVEL). Any underrun is zero-filled (silence). +// +VOID +MicPipeRead( + _Out_writes_bytes_(Len) PVOID Out, + _In_ ULONG Len +); + +EXTERN_C_END diff --git a/windows/drivers/mic/Common/NewDelete.cpp b/windows/drivers/mic/Common/NewDelete.cpp new file mode 100644 index 000000000..a2bee67dd --- /dev/null +++ b/windows/drivers/mic/Common/NewDelete.cpp @@ -0,0 +1,91 @@ +/*++ + THIS CODE AND INFORMATION IS PROVIDED "AS IS" WITHOUT WARRANTY OF ANY + KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE + IMPLIED WARRANTIES OF MERCHANTABILITY AND/OR FITNESS FOR A PARTICULAR + PURPOSE. +Module Name: + newdelete.cpp +Abstract: + Contains overloaded placement new and delete operators +Environment: + Kernel mode +--*/ + +#include "private.h" +#include "NewDelete.h" + +/***************************************************************************** + * ::new(POOL_FLAGS) + ***************************************************************************** + * New function for creating objects with a specified pool flags. + */ +PVOID operator new( + _In_ size_t size, + _In_ POOL_FLAGS poolFlags +) +{ + PVOID result = ExAllocatePool2(poolFlags, size, 'wNwS'); + + return result; +} + +/***************************************************************************** + * ::new(POOL_FLAGS, TAG) + ***************************************************************************** + * New function for creating objects with specified pool flags and allocation tag. + */ +PVOID operator new( + _In_ size_t size, + _In_ POOL_FLAGS poolFlags, + _In_ ULONG tag +) +{ + PVOID result = ExAllocatePool2(poolFlags, size, tag); + + return result; +} + +void __cdecl operator delete(PVOID buffer) +{ + if (buffer) + { + ExFreePool(buffer); + } +} + +void __cdecl operator delete(PVOID buffer, ULONG tag) +{ + if (buffer) + { + ExFreePoolWithTag(buffer, tag); + } +} + +void __cdecl operator delete(_Pre_maybenull_ __drv_freesMem(Mem) PVOID buffer, _In_ size_t cbSize) +{ + UNREFERENCED_PARAMETER(cbSize); + + if (buffer) + { + ExFreePool(buffer); + } +} + +void __cdecl operator delete[](_Pre_maybenull_ __drv_freesMem(Mem) PVOID buffer) +{ + if (buffer) + { + ExFreePool(buffer); + } +} + +void __cdecl operator delete[](_Pre_maybenull_ __drv_freesMem(Mem) PVOID buffer, _In_ size_t cbSize) +{ + UNREFERENCED_PARAMETER(cbSize); + + if (buffer) + { + ExFreePool(buffer); + } +} + diff --git a/windows/drivers/mic/Common/NewDelete.h b/windows/drivers/mic/Common/NewDelete.h new file mode 100644 index 000000000..aee6d8bdd --- /dev/null +++ b/windows/drivers/mic/Common/NewDelete.h @@ -0,0 +1,58 @@ +/*++ +Copyright (c) Microsoft Corporation. All rights reserved. + THIS CODE AND INFORMATION IS PROVIDED "AS IS" WITHOUT WARRANTY OF ANY + KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE + IMPLIED WARRANTIES OF MERCHANTABILITY AND/OR FITNESS FOR A PARTICULAR + PURPOSE. +Module Name: + NewDelete.h +Abstract: + Contains overloaded placement new and delete operators +Environment: + Kernel mode +--*/ + +/***************************************************************************** + * ::new(POOL_FLAGS) + ***************************************************************************** + * New function for creating objects with a specified pool flags. + */ +PVOID operator new( + _In_ size_t size, + _In_ POOL_FLAGS poolFlags +); + +/***************************************************************************** + * ::new(POOL_FLAGS, TAG) + ***************************************************************************** + * New function for creating objects with specified pool flags and allocation tag. + */ +PVOID operator new( + _In_ size_t size, + _In_ POOL_FLAGS poolFlags, + _In_ ULONG tag +); + +/***************************************************************************** + * ::delete() + ***************************************************************************** + * Delete function. + */ +void __cdecl operator delete(PVOID buffer); + +/***************************************************************************** + * ::delete() + ***************************************************************************** + * Delete function. + */ +void __cdecl operator delete(PVOID buffer, ULONG tag); + +void __cdecl operator delete[](PVOID pVoid, _In_ size_t cbSize); + +void __cdecl operator delete(_Pre_maybenull_ __drv_freesMem(Mem) PVOID buffer, _In_ size_t cbSize); + +void __cdecl operator delete[](_Pre_maybenull_ __drv_freesMem(Mem) PVOID buffer); + +void __cdecl operator delete[](_Pre_maybenull_ __drv_freesMem(Mem) PVOID buffer, _In_ size_t cbSize); + + diff --git a/windows/drivers/mic/Common/Private.h b/windows/drivers/mic/Common/Private.h new file mode 100644 index 000000000..4402fa06c --- /dev/null +++ b/windows/drivers/mic/Common/Private.h @@ -0,0 +1,345 @@ +/*++ + +Copyright (c) Microsoft Corporation. All rights reserved. + + THIS CODE AND INFORMATION IS PROVIDED "AS IS" WITHOUT WARRANTY OF ANY + KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE + IMPLIED WARRANTIES OF MERCHANTABILITY AND/OR FITNESS FOR A PARTICULAR + PURPOSE. + +Module Name: + + Private.h + +Abstract: + + Contains structure definitions and function prototypes private to + the Common library. + +Environment: + + Kernel mode + +--*/ + +#ifndef _PRIVATE_H_ +#define _PRIVATE_H_ + + +#include +#include +#include "cpp_utils.h" +#include +#include +#include +#include "NewDelete.h" + +/* make prototypes usable from C++ */ +#ifdef __cplusplus +extern "C" { +#endif + +#include +#include +#include +#include +#include "Trace.h" + +#include +#include + +#define PAGED_CODE_SEG __declspec(code_seg("PAGE")) +#define INIT_CODE_SEG __declspec(code_seg("INIT")) + +extern const GUID DSP_CIRCUIT_SPEAKER_GUID; +extern const GUID DSP_CIRCUIT_MICROPHONE_GUID; +extern const GUID DSP_CIRCUIT_UNIVERSALJACK_RENDER_GUID; +extern const GUID DSP_CIRCUIT_UNIVERSALJACK_CAPTURE_GUID; + +///////////////////////////////////////////////////////// +// +// Driver wide definitions +// + +// Copied from cfgmgr32.h +#if !defined(MAX_DEVICE_ID_LEN) +#define MAX_DEVICE_ID_LEN 200 +#endif + +// Number of millisecs per sec. +#define MS_PER_SEC 1000 + +// Number of hundred nanosecs per sec. +#define HNS_PER_SEC 10000000 + +#define REQUEST_TIMEOUT_SECONDS 5 + +#undef MIN +#undef MAX +#define MIN(a,b) ((a) > (b) ? (b) : (a)) +#define MAX(a,b) ((a) > (b) ? (a) : (b)) + +#ifndef BOOL +typedef int BOOL; +#endif + +#ifndef SIZEOF_ARRAY +#define SIZEOF_ARRAY(ar) (sizeof(ar)/sizeof((ar)[0])) +#endif // !defined(SIZEOF_ARRAY) + +#ifndef RGB +#define RGB(r, g, b) (DWORD)(r << 16 | g << 8 | b) +#endif + +#define ALL_CHANNELS_ID UINT32_MAX +#define MAX_CHANNELS 2 + +// +// Ks support. +// +#define KSPROPERTY_TYPE_ALL KSPROPERTY_TYPE_BASICSUPPORT | \ + KSPROPERTY_TYPE_GET | \ + KSPROPERTY_TYPE_SET + +// +// Define struct to hold signal processing mode and corresponding +// list of supported formats. +// +typedef struct +{ + GUID SignalProcessingMode; + KSDATAFORMAT_WAVEFORMATEXTENSIBLE* FormatList; + ULONG FormatListCount; +} SUPPORTED_FORMATS_LIST; + +// +// Define CAPTURE device context. +// +typedef struct _CAPTURE_DEVICE_CONTEXT { + ACXCIRCUIT Circuit; + BOOLEAN FirstTimePrepareHardware; +} CAPTURE_DEVICE_CONTEXT, * PCAPTURE_DEVICE_CONTEXT; + +WDF_DECLARE_CONTEXT_TYPE_WITH_NAME(CAPTURE_DEVICE_CONTEXT, GetCaptureDeviceContext) + +// +// Define RENDER device context. +// +typedef struct _RENDER_DEVICE_CONTEXT { + ACXCIRCUIT Circuit; + BOOLEAN FirstTimePrepareHardware; +} RENDER_DEVICE_CONTEXT, * PRENDER_DEVICE_CONTEXT; + +WDF_DECLARE_CONTEXT_TYPE_WITH_NAME(RENDER_DEVICE_CONTEXT, GetRenderDeviceContext) + +// +// Define circuit/stream element context. +// +typedef struct _ELEMENT_CONTEXT { + BOOLEAN Dummy; +} ELEMENT_CONTEXT, *PELEMENT_CONTEXT; + +WDF_DECLARE_CONTEXT_TYPE_WITH_NAME(ELEMENT_CONTEXT, GetElementContext) + +// +// Define circuit/stream element context. +// +typedef struct _MUTE_ELEMENT_CONTEXT { + BOOL MuteState[MAX_CHANNELS]; +} MUTE_ELEMENT_CONTEXT, *PMUTE_ELEMENT_CONTEXT; + +WDF_DECLARE_CONTEXT_TYPE_WITH_NAME(MUTE_ELEMENT_CONTEXT, GetMuteElementContext) + +// +// Define circuit/stream element context. +// +typedef struct _VOLUME_ELEMENT_CONTEXT { + LONG VolumeLevel[MAX_CHANNELS]; +} VOLUME_ELEMENT_CONTEXT, *PVOLUME_ELEMENT_CONTEXT; + +WDF_DECLARE_CONTEXT_TYPE_WITH_NAME(VOLUME_ELEMENT_CONTEXT, GetVolumeElementContext) + +#define VOLUME_STEPPING 0x8000 +#define VOLUME_LEVEL_MAXIMUM 0x00000000 +#define VOLUME_LEVEL_MINIMUM (-96 * 0x10000) + +// +// Define mute timer context. +// +typedef struct _MUTE_TIMER_CONTEXT { + ACXELEMENT MuteElement; + ACXEVENT Event; +} MUTE_TIMER_CONTEXT, *PMUTE_TIMER_CONTEXT; + +WDF_DECLARE_CONTEXT_TYPE_WITH_NAME(MUTE_TIMER_CONTEXT, GetMuteTimerContext) + +// +// Define format context. +// +typedef struct _FORMAT_CONTEXT { + BOOLEAN Dummy; +} FORMAT_CONTEXT, *PFORMAT_CONTEXT; + +WDF_DECLARE_CONTEXT_TYPE_WITH_NAME(FORMAT_CONTEXT, GetFormatContext) + +// +// Define jack context. +// +typedef struct _JACK_CONTEXT { + ULONG Dummy; +} JACK_CONTEXT, * PJACK_CONTEXT; + +WDF_DECLARE_CONTEXT_TYPE_WITH_NAME(JACK_CONTEXT, GetJackContext) + +// +// Define audio engine context. +// +typedef struct _ENGINE_CONTEXT { + ACXDATAFORMAT MixFormat; +} ENGINE_CONTEXT, * PENGINE_CONTEXT; + +WDF_DECLARE_CONTEXT_TYPE_WITH_NAME(ENGINE_CONTEXT, GetEngineContext) + +// +// Define stream audio engine context. +// +typedef struct _STREAMAUDIOENGINE_CONTEXT { + BOOLEAN Dummy; +} STREAMAUDIOENGINE_CONTEXT, * PSTREAMAUDIOENGINE_CONTEXT; + +WDF_DECLARE_CONTEXT_TYPE_WITH_NAME(STREAMAUDIOENGINE_CONTEXT, GetStreamAudioEngineContext) + +// +// Define keyword spotter context +// +typedef struct _KEYWORDSPOTTER_CONTEXT { + ACXPNPEVENT Event; + PVOID KeywordDetector; +} KEYWORDSPOTTER_CONTEXT, * PKEYWORDSPOTTER_CONTEXT; + +WDF_DECLARE_CONTEXT_TYPE_WITH_NAME(KEYWORDSPOTTER_CONTEXT, GetKeywordSpotterContext) + +// +// Define pnp event context. +// +typedef struct _PNPEVENT_CONTEXT { + BOOLEAN Dummy; +} PNPEVENT_CONTEXT, * PPNPEVENT_CONTEXT; + +WDF_DECLARE_CONTEXT_TYPE_WITH_NAME(PNPEVENT_CONTEXT, GetPnpEventContext) + +// +// Define peakmeter element context. +// +typedef struct _PEAKMETER_ELEMENT_CONTEXT { + LONG PeakMeterLevel[MAX_CHANNELS]; +} PEAKMETER_ELEMENT_CONTEXT, * PPEAKMETER_ELEMENT_CONTEXT; + +WDF_DECLARE_CONTEXT_TYPE_WITH_NAME(PEAKMETER_ELEMENT_CONTEXT, GetPeakMeterElementContext) + +// +// Define DSP circuit's peakmeter element context. +// +typedef struct _DSP_PEAKMETER_ELEMENT_CONTEXT +{ + PVOID peakMeter; +} DSP_PEAKMETER_ELEMENT_CONTEXT, *PDSP_PEAKMETER_ELEMENT_CONTEXT; + +WDF_DECLARE_CONTEXT_TYPE_WITH_NAME(DSP_PEAKMETER_ELEMENT_CONTEXT, GetDspPeakMeterElementContext) + +// +// Define stream engine context. +// +typedef struct _STREAMENGINE_CONTEXT { + PVOID StreamEngine; +} STREAMENGINE_CONTEXT, * PSTREAMENGINE_CONTEXT; + +WDF_DECLARE_CONTEXT_TYPE_WITH_NAME(STREAMENGINE_CONTEXT, GetStreamEngineContext) + +#define PEAKMETER_STEPPING_DELTA 0x1000 +#define PEAKMETER_MAXIMUM LONG_MAX +#define PEAKMETER_MINIMUM LONG_MIN + +///////////////////////////////////////////////////////////// +// Codec driver defintions +// + +typedef enum _CODEC_PIN_TYPE { + CodecPinTypeHost, + CodecPinTypeOffload, + CodecPinTypeLoopback, + CodecPinTypeKeyword, + CodecPinTypeDevice +} CODEC_PIN_TYPE, * PCODEC_PIN_TYPE; + +typedef struct _CODEC_PIN_CONTEXT { + CODEC_PIN_TYPE CodecPinType; +} CODEC_PIN_CONTEXT, * PCODEC_PIN_CONTEXT; + +WDF_DECLARE_CONTEXT_TYPE_WITH_NAME(CODEC_PIN_CONTEXT, GetCodecPinContext) + + +///////////////////////////////////////////////////////// +// +// Codec Capture (microphone) definitions +// + +// +// Define capture circuit context. +// +typedef struct _CODEC_CAPTURE_CIRCUIT_CONTEXT { + ACXVOLUME BoostElement; + ACXVOLUME VolumeElement; + ACXKEYWORDSPOTTER KeywordSpotter; +} CODEC_CAPTURE_CIRCUIT_CONTEXT, * PCODEC_CAPTURE_CIRCUIT_CONTEXT; + +WDF_DECLARE_CONTEXT_TYPE_WITH_NAME(CODEC_CAPTURE_CIRCUIT_CONTEXT, GetCaptureCircuitContext) + +typedef enum { + CodecCaptureHostPin = 0, + CodecCaptureBridgePin = 1, + CodecCapturePinCount = 2 +} CODEC_CAPTURE_PINS; + +typedef enum { + CaptureVolumeIndex = 0, + CaptureElementCount = 1 +} CAPTURE_ELEMENTS; + +// Capture callbacks. + +EVT_ACX_CIRCUIT_CREATE_STREAM CodecC_EvtCircuitCreateStream; +EVT_ACX_CIRCUIT_POWER_UP CodecC_EvtCircuitPowerUp; +EVT_ACX_CIRCUIT_POWER_DOWN CodecC_EvtCircuitPowerDown; +EVT_ACX_VOLUME_ASSIGN_LEVEL CodecC_EvtVolumeAssignLevelCallback; +EVT_ACX_VOLUME_RETRIEVE_LEVEL CodecC_EvtVolumeRetrieveLevelCallback; +EVT_ACX_VOLUME_ASSIGN_LEVEL CodecC_EvtBoostAssignLevelCallback; +EVT_ACX_VOLUME_RETRIEVE_LEVEL CodecC_EvtBoostRetrieveLevelCallback; +EVT_ACX_STREAM_GET_CAPTURE_PACKET CodecC_EvtStreamGetCapturePacket; +EVT_ACX_PIN_SET_DATAFORMAT CodecC_EvtAcxPinSetDataFormat; +EVT_ACX_PIN_RETRIEVE_NAME CodecC_EvtAcxPinRetrieveName; +EVT_WDF_DEVICE_CONTEXT_CLEANUP CodecC_EvtPinContextCleanup; +EVT_ACX_KEYWORDSPOTTER_RETRIEVE_ARM CodecC_EvtAcxKeywordSpotterRetrieveArm; +EVT_ACX_KEYWORDSPOTTER_ASSIGN_ARM CodecC_EvtAcxKeywordSpotterAssignArm; +EVT_ACX_KEYWORDSPOTTER_ASSIGN_PATTERNS CodecC_EvtAcxKeywordSpotterAssignPatterns; +EVT_ACX_KEYWORDSPOTTER_ASSIGN_RESET CodecC_EvtAcxKeywordSpotterAssignReset; + +PAGED_CODE_SEG +NTSTATUS +CodecC_CreateCaptureCircuit( + _In_ WDFDEVICE Device, + _In_ const GUID * ComponentGuid, + _In_ const GUID * MicCustomName, + _In_ const UNICODE_STRING * CircuitName, + _Out_ ACXCIRCUIT * Circuit +); + + +/* make internal prototypes usable from C++ */ +#ifdef __cplusplus +} +#endif + + + +#endif // _PRIVATE_H_ diff --git a/windows/drivers/mic/Common/SamplesCommon.vcxproj b/windows/drivers/mic/Common/SamplesCommon.vcxproj new file mode 100644 index 000000000..ce40bb576 --- /dev/null +++ b/windows/drivers/mic/Common/SamplesCommon.vcxproj @@ -0,0 +1,172 @@ + + + + + Debug + x64 + + + Release + x64 + + + Debug + ARM64 + + + Release + ARM64 + + + + {6A946D59-6690-4ED0-A77D-7D4C3D4B3241} + {497e31cb-056b-4f31-abb8-447fd55ee5a5} + v4.5 + 12.0 + Debug + SamplesCommon + $(LatestTargetPlatformVersion) + + + + Windows10 + true + WindowsKernelModeDriver10.0 + StaticLibrary + KMDF + Windows Driver + 1 + 31 + + + Windows10 + false + WindowsKernelModeDriver10.0 + StaticLibrary + KMDF + Windows Driver + 1 + 31 + + + Windows10 + true + WindowsKernelModeDriver10.0 + StaticLibrary + KMDF + Windows Driver + 1 + 31 + + + Windows10 + false + WindowsKernelModeDriver10.0 + StaticLibrary + KMDF + Windows Driver + 1 + 31 + + + + + + + + + + + DbgengKernelDebugger + $(IntDir) + + + DbgengKernelDebugger + $(IntDir) + + + DbgengKernelDebugger + $(IntDir) + + + DbgengKernelDebugger + $(IntDir) + + + + true + true + trace_macros.h + true + $(SDK_INC_PATH);$(DDK_INC_PATH)\acx\km\1.1;..\inc;..\shared;%(AdditionalIncludeDirectories) + ACX_VERSION_MAJOR=1;ACX_VERSION_MINOR=1;KMDF_VERSION_MAJOR=1;KMDF_VERSION_MINOR=31;%(PreprocessorDefinitions) + + + sha256 + + + + + true + true + trace_macros.h + true + $(SDK_INC_PATH);$(DDK_INC_PATH)\acx\km\1.1;..\inc;..\shared;%(AdditionalIncludeDirectories) + ACX_VERSION_MAJOR=1;ACX_VERSION_MINOR=1;KMDF_VERSION_MAJOR=1;KMDF_VERSION_MINOR=31;KMDF_VERSION_MINOR=31;%(PreprocessorDefinitions) + + + sha256 + + + + + true + true + trace_macros.h + true + $(SDK_INC_PATH);$(DDK_INC_PATH)\acx\km\1.1;..\inc;..\shared;%(AdditionalIncludeDirectories) + ACX_VERSION_MAJOR=1;ACX_VERSION_MINOR=1;KMDF_VERSION_MAJOR=1;KMDF_VERSION_MINOR=31;%(PreprocessorDefinitions) + + + sha256 + + + + + true + true + trace_macros.h + true + $(SDK_INC_PATH);$(DDK_INC_PATH)\acx\km\1.1;..\inc;..\shared;%(AdditionalIncludeDirectories) + ACX_VERSION_MAJOR=1;ACX_VERSION_MINOR=1;KMDF_VERSION_MAJOR=1;KMDF_VERSION_MINOR=31;%(PreprocessorDefinitions) + + + sha256 + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/windows/drivers/mic/Common/SimPeakMeter.cpp b/windows/drivers/mic/Common/SimPeakMeter.cpp new file mode 100644 index 000000000..05f7fe05e --- /dev/null +++ b/windows/drivers/mic/Common/SimPeakMeter.cpp @@ -0,0 +1,106 @@ +/*++ + + THIS CODE AND INFORMATION IS PROVIDED "AS IS" WITHOUT WARRANTY OF ANY + KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE + IMPLIED WARRANTIES OF MERCHANTABILITY AND/OR FITNESS FOR A PARTICULAR + PURPOSE. + +Module Name: + + SimPeakMeter.cpp + +Abstract: + + Virtual Peakmeter - aggregates all streams + +Environment: + + Kernel mode + +--*/ + +#include "private.h" +#include "SimPeakMeter.h" + +#ifndef __INTELLISENSE__ +#include "SimPeakMeter.tmh" +#endif + +_Use_decl_annotations_ +PAGED_CODE_SEG +CSimPeakMeter::CSimPeakMeter() +{ + PAGED_CODE(); + m_NumStreams = 0; + m_PeakMeterIndex = 0; +} + +_Use_decl_annotations_ +PAGED_CODE_SEG +CSimPeakMeter::~CSimPeakMeter() +{ + PAGED_CODE(); +} + +_Use_decl_annotations_ +PAGED_CODE_SEG +LONG CSimPeakMeter::GetValue(ULONG Channel) +{ + PAGED_CODE(); + + // Ignore channel + UNREFERENCED_PARAMETER(Channel); + +#define PEAKMETER_VALUE_FULL (PEAKMETER_MAXIMUM / PEAKMETER_STEPPING_DELTA * PEAKMETER_STEPPING_DELTA) +#define PEAKMETER_VALUE_HALF (PEAKMETER_MAXIMUM / 2 / PEAKMETER_STEPPING_DELTA * PEAKMETER_STEPPING_DELTA) +#define PEAKMETER_VALUE_QUARTER (PEAKMETER_MAXIMUM / 4 / PEAKMETER_STEPPING_DELTA * PEAKMETER_STEPPING_DELTA) +#define PEAKMETER_VALUE_ONE_EIGTH (PEAKMETER_MAXIMUM / 8 / PEAKMETER_STEPPING_DELTA * PEAKMETER_STEPPING_DELTA) + + LONG PeakMeterValues[] = { + PEAKMETER_VALUE_ONE_EIGTH, + PEAKMETER_VALUE_QUARTER, + PEAKMETER_VALUE_HALF, + PEAKMETER_VALUE_FULL, + PEAKMETER_VALUE_HALF, + PEAKMETER_VALUE_QUARTER + }; + + if (m_NumStreams) + { + LONG pmi = InterlockedIncrement(&m_PeakMeterIndex); + if (pmi == ARRAYSIZE(PeakMeterValues)) + { + pmi = 0; + InterlockedExchange(&m_PeakMeterIndex, 0); + } + + return PeakMeterValues[pmi]; + } + + // + // No active streams. Peak meter = 0 + // + return 0; +} + +_Use_decl_annotations_ +PAGED_CODE_SEG +NTSTATUS CSimPeakMeter::StartStream() +{ + PAGED_CODE(); + InterlockedIncrement(&m_NumStreams); + + return STATUS_SUCCESS; +} + +_Use_decl_annotations_ +PAGED_CODE_SEG +NTSTATUS CSimPeakMeter::StopStream() +{ + PAGED_CODE(); + + ASSERT(m_NumStreams); + InterlockedDecrement(&m_NumStreams); + + return STATUS_SUCCESS; +} diff --git a/windows/drivers/mic/Common/SimPeakMeter.h b/windows/drivers/mic/Common/SimPeakMeter.h new file mode 100644 index 000000000..3f0828572 --- /dev/null +++ b/windows/drivers/mic/Common/SimPeakMeter.h @@ -0,0 +1,51 @@ +/*++ + + THIS CODE AND INFORMATION IS PROVIDED "AS IS" WITHOUT WARRANTY OF ANY + KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE + IMPLIED WARRANTIES OF MERCHANTABILITY AND/OR FITNESS FOR A PARTICULAR + PURPOSE. + +Module Name: + + SimPeakMeter.h + +Abstract: + + Virtual Peakmeter - aggregates all streams + +Environment: + + Kernel mode + +--*/ + +#pragma once + +class CSimPeakMeter +{ +public: + __drv_maxIRQL(PASSIVE_LEVEL) + PAGED_CODE_SEG + CSimPeakMeter(); + + __drv_maxIRQL(PASSIVE_LEVEL) + PAGED_CODE_SEG + ~CSimPeakMeter(); + + __drv_maxIRQL(PASSIVE_LEVEL) + PAGED_CODE_SEG + LONG GetValue(_In_ ULONG Channel); + + __drv_maxIRQL(PASSIVE_LEVEL) + PAGED_CODE_SEG + NTSTATUS StartStream(); + + __drv_maxIRQL(PASSIVE_LEVEL) + PAGED_CODE_SEG + NTSTATUS StopStream(); + +private: + LONG m_NumStreams; + LONG m_PeakMeterIndex; +}; + diff --git a/windows/drivers/mic/Common/StreamEngine.cpp b/windows/drivers/mic/Common/StreamEngine.cpp new file mode 100644 index 000000000..8ffbea465 --- /dev/null +++ b/windows/drivers/mic/Common/StreamEngine.cpp @@ -0,0 +1,992 @@ +/*++ + + THIS CODE AND INFORMATION IS PROVIDED "AS IS" WITHOUT WARRANTY OF ANY + KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE + IMPLIED WARRANTIES OF MERCHANTABILITY AND/OR FITNESS FOR A PARTICULAR + PURPOSE. + +Module Name: + + StreamEngine.cpp + +Abstract: + + Virtual Streaming Engine - this module controls streaming logic for + the device. + +Environment: + + Kernel mode + +--*/ + +#include "private.h" +#include "public.h" +#include +#include +#include +#include +#include "streamengine.h" +#include "MicPipe.h" + +#ifndef __INTELLISENSE__ +#include "streamengine.tmh" +#endif + +_Use_decl_annotations_ +PAGED_CODE_SEG +CStreamEngine::CStreamEngine( + _In_ ACXSTREAM Stream, + _In_ ACXDATAFORMAT StreamFormat, + _In_ BOOL Offload, + _In_opt_ CSimPeakMeter *CircuitPeakmeter +) + : m_PacketsCount(0), + m_PacketSize(0), + m_FirstPacketOffset(0), + m_NotificationTimer(NULL), + m_CurrentState(AcxStreamStateStop), + m_CurrentPacket(0), + m_Position(0), + m_Stream(Stream), + m_StreamFormat(StreamFormat), + m_StartTime(0), + m_StartPosition(0), + m_GlitchAdjust(0), + m_ToneFrequency(DEFAULT_FREQUENCY), + m_Offload(Offload), + m_pCircuitPeakmeter(CircuitPeakmeter) +{ + PAGED_CODE(); + + KeQueryPerformanceCounter(&m_PerformanceCounterFrequency); + RtlZeroMemory(m_Packets, sizeof(m_Packets)); +} + +_Use_decl_annotations_ +PAGED_CODE_SEG +CStreamEngine::~CStreamEngine() +{ + PAGED_CODE(); +} + +_Use_decl_annotations_ +PAGED_CODE_SEG +NTSTATUS +CStreamEngine::AllocateRtPackets( + _In_ ULONG PacketCount, + _In_ ULONG PacketSize, + _Out_ PACX_RTPACKET* Packets +) +{ + NTSTATUS status = STATUS_SUCCESS; + PACX_RTPACKET packets = NULL; + PVOID packetBuffer = NULL; + ULONG i; + ULONG packetAllocSizeInPages = 0; + ULONG packetAllocSizeInBytes = 0; + ULONG firstPacketOffset = 0; + size_t packetsSize = 0; + + PAGED_CODE(); + + if (PacketCount > MAX_PACKET_COUNT) + { + ASSERT(FALSE); + status = STATUS_INVALID_PARAMETER; + goto exit; + } + + status = RtlSizeTMult(PacketCount, sizeof(ACX_RTPACKET), &packetsSize); + if (!NT_SUCCESS(status)) + { + ASSERT(FALSE); + goto exit; + } + + packets = (PACX_RTPACKET)ExAllocatePool2(POOL_FLAG_NON_PAGED, packetsSize, DeviceDriverTag); + if (!packets) + { + status = STATUS_NO_MEMORY; + ASSERT(FALSE); + goto exit; + } + + // + // We need to allocate page-aligned buffers, to ensure no kernel memory leaks + // to user space. Round up the packet size to page aligned, then calculate + // the first packet's buffer offset so packet 0 ends on a page boundary and + // packet 1 begins on a page boundary. + // + status = RtlULongAdd(PacketSize, PAGE_SIZE - 1, &packetAllocSizeInPages); + if (!NT_SUCCESS(status)) + { + ASSERT(FALSE); + goto exit; + } + packetAllocSizeInPages = packetAllocSizeInPages / PAGE_SIZE; + packetAllocSizeInBytes = PAGE_SIZE * packetAllocSizeInPages; + firstPacketOffset = packetAllocSizeInBytes - PacketSize; + + for (i = 0; i < PacketCount; ++i) + { + PMDL pMdl = NULL; + + ACX_RTPACKET_INIT(&packets[i]); + + packetBuffer = ExAllocatePool2(POOL_FLAG_NON_PAGED, packetAllocSizeInBytes, DeviceDriverTag); + if (packetBuffer == NULL) + { + status = STATUS_NO_MEMORY; + goto exit; + } + + pMdl = IoAllocateMdl(packetBuffer, packetAllocSizeInBytes, FALSE, TRUE, NULL); + if (pMdl == NULL) + { + status = STATUS_NO_MEMORY; + goto exit; + } + + MmBuildMdlForNonPagedPool(pMdl); + + WDF_MEMORY_DESCRIPTOR_INIT_MDL(&((packets)[i].RtPacketBuffer), pMdl, packetAllocSizeInBytes); + + packets[i].RtPacketSize = PacketSize; + if (i == 0) + { + packets[i].RtPacketOffset = firstPacketOffset; + } + else + { + packets[i].RtPacketOffset = 0; + } + m_Packets[i] = packetBuffer; + + packetBuffer = NULL; + } + + *Packets = packets; + packets = NULL; + m_PacketsCount = PacketCount; + m_PacketSize = PacketSize; + m_FirstPacketOffset = firstPacketOffset; + +exit: + if (packetBuffer) + { + ExFreePoolWithTag(packetBuffer, DeviceDriverTag); + } + if (packets) + { + FreeRtPackets(packets, PacketCount); + } + return status; +} + +_Use_decl_annotations_ +PAGED_CODE_SEG +VOID +CStreamEngine::FreeRtPackets( + _Frees_ptr_ PACX_RTPACKET Packets, + _In_ ULONG PacketCount +) +{ + ULONG i; + PVOID buffer; + + PAGED_CODE(); + + for (i = 0; i < PacketCount; ++i) + { + if (Packets[i].RtPacketBuffer.u.MdlType.Mdl) + { + buffer = MmGetMdlVirtualAddress(Packets[i].RtPacketBuffer.u.MdlType.Mdl); + IoFreeMdl(Packets[i].RtPacketBuffer.u.MdlType.Mdl); + ExFreePool(buffer); + } + } + + ExFreePool(Packets); +} + +_Use_decl_annotations_ +PAGED_CODE_SEG +NTSTATUS +CStreamEngine::PrepareHardware() +{ + NTSTATUS status = STATUS_UNSUCCESSFUL; + WDF_TIMER_CONFIG timerConfig; + WDF_OBJECT_ATTRIBUTES timerAttributes; + PSTREAM_TIMER_CONTEXT timerCtx; + + PAGED_CODE(); + + // + // If already in this state, do nothing. + // + if (m_CurrentState == AcxStreamStatePause) + { + // Nothing to do. + status = STATUS_SUCCESS; + goto exit; + } + + if (m_CurrentState != AcxStreamStateStop) + { + // Error out. + status = STATUS_INVALID_STATE_TRANSITION; + goto exit; + } + + // + // Stop to Pause. + // + WDF_TIMER_CONFIG_INIT(&timerConfig, CStreamEngine::s_EvtStreamPassCallback); + timerConfig.AutomaticSerialization = TRUE; + timerConfig.UseHighResolutionTimer = WdfTrue; + timerConfig.Period = 0; + + WDF_OBJECT_ATTRIBUTES_INIT(&timerAttributes); + WDF_OBJECT_ATTRIBUTES_INIT_CONTEXT_TYPE(&timerAttributes, STREAM_TIMER_CONTEXT); + timerAttributes.ParentObject = m_Stream; + + status = WdfTimerCreate(&timerConfig, &timerAttributes, &m_NotificationTimer); + if (!NT_SUCCESS(status)) + { + goto exit; + } + + timerCtx = GetStreamTimerContext(m_NotificationTimer); + timerCtx->StreamEngine = this; + + m_CurrentState = AcxStreamStatePause; + status = STATUS_SUCCESS; + +exit: + return status; +} + +_Use_decl_annotations_ +PAGED_CODE_SEG +NTSTATUS +CStreamEngine::ReleaseHardware() +{ + PAGED_CODE(); + + // + // If already in this state, do nothing. + // + if (m_CurrentState == AcxStreamStateStop) + { + // Nothing to do. + goto exit; + } + + // + // Just assert we are in the correct state. + // On the way down we always want to succeed. + // + ASSERT(m_CurrentState == AcxStreamStatePause); + + // + // Pause to Stop. + // + if (m_NotificationTimer) + { + WdfTimerStop(m_NotificationTimer, TRUE); + WdfObjectDelete(m_NotificationTimer); + m_NotificationTimer = NULL; + } + + KeFlushQueuedDpcs(); + + m_Position = 0; + m_GlitchAdjust = 0; + m_CurrentPacket = 0; + + m_CurrentState = AcxStreamStateStop; + +exit: + return STATUS_SUCCESS; +} + +_Use_decl_annotations_ +PAGED_CODE_SEG +NTSTATUS +CStreamEngine::Pause() +{ + NTSTATUS status = STATUS_UNSUCCESSFUL; + + PAGED_CODE(); + + if (m_CurrentState == AcxStreamStatePause) + { + // Nothing to do. + status = STATUS_SUCCESS; + goto exit; + } + + if (m_CurrentState != AcxStreamStateRun) + { + // Error out. + status = STATUS_INVALID_STATE_TRANSITION; + goto exit; + } + + m_PeakMeter.StopStream(); + if (m_pCircuitPeakmeter) + { + m_pCircuitPeakmeter->StopStream(); + } + + // + // Run to Pause. + // + WdfTimerStop(m_NotificationTimer, TRUE); + + // Save the position we paused at. + UpdatePosition(); + + m_CurrentState = AcxStreamStatePause; + status = STATUS_SUCCESS; + +exit: + return status; +} + +_Use_decl_annotations_ +PAGED_CODE_SEG +NTSTATUS +CStreamEngine::Run() +{ + NTSTATUS status = STATUS_UNSUCCESSFUL; + + PAGED_CODE(); + + if (m_CurrentState == AcxStreamStateRun) + { + // Nothing to do. + status = STATUS_SUCCESS; + goto exit; + } + + if (m_CurrentState != AcxStreamStatePause) + { + status = STATUS_INVALID_STATE_TRANSITION; + goto exit; + } + + m_PeakMeter.StartStream(); + if (m_pCircuitPeakmeter) + { + m_pCircuitPeakmeter->StartStream(); + } + + // + // Pause to Run. + // + // Save the time and position - if we ran and paused previously, the StartTime and StartPosition will allow + // us to continue scheduling packet completions correctly, while still reporting absolute position from the + // start of the stream. + // + m_StartTime = KSCONVERT_PERFORMANCE_TIME(m_PerformanceCounterFrequency.QuadPart, KeQueryPerformanceCounter(NULL)); + m_StartPosition = m_Position; + + // Reset time we've lost to glitches + m_GlitchAdjust = 0; + + ScheduleNextPass(); + + m_CurrentState = AcxStreamStateRun; + status = STATUS_SUCCESS; + +exit: + return status; +} + +_Use_decl_annotations_ +PAGED_CODE_SEG +NTSTATUS +CStreamEngine::GetPresentationPosition( + _Out_ PULONGLONG PositionInBlocks, + _Out_ PULONGLONG QPCPosition +) +{ + ULONG blockAlign; + LARGE_INTEGER qpc; + + PAGED_CODE(); + + blockAlign = AcxDataFormatGetBlockAlign(m_StreamFormat); + + // Update the position based on the current time + UpdatePosition(); + qpc = KeQueryPerformanceCounter(NULL); + + *PositionInBlocks = m_Position / blockAlign; + *QPCPosition = (ULONGLONG)qpc.QuadPart; + + return STATUS_SUCCESS; +} + +_Use_decl_annotations_ +PAGED_CODE_SEG +NTSTATUS +CStreamEngine::GetLinearBufferPosition( + _Out_ PULONGLONG Position +) +{ + UNREFERENCED_PARAMETER(Position); + PAGED_CODE(); + return STATUS_SUCCESS; +} + +_Use_decl_annotations_ +PAGED_CODE_SEG +NTSTATUS +CStreamEngine::SetCurrentWritePosition( + _In_ ULONG Position +) +{ + UNREFERENCED_PARAMETER(Position); + PAGED_CODE(); + return STATUS_SUCCESS; +} + +_Use_decl_annotations_ +PAGED_CODE_SEG +NTSTATUS +CStreamEngine::SetLastBufferPosition( + _In_ ULONG Position +) +{ + UNREFERENCED_PARAMETER(Position); + PAGED_CODE(); + return STATUS_SUCCESS; +} + +_Use_decl_annotations_ +PAGED_CODE_SEG +NTSTATUS +CStreamEngine::AssignDrmContentId( + ULONG DrmContentId, + PACXDRMRIGHTS DrmRights +) +{ + PAGED_CODE(); + + UNREFERENCED_PARAMETER(DrmContentId); + UNREFERENCED_PARAMETER(DrmRights); + + // + // At this point the driver should enforce the new DrmRights. + // + // HDMI render: if DigitalOutputDisable or CopyProtect is true, enable HDCP. + // + // From MSDN: + // + // This sample doesn't forward protected content, but if your driver uses + // lower layer drivers or a different stack to properly work, please see the + // following info from MSDN: + // + // "Before allowing protected content to flow through a data path, the system + // verifies that the data path is secure. To do so, the system authenticates + // each module in the data path beginning at the upstream end of the data path + // and moving downstream. As each module is authenticated, that module gives + // the system information about the next module in the data path so that it + // can also be authenticated. To be successfully authenticated, a module's + // binary file must be signed as DRM-compliant. + // + // Two adjacent modules in the data path can communicate with each other in + // one of several ways. If the upstream module calls the downstream module + // through IoCallDriver, the downstream module is part of a WDM driver. In + // this case, the upstream module calls the AcxDrmForwardContentToDeviceObject + // function to provide the system with the device object representing the + // downstream module. (If the two modules communicate through the downstream + // module's content handlers, the upstream module calls AcxDrmAddContentHandlers + // instead.) + // + // For more information, see MSDN's DRM Functions and Interfaces. + // + + return STATUS_SUCCESS; +} + +_Use_decl_annotations_ +PAGED_CODE_SEG +NTSTATUS +CStreamEngine::GetHWLatency( + _Out_ ULONG* FifoSize, + _Out_ ULONG* Delay +) +{ + PAGED_CODE(); + + *FifoSize = 128; + *Delay = 0; + + return STATUS_SUCCESS; +} + +_Use_decl_annotations_ +PAGED_CODE_SEG +CSimPeakMeter * +CStreamEngine::GetPeakMeter() +{ + PAGED_CODE(); + + return &m_PeakMeter; +} + +_Use_decl_annotations_ +VOID +CStreamEngine::s_EvtStreamPassCallback( + _In_ WDFTIMER Timer +) +{ + CStreamEngine* This; + PSTREAM_TIMER_CONTEXT timerCtx; + + // Get our stream engine pointer from the timer context + timerCtx = GetStreamTimerContext(Timer); + This = timerCtx->StreamEngine; + + // Call the StreamPassCallback for the engine + This->StreamPassCallback(); +} + +// This is run every time the stream timer fires +_Use_decl_annotations_ +VOID +CStreamEngine::StreamPassCallback() +{ + ULONGLONG completedPacket; + ULONGLONG qpcCompleted; + + // Process the packet (e.g. save render to file/generate capture data) + ProcessPacket(); + + // We've completed a packet! Increment our currently active packet + completedPacket = (ULONG)InterlockedIncrement((LONG*)&m_CurrentPacket) - 1; + // Save the time at which we moved to the next packet + qpcCompleted = (ULONGLONG)KeQueryPerformanceCounter(NULL).QuadPart; + + InterlockedExchange64(&m_LastPacketStart.QuadPart, m_CurrentPacketStart.QuadPart); + InterlockedExchange64(&m_CurrentPacketStart.QuadPart, qpcCompleted); + + // Tell ACX we've completed the packet. + (void)AcxRtStreamNotifyPacketComplete(m_Stream, completedPacket, qpcCompleted); + + // Schedule when our new current packet will finish + ScheduleNextPass(); +} + +_Use_decl_annotations_ +VOID +CStreamEngine::ScheduleNextPass() +{ + LONGLONG delay = 0; + ULONG bytesPerSecond; + ULONGLONG nextPacket = 0; + ULONGLONG nextPacketStartPosition = 0; + ULONGLONG nextPacketPositionFromLastPause = 0; + ULONGLONG nextPacketTimeFromLastPauseHns = 0; + ULONGLONG nextPacketTime = 0; + ULONGLONG currentTime; + BOOLEAN inTimerQueue = FALSE; + + // Get the number of bytes per second from our stored stream format + bytesPerSecond = GetBytesPerSecond(); + + // Calculate the absolute position of the beginning of the next packet from the beginning of the stream + nextPacket = m_CurrentPacket + 1; + nextPacketStartPosition = nextPacket * m_PacketSize; + + // Adjust next packet position to account for the last time we resumed from Pause + nextPacketPositionFromLastPause = nextPacketStartPosition - m_StartPosition; + + // Convert from bytes to HNS (to prevent truncation, multiply first then divide) + nextPacketTimeFromLastPauseHns = nextPacketPositionFromLastPause * HNS_PER_SEC / bytesPerSecond; + + // Next packet time is Time @ resume from Pause, offset for lost time due to glitch, with next packet time added + nextPacketTime = m_StartTime + m_GlitchAdjust + nextPacketTimeFromLastPauseHns; + + currentTime = KSCONVERT_PERFORMANCE_TIME(m_PerformanceCounterFrequency.QuadPart, KeQueryPerformanceCounter(NULL)); + + // Determine how long we want to wait, in HNS. Negative since it's a relative wait + delay = -(LONGLONG)(nextPacketTime - currentTime); + + // If the delay isn't negative, this means we lost some time (e.g. broken into kernel debugger). Update + // our glitch adjust to account for that lost time, and attempt to schedule again + if (delay >= 0) + { + // Glitch!!! + // Update the glitch adjustment and set the new delay. + m_GlitchAdjust += delay; + + StreamPassCallback(); + + return; + } + + // Start the timer for our next pass! Note the timer isn't periodic. + inTimerQueue = WdfTimerStart(m_NotificationTimer, delay); + + // We shouldn't be scheduling our next pass if the timer was previously still pending + ASSERT(inTimerQueue == FALSE); +} + +_Use_decl_annotations_ +VOID +CStreamEngine::UpdatePosition() +{ + ULONGLONG currentTime; + ULONG bytesPerSecond; + + if (m_CurrentState != AcxStreamStateRun) + { + return; + } + bytesPerSecond = GetBytesPerSecond(); + currentTime = KSCONVERT_PERFORMANCE_TIME(m_PerformanceCounterFrequency.QuadPart, KeQueryPerformanceCounter(NULL)); + + // Update position + m_Position = m_StartPosition - m_GlitchAdjust + (currentTime - m_StartTime) * bytesPerSecond / HNS_PER_SEC; +} + +_Use_decl_annotations_ +ULONG +CStreamEngine::GetBytesPerSecond() +{ + ULONG bytesPerSecond; + + bytesPerSecond = AcxDataFormatGetAverageBytesPerSec(m_StreamFormat); + + return bytesPerSecond; +} + +_Use_decl_annotations_ +PAGED_CODE_SEG +NTSTATUS +CStreamEngine::GetCurrentPacket( + _Out_ PULONG CurrentPacket +) +{ + ULONG currentPacket; + PAGED_CODE(); + + currentPacket = (ULONG)InterlockedCompareExchange((LONG*)&m_CurrentPacket, -1, -1); + + *CurrentPacket = currentPacket; + + return STATUS_SUCCESS; +} + + + +_Use_decl_annotations_ +PAGED_CODE_SEG +CCaptureStreamEngine::CCaptureStreamEngine( + _In_ ACXSTREAM Stream, + _In_ ACXDATAFORMAT StreamFormat +) + : CStreamEngine(Stream, StreamFormat, FALSE, NULL) +{ + PAGED_CODE(); + + m_CurrentPacketStart.QuadPart = 0; + m_LastPacketStart.QuadPart = 0; +} + +_Use_decl_annotations_ +PAGED_CODE_SEG +CCaptureStreamEngine::~CCaptureStreamEngine() +{ + PAGED_CODE(); +} + +_Use_decl_annotations_ +PAGED_CODE_SEG +NTSTATUS +CCaptureStreamEngine::PrepareHardware() +{ + PAGED_CODE(); + + // LibrePods: capture data comes from the mic pipe (see ProcessPacket), so there + // is no wave-file reader or tone generator source to initialize here. + return CStreamEngine::PrepareHardware(); +} + +_Use_decl_annotations_ +PAGED_CODE_SEG +NTSTATUS +CCaptureStreamEngine::ReleaseHardware() +{ + PAGED_CODE(); + + return CStreamEngine::ReleaseHardware(); +} + +_Use_decl_annotations_ +PAGED_CODE_SEG +NTSTATUS +CCaptureStreamEngine::GetCapturePacket( + _Out_ ULONG* LastCapturePacket, + _Out_ ULONGLONG* QPCPacketStart, + _Out_ BOOLEAN* MoreData +) +{ + NTSTATUS status = STATUS_SUCCESS; + ULONG currentPacket; + LONGLONG qpcPacketStart; + + PAGED_CODE(); + + currentPacket = (ULONG)InterlockedCompareExchange((LONG*)&m_CurrentPacket, -1, -1); + qpcPacketStart = InterlockedCompareExchange64(&m_LastPacketStart.QuadPart, -1, -1); + + *LastCapturePacket = currentPacket - 1; + *QPCPacketStart = (ULONGLONG)qpcPacketStart; + *MoreData = FALSE; + + return status; +} + +_Use_decl_annotations_ +VOID +CCaptureStreamEngine::ProcessPacket() +{ + ULONG currentPacket; + ULONG packetIndex; + PBYTE packetBuffer; + + currentPacket = (ULONG)InterlockedCompareExchange((LONG*)&m_CurrentPacket, -1, -1); + + packetIndex = currentPacket % m_PacketsCount; + packetBuffer = (PBYTE)m_Packets[packetIndex]; + + // Packet 0 starts at an offset if the size isn't a multiple of page_size + if (packetIndex == 0) + { + packetBuffer += m_FirstPacketOffset; + } + + // LibrePods: fill this capture packet from the mic pipe — the PCM that user + // mode pushed over IOCTL_LIBREPODS_MIC_WRITE_PCM (the decoded AirPods audio). + // Underrun is zero-filled (silence). Replaces the sample's WAV-file / tone + // dummy sources. + MicPipeRead(packetBuffer, m_PacketSize); +} + + +//Streamengine callbacks + +VOID +EvtStreamDestroy( + _In_ WDFOBJECT Object +) +{ + PSTREAMENGINE_CONTEXT ctx; + CStreamEngine* streamEngine = NULL; + + ctx = GetStreamEngineContext((ACXSTREAM)Object); + + streamEngine = (CStreamEngine*)ctx->StreamEngine; + ctx->StreamEngine = NULL; + delete streamEngine; +} + +PAGED_CODE_SEG +NTSTATUS +EvtStreamGetHwLatency( + _In_ ACXSTREAM Stream, + _Out_ ULONG* FifoSize, + _Out_ ULONG* Delay +) +{ + PSTREAMENGINE_CONTEXT ctx; + CStreamEngine* streamEngine = NULL; + + PAGED_CODE(); + + ctx = GetStreamEngineContext(Stream); + + streamEngine = (CStreamEngine*)ctx->StreamEngine; + + return streamEngine->GetHWLatency(FifoSize, Delay); +} + +PAGED_CODE_SEG +NTSTATUS +EvtStreamAllocateRtPackets( + _In_ ACXSTREAM Stream, + _In_ ULONG PacketCount, + _In_ ULONG PacketSize, + _Out_ PACX_RTPACKET* Packets +) +{ + PSTREAMENGINE_CONTEXT ctx; + CStreamEngine* streamEngine = NULL; + + PAGED_CODE(); + + ctx = GetStreamEngineContext(Stream); + + streamEngine = (CStreamEngine*)ctx->StreamEngine; + + return streamEngine->AllocateRtPackets(PacketCount, PacketSize, Packets); +} + +PAGED_CODE_SEG +VOID +EvtStreamFreeRtPackets( + _In_ ACXSTREAM Stream, + _In_ PACX_RTPACKET Packets, + _In_ ULONG PacketCount +) +{ + PSTREAMENGINE_CONTEXT ctx; + CStreamEngine* streamEngine = NULL; + + PAGED_CODE(); + + ctx = GetStreamEngineContext(Stream); + + streamEngine = (CStreamEngine*)ctx->StreamEngine; + + return streamEngine->FreeRtPackets(Packets, PacketCount); +} + +PAGED_CODE_SEG +NTSTATUS +EvtStreamPrepareHardware( + _In_ ACXSTREAM Stream +) +{ + PSTREAMENGINE_CONTEXT ctx; + CStreamEngine* streamEngine = NULL; + + PAGED_CODE(); + + ctx = GetStreamEngineContext(Stream); + + streamEngine = (CStreamEngine*)ctx->StreamEngine; + + return streamEngine->PrepareHardware(); +} + +PAGED_CODE_SEG +NTSTATUS +EvtStreamReleaseHardware( + _In_ ACXSTREAM Stream +) +{ + PSTREAMENGINE_CONTEXT ctx; + CStreamEngine* streamEngine = NULL; + + PAGED_CODE(); + + ctx = GetStreamEngineContext(Stream); + + streamEngine = (CStreamEngine*)ctx->StreamEngine; + + return streamEngine->ReleaseHardware(); +} + +PAGED_CODE_SEG +NTSTATUS +EvtStreamRun( + _In_ ACXSTREAM Stream +) +{ + PSTREAMENGINE_CONTEXT ctx; + CStreamEngine* streamEngine = NULL; + + PAGED_CODE(); + + ctx = GetStreamEngineContext(Stream); + + streamEngine = (CStreamEngine*)ctx->StreamEngine; + + return streamEngine->Run(); +} + + +PAGED_CODE_SEG +NTSTATUS +EvtStreamPause( + _In_ ACXSTREAM Stream +) +{ + PSTREAMENGINE_CONTEXT ctx; + CStreamEngine* streamEngine = NULL; + + PAGED_CODE(); + + ctx = GetStreamEngineContext(Stream); + + streamEngine = (CStreamEngine*)ctx->StreamEngine; + + return streamEngine->Pause(); +} + +PAGED_CODE_SEG +NTSTATUS +EvtStreamAssignDrmContentId( + _In_ ACXSTREAM Stream, + _In_ ULONG DrmContentId, + _In_ PACXDRMRIGHTS DrmRights +) +{ + PSTREAMENGINE_CONTEXT ctx; + CStreamEngine * streamEngine = NULL; + + PAGED_CODE(); + + ctx = GetStreamEngineContext(Stream); + + streamEngine = (CStreamEngine*)ctx->StreamEngine; + + return streamEngine->AssignDrmContentId(DrmContentId, DrmRights); +} + +PAGED_CODE_SEG +NTSTATUS +EvtStreamGetCurrentPacket( + _In_ ACXSTREAM Stream, + _Out_ PULONG CurrentPacket +) +{ + PSTREAMENGINE_CONTEXT ctx; + CStreamEngine* streamEngine = NULL; + + PAGED_CODE(); + + ctx = GetStreamEngineContext(Stream); + + streamEngine = static_cast(ctx->StreamEngine); + + return streamEngine->GetCurrentPacket(CurrentPacket); +} + +PAGED_CODE_SEG +NTSTATUS +EvtStreamGetPresentationPosition( + _In_ ACXSTREAM Stream, + _Out_ PULONGLONG PositionInBlocks, + _Out_ PULONGLONG QPCPosition +) +{ + PSTREAMENGINE_CONTEXT ctx; + CStreamEngine* streamEngine = NULL; + + PAGED_CODE(); + + ctx = GetStreamEngineContext(Stream); + + streamEngine = static_cast(ctx->StreamEngine); + + return streamEngine->GetPresentationPosition(PositionInBlocks, QPCPosition); +} + diff --git a/windows/drivers/mic/Common/StreamEngine.h b/windows/drivers/mic/Common/StreamEngine.h new file mode 100644 index 000000000..e2b38eee2 --- /dev/null +++ b/windows/drivers/mic/Common/StreamEngine.h @@ -0,0 +1,333 @@ +#pragma once + +#include "SimPeakMeter.h" + +#define HNSTIME_PER_MILLISECOND 10000 + +#define MAX_PACKET_COUNT 2 + +#define DEFAULT_FREQUENCY 220 +#define LOOPBACK_FREQUENCY 500 + +// Stream callbacks shared between Capture and Render + +VOID +EvtStreamDestroy( + _In_ WDFOBJECT Object +); + +PAGED_CODE_SEG +NTSTATUS +EvtStreamGetHwLatency( + _In_ ACXSTREAM Stream, + _Out_ ULONG* FifoSize, + _Out_ ULONG* Delay +); + +PAGED_CODE_SEG +NTSTATUS +EvtStreamAllocateRtPackets( + _In_ ACXSTREAM Stream, + _In_ ULONG PacketCount, + _In_ ULONG PacketSize, + _Out_ PACX_RTPACKET* Packets +); + +PAGED_CODE_SEG +VOID +EvtStreamFreeRtPackets( + _In_ ACXSTREAM Stream, + _In_ PACX_RTPACKET Packets, + _In_ ULONG PacketCount +); + +PAGED_CODE_SEG +NTSTATUS +EvtStreamPrepareHardware( + _In_ ACXSTREAM Stream +); + +PAGED_CODE_SEG +NTSTATUS +EvtStreamReleaseHardware( + _In_ ACXSTREAM Stream +); + +PAGED_CODE_SEG +NTSTATUS +EvtStreamRun( + _In_ ACXSTREAM Stream +); + +PAGED_CODE_SEG +NTSTATUS +EvtStreamPause( + _In_ ACXSTREAM Stream +); + +PAGED_CODE_SEG +NTSTATUS +EvtStreamAssignDrmContentId( + _In_ ACXSTREAM Stream, + _In_ ULONG DrmContentId, + _In_ PACXDRMRIGHTS DrmRights +); + +PAGED_CODE_SEG +NTSTATUS +EvtStreamGetCurrentPacket( + _In_ ACXSTREAM Stream, + _Out_ PULONG CurrentPacket +); + +PAGED_CODE_SEG +NTSTATUS +EvtStreamGetPresentationPosition( + _In_ ACXSTREAM Stream, + _Out_ PULONGLONG PositionInBlocks, + _Out_ PULONGLONG QPCPosition +); + + +class CStreamEngine +{ +public: + virtual + __drv_maxIRQL(PASSIVE_LEVEL) + PAGED_CODE_SEG + NTSTATUS + AllocateRtPackets( + _In_ ULONG PacketCount, + _In_ ULONG PacketSize, + _Out_ PACX_RTPACKET * Packets + ); + + virtual + __drv_maxIRQL(PASSIVE_LEVEL) + PAGED_CODE_SEG + VOID + FreeRtPackets( + _Frees_ptr_ PACX_RTPACKET Packets, + _In_ ULONG PacketCount + ); + + virtual + __drv_maxIRQL(PASSIVE_LEVEL) + PAGED_CODE_SEG + NTSTATUS + PrepareHardware(); + + virtual + __drv_maxIRQL(PASSIVE_LEVEL) + PAGED_CODE_SEG + NTSTATUS + ReleaseHardware(); + + virtual + __drv_maxIRQL(PASSIVE_LEVEL) + PAGED_CODE_SEG + NTSTATUS + Run(); + + virtual + __drv_maxIRQL(PASSIVE_LEVEL) + PAGED_CODE_SEG + NTSTATUS + Pause(); + + virtual + __drv_maxIRQL(PASSIVE_LEVEL) + PAGED_CODE_SEG + NTSTATUS + GetPresentationPosition( + _Out_ PULONGLONG PositionInBlocks, + _Out_ PULONGLONG QPCPosition + ); + + virtual + __drv_maxIRQL(PASSIVE_LEVEL) + PAGED_CODE_SEG + NTSTATUS + GetLinearBufferPosition( + _Out_ PULONGLONG Position + ); + + virtual + __drv_maxIRQL(PASSIVE_LEVEL) + PAGED_CODE_SEG + NTSTATUS + SetCurrentWritePosition( + _In_ ULONG Position + ); + + virtual + __drv_maxIRQL(PASSIVE_LEVEL) + PAGED_CODE_SEG + NTSTATUS + SetLastBufferPosition( + _In_ ULONG Position + ); + + virtual + __drv_maxIRQL(PASSIVE_LEVEL) + PAGED_CODE_SEG + NTSTATUS + GetCurrentPacket( + _Out_ PULONG CurrentPacket + ); + + virtual + __drv_maxIRQL(PASSIVE_LEVEL) + PAGED_CODE_SEG + NTSTATUS + GetHWLatency( + _Out_ ULONG * FifoSize, + _Out_ ULONG * Delay + ); + + virtual + __drv_maxIRQL(PASSIVE_LEVEL) + PAGED_CODE_SEG + NTSTATUS + AssignDrmContentId( + _In_ ULONG DrmContentId, + _In_ PACXDRMRIGHTS DrmRights + ); + + virtual + __drv_maxIRQL(PASSIVE_LEVEL) + PAGED_CODE_SEG + CSimPeakMeter * + GetPeakMeter(); + + __drv_maxIRQL(PASSIVE_LEVEL) + PAGED_CODE_SEG + CStreamEngine( + _In_ ACXSTREAM Stream, + _In_ ACXDATAFORMAT StreamFormat, + _In_ BOOL Offload, + _In_opt_ CSimPeakMeter *CircuitPeakmeter + ); + + virtual + __drv_maxIRQL(PASSIVE_LEVEL) + PAGED_CODE_SEG + ~CStreamEngine(); + + __drv_maxIRQL(PASSIVE_LEVEL) + PAGED_CODE_SEG + VOID + SetFrequency( + _In_ DWORD ToneFrequency + ) + { + PAGED_CODE(); + + m_ToneFrequency = ToneFrequency; + } + +protected: + PVOID m_Packets[MAX_PACKET_COUNT]; + ULONG m_PacketsCount; + ULONG m_PacketSize; + ULONG m_FirstPacketOffset; + WDFTIMER m_NotificationTimer; + ACX_STREAM_STATE m_CurrentState; + ULONG m_CurrentPacket; + ULONGLONG m_Position; + ACXSTREAM m_Stream; + ACXDATAFORMAT m_StreamFormat; + ULONGLONG m_StartTime; + ULONGLONG m_StartPosition; + ULONGLONG m_GlitchAdjust; + LARGE_INTEGER m_PerformanceCounterFrequency; + LARGE_INTEGER m_CurrentPacketStart; + LARGE_INTEGER m_LastPacketStart; + DWORD m_ToneFrequency; + BOOL m_Offload; + CSimPeakMeter m_PeakMeter; + CSimPeakMeter* m_pCircuitPeakmeter; + + static + __drv_maxIRQL(DISPATCH_LEVEL) + _Function_class_(EVT_WDF_TIMER) + VOID s_EvtStreamPassCallback( + _In_ WDFTIMER Timer + ); + + // This is run every time the stream timer fires + virtual + __drv_maxIRQL(DISPATCH_LEVEL) + VOID + StreamPassCallback(); + + virtual + __drv_maxIRQL(DISPATCH_LEVEL) + VOID + ScheduleNextPass(); + + virtual + __drv_maxIRQL(DISPATCH_LEVEL) + VOID + UpdatePosition(); + + virtual + __drv_maxIRQL(DISPATCH_LEVEL) + ULONG + GetBytesPerSecond(); + + virtual + __drv_maxIRQL(DISPATCH_LEVEL) + VOID + ProcessPacket() = 0; +}; + +class CCaptureStreamEngine : public CStreamEngine +{ +public: + __drv_maxIRQL(PASSIVE_LEVEL) + PAGED_CODE_SEG + CCaptureStreamEngine( + _In_ ACXSTREAM Stream, + _In_ ACXDATAFORMAT StreamFormat + ); + + __drv_maxIRQL(PASSIVE_LEVEL) + PAGED_CODE_SEG + ~CCaptureStreamEngine(); + + virtual + __drv_maxIRQL(PASSIVE_LEVEL) + PAGED_CODE_SEG + NTSTATUS + PrepareHardware(); + + virtual + __drv_maxIRQL(PASSIVE_LEVEL) + PAGED_CODE_SEG + NTSTATUS + ReleaseHardware(); + + __drv_maxIRQL(PASSIVE_LEVEL) + PAGED_CODE_SEG + NTSTATUS + GetCapturePacket( + _Out_ ULONG * LastCapturePacket, + _Out_ ULONGLONG * QPCPacketStart, + _Out_ BOOLEAN * MoreData + ); + +protected: + virtual + __drv_maxIRQL(DISPATCH_LEVEL) + VOID + ProcessPacket(); +}; + +// Define circuit/stream pin context. +// +typedef struct _STREAM_TIMER_CONTEXT { + CStreamEngine * StreamEngine; +} STREAM_TIMER_CONTEXT, *PSTREAM_TIMER_CONTEXT; + +WDF_DECLARE_CONTEXT_TYPE_WITH_NAME(STREAM_TIMER_CONTEXT, GetStreamTimerContext) diff --git a/windows/drivers/mic/Common/Trace_macros.h b/windows/drivers/mic/Common/Trace_macros.h new file mode 100644 index 000000000..c2fc3efff --- /dev/null +++ b/windows/drivers/mic/Common/Trace_macros.h @@ -0,0 +1,470 @@ +#pragma once + +#include // for va_start, etc. + +#pragma region Tracing level definitions + +#if !defined(FAILED_NTSTATUS) +#define FAILED_NTSTATUS(status) (((NTSTATUS)(status)) < 0) +#endif + +#if !defined(SUCCEEDED_NTSTATUS) +#define SUCCEEDED_NTSTATUS(status) (((NTSTATUS)(status)) >= 0) +#endif + +//! Define shorter versions of the ETW trace levels +#define LEVEL_CRITICAL TRACE_LEVEL_CRITICAL +#define LEVEL_ERROR TRACE_LEVEL_ERROR +#define LEVEL_WARNING TRACE_LEVEL_WARNING +#define LEVEL_INFO TRACE_LEVEL_INFORMATION +#define LEVEL_VERBOSE TRACE_LEVEL_VERBOSE + +//! This is a special LEVEL that changes the trace macro level from ERROR to VERBOSE +//! depending on whether the return value passed to the macro was non-zero or zero, +//! respectively. +#define LEVEL_COND 0xFF +#pragma endregion + +//! Logger and Enabled that supports both level and flag. +//! \link https://msdn.microsoft.com/en-us/library/windows/hardware/ff542492(v=vs.85).aspx +#define WPP_LEVEL_FLAGS_LOGGER(LEVEL, FLAGS) WPP_LEVEL_LOGGER(FLAGS) +#define WPP_LEVEL_FLAGS_ENABLED(LEVEL, FLAGS) (WPP_LEVEL_ENABLED(FLAGS) && (WPP_CONTROL(WPP_BIT_ ## FLAGS).Level >= LEVEL)) + +//! This macro is to be used by the WPP custom macros below that want to do conditional +//! logging based on return value. If LEVEL_VERBOSE is specified when calling a macro that +//! uses this, the level will be set to LEVEL_INFO if return code is 0 or +//! LEVEL_ERROR if the return code is not 0. This can be called in any PRE macro. +//! +//! The "LEVEL == LEVEL_COND" check generates a compiler warning that the "conditional +//! expression is constant" so we explicitly disable that. +#define WPP_CONDITIONAL_LEVEL_FLAGS_OVERRIDE(LEVEL, FLAGS, HR) \ + BOOL bEnabled = WPP_LEVEL_FLAGS_ENABLED(LEVEL, FLAGS); \ + __pragma(warning(push)) \ + __pragma(warning(disable: 4127)) \ + if (LEVEL == LEVEL_COND) \ + { \ + if (SUCCEEDED(HR)) \ + { \ + bEnabled = WPP_LEVEL_FLAGS_ENABLED(LEVEL_VERBOSE, FLAGS); \ + } \ + else \ + { \ + bEnabled = WPP_LEVEL_FLAGS_ENABLED(LEVEL_ERROR, FLAGS); \ + } \ + } \ + __pragma(warning(pop)) + +#define WPP_CONDITIONAL_LEVEL_FLAGS_OVERRIDE_NTSTATUS(LEVEL, FLAGS, STATUS) \ + BOOLEAN bEnabled = WPP_LEVEL_FLAGS_ENABLED(LEVEL, FLAGS); \ + __pragma(warning(push)) \ + __pragma(warning(disable: 4127)) \ + if (LEVEL == LEVEL_COND) \ + { \ + if (SUCCEEDED_NTSTATUS(STATUS)) \ + { \ + bEnabled = WPP_LEVEL_FLAGS_ENABLED(LEVEL_VERBOSE, FLAGS); \ + } \ + else \ + { \ + bEnabled = WPP_LEVEL_FLAGS_ENABLED(LEVEL_ERROR, FLAGS); \ + } \ + } \ + __pragma(warning(pop)) + + +#define WPP_LEVEL_FLAGS_IFRLOG_ENABLED(LEVEL, FLAGS, IFRLOG) WPP_LEVEL_FLAGS_ENABLED(LEVEL, FLAGS) +#define WPP_LEVEL_FLAGS_IFRLOG_LOGGER(LEVEL, FLAGS, IFRLOG) WPP_LEVEL_FLAGS_LOGGER(LEVEL, FLAGS) +#define WPP_LEVEL_IFRLOG_FLAGS_ENABLED(LEVEL, IFRLOG, FLAGS) WPP_LEVEL_FLAGS_ENABLED(LEVEL, FLAGS) +#define WPP_LEVEL_IFRLOG_FLAGS_LOGGER(LEVEL, IFRLOG, FLAGS) WPP_LEVEL_FLAGS_LOGGER(LEVEL, FLAGS) + +#define WPP_LEVEL_FLAGS_HR_PRE(LEVEL, FLAGS, HR) { WPP_CONDITIONAL_LEVEL_FLAGS_OVERRIDE(LEVEL, FLAGS, HR) +#define WPP_LEVEL_FLAGS_HR_POST(LEVEL, FLAGS, HR) ;} +#define WPP_LEVEL_FLAGS_HR_ENABLED(LEVEL, FLAGS, HR) bEnabled +#define WPP_LEVEL_FLAGS_HR_LOGGER(LEVEL, FLAGS, HR) WPP_LEVEL_FLAGS_LOGGER(LEVEL, FLAGS) + +#define WPP_LEVEL_FLAGS_RETVAL_ENABLED(LEVEL, FLAGS, RETVAL) WPP_LEVEL_FLAGS_ENABLED(LEVEL, FLAGS) +#define WPP_LEVEL_FLAGS_RETVAL_LOGGER(LEVEL, FLAGS, RETVAL) WPP_LEVEL_FLAGS_LOGGER(LEVEL, FLAGS) + +#define WPP_LEVEL_FLAGS_FI_ENABLED(LEVEL, FLAGS, FI) WPP_LEVEL_FLAGS_ENABLED(LEVEL, FLAGS) +#define WPP_LEVEL_FLAGS_FI_LOGGER(LEVEL, FLAGS, FI) WPP_LEVEL_FLAGS_LOGGER(LEVEL, FLAGS) + +#define WPP_LEVEL_FLAGS_STATUS_PRE(LEVEL, FLAGS, STATUS) { WPP_CONDITIONAL_LEVEL_FLAGS_OVERRIDE_NTSTATUS(LEVEL, FLAGS, STATUS) +#define WPP_LEVEL_FLAGS_STATUS_POST(LEVEL, FLAGS, STATUS) ;} +#define WPP_LEVEL_FLAGS_STATUS_ENABLED(LEVEL, FLAGS, STATUS) bEnabled +#define WPP_LEVEL_FLAGS_STATUS_LOGGER(LEVEL, FLAGS, STATUS) WPP_LEVEL_FLAGS_LOGGER(LEVEL, FLAGS) + +#define WPP_LEVEL_FLAGS_RETSTATUS_PRE(LEVEL, FLAGS, RETSTATUS) do { NTSTATUS __statusRet = (RETSTATUS); if (FAILED_NTSTATUS(__statusRet)) { +#define WPP_LEVEL_FLAGS_RETSTATUS_POST(LEVEL, FLAGS, RETSTATUS) ; return __statusRet; } } while (0, 0) +#define WPP_LEVEL_FLAGS_RETSTATUS_ENABLED(LEVEL, FLAGS, RETSTATUS) WPP_LEVEL_FLAGS_ENABLED(LEVEL, FLAGS) +#define WPP_LEVEL_FLAGS_RETSTATUS_LOGGER(LEVEL, FLAGS, RETSTATUS) WPP_LEVEL_FLAGS_LOGGER(LEVEL, FLAGS) + +#define WPP_LEVEL_FLAGS_IFRLOG_RETSTATUS_PRE(LEVEL, FLAGS, IFRLOG, RETSTATUS) do { NTSTATUS __statusRet = (RETSTATUS); if (FAILED_NTSTATUS(__statusRet)) { +#define WPP_LEVEL_FLAGS_IFRLOG_RETSTATUS_POST(LEVEL, FLAGS, IFRLOG, RETSTATUS) ; return __statusRet; } } while (0, 0) +#define WPP_LEVEL_FLAGS_IFRLOG_RETSTATUS_ENABLED(LEVEL, FLAGS, IFRLOG, RETSTATUS) WPP_LEVEL_FLAGS_ENABLED(LEVEL, FLAGS) +#define WPP_LEVEL_FLAGS_IFRLOG_RETSTATUS_LOGGER(LEVEL, FLAGS, IFRLOG, RETSTATUS) WPP_LEVEL_FLAGS_LOGGER(LEVEL, FLAGS) + +#define WPP_LEVEL_FLAGS_IFRLOG_RETSTATUS_ALLOWEDSTATUS_PRE(LEVEL, FLAGS, IFRLOG, RETSTATUS, ALLOWEDSTATUS) do {\ +NTSTATUS __statusRet = (RETSTATUS);\ +if(__statusRet == ALLOWEDSTATUS)\ +{\ + __statusRet = STATUS_SUCCESS;\ +}\ +if (FAILED_NTSTATUS(__statusRet)) { +#define WPP_LEVEL_FLAGS_IFRLOG_RETSTATUS_ALLOWEDSTATUS_POST(LEVEL, FLAGS, IFRLOG, RETSTATUS, ALLOWEDSTATUS) ; return __statusRet; } } while (0, 0) +#define WPP_LEVEL_FLAGS_IFRLOG_RETSTATUS_ALLOWEDSTATUS_ENABLED(LEVEL, FLAGS, IFRLOG, RETSTATUS, ALLOWEDSTATUS) WPP_LEVEL_FLAGS_ENABLED(LEVEL, FLAGS) +#define WPP_LEVEL_FLAGS_IFRLOG_RETSTATUS_ALLOWEDSTATUS_LOGGER(LEVEL, FLAGS, IFRLOG, RETSTATUS, ALLOWEDSTATUS) WPP_LEVEL_FLAGS_LOGGER(LEVEL, FLAGS) + +#define WPP_LEVEL_FLAGS_RETPTR_PRE(LEVEL, FLAGS, RETPTR) do { if ((RETPTR) == nullptr) { +#define WPP_LEVEL_FLAGS_RETPTR_POST(LEVEL, FLAGS, RETPTR) ; return STATUS_INSUFFICIENT_RESOURCES; } } while (0, 0) +#define WPP_LEVEL_FLAGS_RETPTR_ENABLED(LEVEL, FLAGS, RETPTR) WPP_LEVEL_FLAGS_ENABLED(LEVEL, FLAGS) +#define WPP_LEVEL_FLAGS_RETPTR_LOGGER(LEVEL, FLAGS, RETPTR) WPP_LEVEL_FLAGS_LOGGER(LEVEL, FLAGS) + +#define WPP_LEVEL_FLAGS_RETSTATUS_RETPTR_PRE(LEVEL, FLAGS, RETSTATUS, RETPTR) do { NTSTATUS __statusRet = (RETSTATUS); if ((RETPTR) == nullptr) { +#define WPP_LEVEL_FLAGS_RETSTATUS_RETPTR_POST(LEVEL, FLAGS, RETSTATUS, RETPTR) ; return __statusRet; } } while (0, 0) +#define WPP_LEVEL_FLAGS_RETSTATUS_RETPTR_ENABLED(LEVEL, FLAGS, RETSTATUS, RETPTR) WPP_LEVEL_FLAGS_ENABLED(LEVEL, FLAGS) +#define WPP_LEVEL_FLAGS_RETSTATUS_RETPTR_LOGGER(LEVEL, FLAGS, RETSTATUS, RETPTR) WPP_LEVEL_FLAGS_LOGGER(LEVEL, FLAGS) + +#define WPP_LEVEL_FLAGS_RETSTATUS_POSCOND_PRE(LEVEL, FLAGS, RETSTATUS, POSCOND) do { NTSTATUS __statusRet = (RETSTATUS); if ((POSCOND)) { +#define WPP_LEVEL_FLAGS_RETSTATUS_POSCOND_POST(LEVEL, FLAGS, RETSTATUS, POSCOND) ; return __statusRet; } } while (0, 0) +#define WPP_LEVEL_FLAGS_RETSTATUS_POSCOND_ENABLED(LEVEL, FLAGS, RETSTATUS, POSCOND) WPP_LEVEL_FLAGS_ENABLED(LEVEL, FLAGS) +#define WPP_LEVEL_FLAGS_RETSTATUS_POSCOND_LOGGER(LEVEL, FLAGS, RETSTATUS, POSCOND) WPP_LEVEL_FLAGS_LOGGER(LEVEL, FLAGS) + +#define WPP_LEVEL_FLAGS_IFRLOG_POSCOND_RETSTATUS_PRE(LEVEL, FLAGS, IFRLOG, POSCOND, RETSTATUS) do { NTSTATUS __statusRet = (RETSTATUS); if ((POSCOND)) { +#define WPP_LEVEL_FLAGS_IFRLOG_POSCOND_RETSTATUS_POST(LEVEL, FLAGS, IFRLOG, POSCOND, RETSTATUS) ; return __statusRet; } } while (0, 0) +#define WPP_LEVEL_FLAGS_IFRLOG_POSCOND_RETSTATUS_ENABLED(LEVEL, FLAGS, IFRLOG, POSCOND, RETSTATUS) WPP_LEVEL_FLAGS_ENABLED(LEVEL, FLAGS) +#define WPP_LEVEL_FLAGS_IFRLOG_POSCOND_RETSTATUS_LOGGER(LEVEL, FLAGS, IFRLOG, POSCOND, RETSTATUS) WPP_LEVEL_FLAGS_LOGGER(LEVEL, FLAGS) + +#define WPP_LEVEL_FLAGS_RETSTATUS_NEGCOND_PRE(LEVEL, FLAGS, RETSTATUS, NEGCOND) do { NTSTATUS __statusRet = (RETSTATUS); if (!(NEGCOND)) { +#define WPP_LEVEL_FLAGS_RETSTATUS_NEGCOND_POST(LEVEL, FLAGS, RETSTATUS, NEGCOND) ; return __statusRet; } } while (0, 0) +#define WPP_LEVEL_FLAGS_RETSTATUS_NEGCOND_ENABLED(LEVEL, FLAGS, RETSTATUS, NEGCOND) WPP_LEVEL_FLAGS_ENABLED(LEVEL, FLAGS) +#define WPP_LEVEL_FLAGS_RETSTATUS_NEGCOND_LOGGER(LEVEL, FLAGS, RETSTATUS, NEGCOND) WPP_LEVEL_FLAGS_LOGGER(LEVEL, FLAGS) + +#pragma region IFR Enablement Macros + +// Opt-in to a WPP recorder feature that enables independent evaluation of conditions to decide if a +// message needs to be sent to the recorder, an enabled session, or both. +#define ENABLE_WPP_TRACE_FILTERING_WITH_WPP_RECORDER 1 + +// Logger/Enabled macros used to decide if a message that is being sent to a custom recorder should +// also go to an enabled session. These do not depend on the custom recorder itself, so just +// delegate to the default. +#define WPP_IFRLOG_LEVEL_FLAGS_LOGGER(IFRLOG, LEVEL, FLAGS) WPP_LEVEL_FLAGS_LOGGER(LEVEL, FLAGS) +#define WPP_IFRLOG_LEVEL_FLAGS_ENABLED(IFRLOG, LEVEL, FLAGS) WPP_LEVEL_FLAGS_ENABLED(LEVEL, FLAGS) + +#define WPP_RECORDER_CONDITIONAL_LEVEL_FLAGS_OVERRIDE(LEVEL, FLAGS, HR) \ + ((LEVEL == LEVEL_COND) ? \ + (FAILED(HR) ? \ + WPP_RECORDER_LEVEL_FLAGS_FILTER(LEVEL_ERROR, FLAGS) : WPP_RECORDER_LEVEL_FLAGS_FILTER(LEVEL_VERBOSE, FLAGS)) : \ + WPP_RECORDER_LEVEL_FLAGS_FILTER(LEVEL, FLAGS)) + +#define WPP_RECORDER_CONDITIONAL_LEVEL_FLAGS_OVERRIDE_NTSTATUS(LEVEL, FLAGS, STATUS) \ + ((LEVEL == LEVEL_COND) ? \ + (FAILED_NTSTATUS(STATUS) ? \ + WPP_RECORDER_LEVEL_FLAGS_FILTER(LEVEL_ERROR, FLAGS) : WPP_RECORDER_LEVEL_FLAGS_FILTER(LEVEL_VERBOSE, FLAGS)) : \ + WPP_RECORDER_LEVEL_FLAGS_FILTER(LEVEL, FLAGS)) + +#define WPP_RECORDER_LEVEL_FLAGS_HR_ARGS(LEVEL, FLAGS, RETVAL) WPP_RECORDER_LEVEL_FLAGS_ARGS(LEVEL, FLAGS) +#define WPP_RECORDER_LEVEL_FLAGS_HR_FILTER(LEVEL, FLAGS, RETVAL) WPP_RECORDER_LEVEL_FLAGS_FILTER(LEVEL, FLAGS) + +#define WPP_RECORDER_LEVEL_FLAGS_RETVAL_ARGS(LEVEL, FLAGS, RETVAL) WPP_RECORDER_LEVEL_FLAGS_ARGS(LEVEL, FLAGS) +#define WPP_RECORDER_LEVEL_FLAGS_RETVAL_FILTER(LEVEL, FLAGS, RETVAL) WPP_RECORDER_LEVEL_FLAGS_FILTER(LEVEL, FLAGS) + +#define WPP_RECORDER_LEVEL_FLAGS_FI_ARGS(LEVEL, FLAGS, RETVAL) WPP_RECORDER_LEVEL_FLAGS_ARGS(LEVEL, FLAGS) +#define WPP_RECORDER_LEVEL_FLAGS_FI_FILTER(LEVEL, FLAGS, RETVAL) WPP_RECORDER_LEVEL_FLAGS_FILTER(LEVEL, FLAGS) + +#define WPP_RECORDER_LEVEL_FLAGS_STATUS_ARGS(LEVEL, FLAGS, STATUS) WPP_RECORDER_LEVEL_FLAGS_ARGS(LEVEL, FLAGS) +#define WPP_RECORDER_LEVEL_FLAGS_STATUS_FILTER(LEVEL, FLAGS, STATUS) WPP_RECORDER_LEVEL_FLAGS_FILTER(LEVEL, FLAGS) + +#define WPP_RECORDER_LEVEL_FLAGS_RETSTATUS_ARGS(LEVEL, FLAGS, RETSTATUS) WPP_RECORDER_LEVEL_FLAGS_ARGS(LEVEL, FLAGS) +#define WPP_RECORDER_LEVEL_FLAGS_RETSTATUS_FILTER(LEVEL, FLAGS, RETSTATUS) WPP_RECORDER_LEVEL_FLAGS_FILTER(LEVEL, FLAGS) + +#define WPP_RECORDER_LEVEL_FLAGS_IFRLOG_RETSTATUS_ARGS(LEVEL, FLAGS, IFRLOG, RETSTATUS) WPP_RECORDER_LEVEL_FLAGS_ARGS(LEVEL, FLAGS) +#define WPP_RECORDER_LEVEL_FLAGS_IFRLOG_RETSTATUS_FILTER(LEVEL, FLAGS, IFRLOG, RETSTATUS) WPP_RECORDER_LEVEL_FLAGS_FILTER(LEVEL, FLAGS) + +#define WPP_RECORDER_LEVEL_FLAGS_IFRLOG_RETSTATUS_ALLOWEDSTATUS_ARGS(LEVEL, FLAGS, IFRLOG, RETSTATUS, ALLOWEDSTATUS) WPP_RECORDER_LEVEL_FLAGS_ARGS(LEVEL, FLAGS) +#define WPP_RECORDER_LEVEL_FLAGS_IFRLOG_RETSTATUS_ALLOWEDSTATUS_FILTER(LEVEL, FLAGS, IFRLOG, RETSTATUS, ALLOWEDSTATUS) WPP_RECORDER_LEVEL_FLAGS_FILTER(LEVEL, FLAGS) + +#define WPP_RECORDER_LEVEL_FLAGS_RETPTR_ARGS(LEVEL, FLAGS, RETPTR) WPP_RECORDER_LEVEL_FLAGS_ARGS(LEVEL, FLAGS) +#define WPP_RECORDER_LEVEL_FLAGS_RETPTR_FILTER(LEVEL, FLAGS, RETPTR) WPP_RECORDER_LEVEL_FLAGS_FILTER(LEVEL, FLAGS) + +#define WPP_RECORDER_LEVEL_FLAGS_RETSTATUS_RETPTR_ARGS(LEVEL, FLAGS, RETSTATUS, RETPTR) WPP_RECORDER_LEVEL_FLAGS_ARGS(LEVEL, FLAGS) +#define WPP_RECORDER_LEVEL_FLAGS_RETSTATUS_RETPTR_FILTER(LEVEL, FLAGS, RETSTATUS, RETPTR) WPP_RECORDER_LEVEL_FLAGS_FILTER(LEVEL, FLAGS) + +#define WPP_RECORDER_LEVEL_FLAGS_RETSTATUS_POSCOND_ARGS(LEVEL, FLAGS, RETSTATUS, POSCOND) WPP_RECORDER_LEVEL_FLAGS_ARGS(LEVEL, FLAGS) +#define WPP_RECORDER_LEVEL_FLAGS_RETSTATUS_POSCOND_FILTER(LEVEL, FLAGS, RETSTATUS, POSCOND) WPP_RECORDER_LEVEL_FLAGS_FILTER(LEVEL, FLAGS) + +#define WPP_RECORDER_LEVEL_FLAGS_IFRLOG_POSCOND_RETSTATUS_ARGS(LEVEL, FLAGS, IFRLOG, RETSTATUS, POSCOND) WPP_RECORDER_LEVEL_FLAGS_ARGS(LEVEL, FLAGS) +#define WPP_RECORDER_LEVEL_FLAGS_IFRLOG_POSCOND_RETSTATUS_FILTER(LEVEL, FLAGS, IFRLOG, RETSTATUS, POSCOND) WPP_RECORDER_LEVEL_FLAGS_FILTER(LEVEL, FLAGS) + +#define WPP_RECORDER_LEVEL_FLAGS_RETSTATUS_NEGCOND_ARGS(LEVEL, FLAGS, RETSTATUS, NEGCOND) WPP_RECORDER_LEVEL_FLAGS_ARGS(LEVEL, FLAGS) +#define WPP_RECORDER_LEVEL_FLAGS_RETSTATUS_NEGCOND_FILTER(LEVEL, FLAGS, RETSTATUS, NEGCOND) WPP_RECORDER_LEVEL_FLAGS_FILTER(LEVEL, FLAGS) +#pragma endregion + +#pragma region Custom tracing macros + +// begin_wpp config +// USEPREFIX(DrvLogCritical, "%!STDPREFIX!CRIT: "); +// USEPREFIX(DrvLogError, "%!STDPREFIX!ERROR: "); +// USEPREFIX(DrvLogWarning, "%!STDPREFIX!WARN: "); +// USEPREFIX(DrvLogInfo, "%!STDPREFIX!INFO: "); +// USEPREFIX(DrvLogVerbose, "%!STDPREFIX!VERB: "); +// USEPREFIX(DrvLogEnter, "%!STDPREFIX!ENTER"); +// USEPREFIX(DrvLogExit, "%!STDPREFIX!EXIT"); +// end_wpp + +// begin_wpp config +// FUNC DrvLogCritical{LEVEL=TRACE_LEVEL_CRITICAL}(IFRLOG,FLAGS,MSG,...); +// FUNC DrvLogError{LEVEL=TRACE_LEVEL_ERROR}(IFRLOG,FLAGS,MSG,...); +// FUNC DrvLogWarning{LEVEL=TRACE_LEVEL_WARNING}(IFRLOG,FLAGS,MSG,...); +// FUNC DrvLogInfo{LEVEL=TRACE_LEVEL_INFORMATION}(IFRLOG,FLAGS,MSG,...); +// FUNC DrvLogEnter{LEVEL=TRACE_LEVEL_VERBOSE,FLAGS=FLAG_FUNCTION}(IFRLOG,...); +// FUNC DrvLogVerbose{LEVEL=TRACE_LEVEL_VERBOSE}(IFRLOG,FLAGS,MSG,...); +// FUNC DrvLogExit{LEVEL=TRACE_LEVEL_VERBOSE,FLAGS=FLAG_FUNCTION}(IFRLOG,...); +// end_wpp + + +#ifdef __INTELLISENSE__ +#define FLAG_DEVICE_ALL 0x01 +#define FLAG_FUNCTION 0x02 +#define FLAG_INFO 0x04 +#define FLAG_PNP 0x08 +#define FLAG_POWER 0x10 +#define FLAG_STREAM 0x20 +#define FLAG_INIT 0x40 +#define FLAG_DDI 0x80 +#define FLAG_GENERIC 0x100 +void DrvLogCritical(void* log, int flags, const WCHAR* fmt, ...); +void DrvLogError(void* log, int flags, const WCHAR* fmt, ...); +void DrvLogWarning(void* log, int flags, const WCHAR* fmt, ...); +void DrvLogInfo(void* log, int flags, const WCHAR* fmt, ...); +void DrvLogEnter(void* log, ...); +void DrvLogVerbose(void* log, int flags, const WCHAR* fmt, ...); +void DrvLogExit(void* log, ...); + +void RETURN_IF_FAILED(NTSTATUS status); +void RETURN_NTSTATUS_IF_FAILED(NTSTATUS status); +void RETURN_NTSTATUS_IF_FAILED_MSG(NTSTATUS status, const WCHAR* fmt, ...); +void RETURN_NTSTATUS_IF_FAILED_UNLESS_ALLOWED(NTSTATUS returnStatus, NTSTATUS allowedStatus); +void RETURN_NTSTATUS_IF_NULL_ALLOC(PVOID ptr); +void RETURN_NTSTATUS_IF_NULL(PVOID ptr); +void RETURN_NTSTATUS_IF_TRUE(BOOL condition, NTSTATUS status); +void RETURN_NTSTATUS_IF_TRUE_MSG(BOOL condition, NTSTATUS status, const WCHAR* fmt, ...); +void RETURN_NTSTATUS_IF_FALSE(BOOL condition, NTSTATUS status); +void RETURN_NTSTATUS(NTSTATUS status); +void RETURN_NTSTATUS_MSG(NTSTATUS status, const WCHAR* fmt, ...); +#endif// __INTELLISENSE__ + +//********************************************************* +// MACRO: TRACE_METHOD_LINE +// +// begin_wpp config +// FUNC TRACE_METHOD_LINE(LEVEL, FLAGS, MSG, ...); +// USESUFFIX (TRACE_METHOD_LINE, ", this=0x%p", this); +// end_wpp + +//********************************************************* +// MACRO: TRACE_METHOD_ENTRY +// +// begin_wpp config +// FUNC TRACE_METHOD_ENTRY(LEVEL, FLAGS); +// USESUFFIX (TRACE_METHOD_ENTRY, "Enter, this=0x%p", this); +// end_wpp + +//********************************************************* +// MACRO: TRACE_METHOD_EXIT +// +// begin_wpp config +// FUNC TRACE_METHOD_EXIT(LEVEL, FLAGS); +// USESUFFIX (TRACE_METHOD_EXIT, "Exit, this=0x%p", this); +// end_wpp + +//********************************************************* +// MACRO: TRACE_METHOD_EXIT_HR +// +// begin_wpp config +// FUNC TRACE_METHOD_EXIT_HR(LEVEL, FLAGS, HR); +// USESUFFIX (TRACE_METHOD_EXIT_HR, "Exit, this=0x%p, hr=%!HRESULT!", this, HR); +// end_wpp + +//********************************************************* +// MACRO: TRACE_METHOD_EXIT_DWORD +// +// begin_wpp config +// FUNC TRACE_METHOD_EXIT_DWORD(LEVEL, FLAGS, RETVAL); +// USESUFFIX (TRACE_METHOD_EXIT_DWORD, "Exit, this=0x%p, ret=0x%08Ix ", this, RETVAL); +// end_wpp + +//********************************************************* +// MACRO: TRACE_METHOD_EXIT_PTR +// +// begin_wpp config +// FUNC TRACE_METHOD_EXIT_PTR(LEVEL, FLAGS, RETVAL); +// USESUFFIX (TRACE_METHOD_EXIT_PTR,"Exit, this=0x%p, retptr=0x%p", this, RETVAL); +// end_wpp + +//********************************************************* +// MACRO: TRACE_METHOD_EXIT_STATUS +// +// begin_wpp config +// FUNC TRACE_METHOD_EXIT_STATUS(LEVEL, FLAGS, STATUS); +// USESUFFIX (TRACE_METHOD_EXIT_STATUS, "Exit, this=0x%p, status=%!STATUS!", this, STATUS); +// end_wpp + +//********************************************************* +// MACRO: TRACE_FUNCTION_ENTRY +// +// begin_wpp config +// FUNC TRACE_FUNCTION_ENTRY(LEVEL, FLAGS); +// USESUFFIX (TRACE_FUNCTION_ENTRY, "Enter"); +// end_wpp + +//********************************************************* +// MACRO: TRACE_FUNCTION_EXIT +// +// begin_wpp config +// FUNC TRACE_FUNCTION_EXIT(LEVEL, FLAGS); +// USESUFFIX (TRACE_FUNCTION_EXIT, "Exit"); +// end_wpp + +//********************************************************* +// MACRO: TRACE_FUNCTION_EXIT_HR +// +// begin_wpp config +// FUNC TRACE_FUNCTION_EXIT_HR(LEVEL, FLAGS, HR); +// USESUFFIX (TRACE_FUNCTION_EXIT_HR, "Exit, hr=%!HRESULT!", HR); +// end_wpp + +//********************************************************* +// MACRO: TRACE_FUNCTION_EXIT_DWORD +// +// begin_wpp config +// FUNC TRACE_FUNCTION_EXIT_DWORD(LEVEL, FLAGS, RETVAL); +// USESUFFIX (TRACE_FUNCTION_EXIT_DWORD, "Exit, ret=0x%08Ix", RETVAL); +// end_wpp + +//********************************************************* +// MACRO: TRACE_FUNCTION_EXIT_PTR +// +// begin_wpp config +// FUNC TRACE_FUNCTION_EXIT_PTR(LEVEL, FLAGS, RETVAL); +// USESUFFIX (TRACE_FUNCTION_EXIT_PTR, "Exit, retptr=0x%p", RETVAL); +// end_wpp + +//********************************************************* +// MACRO: TRACE_FUNCTION_EXIT_STATUS +// +// begin_wpp config +// FUNC TRACE_FUNCTION_EXIT_STATUS(LEVEL, FLAGS, STATUS); +// USESUFFIX (TRACE_FUNCTION_EXIT_STATUS, "Exit, status=%!STATUS!", STATUS); +// end_wpp + +//********************************************************* +// MACRO: TRACE_LINE +// +// begin_wpp config +// FUNC TRACE_LINE(LEVEL, FLAGS, MSG, ...); +// end_wpp + +//********************************************************* +// MACRO: TRACE_HRESULT +// +// begin_wpp config +// FUNC TRACE_HRESULT(LEVEL, FLAGS, HR, MSG, ...); +// USESUFFIX (TRACE_HRESULT, ", ret=%!HRESULT!", HR); +// end_wpp + +//********************************************************* +// MACRO: TRACE_FAILURE_INFO (WIL FailureInfo logging) +// see: https://github.com/microsoft/wil/blob/master/include/wil/result_macros.h +// +// begin_wpp config +// FUNC TRACE_FAILURE_INFO(LEVEL, FLAGS, FI); +// USESUFFIX(TRACE_FAILURE_INFO, " [%04X] '%ws', hr=%!HRESULT! ['%s' (%u)]", FI.threadId, FI.pszMessage, FI.hr, FI.pszFile, FI.uLineNumber); +// end_wpp + +// MACRO: RETURN_IF_FAILED +// +// begin_wpp config +// FUNC RETURN_IF_FAILED{LEVEL=LEVEL_ERROR,FLAGS=FLAG_DEVICE_ALL,IFRLOG=g_AudioDspLog}(RETSTATUS); +// USEPREFIX(RETURN_IF_FAILED, "%!STDPREFIX!ERROR:"); +// USESUFFIX(RETURN_IF_FAILED, " File:%s, Line:%d - status=%!STATUS!", __FILE__, __LINE__, __statusRet); +// end_wpp + + +// MACRO: RETURN_NTSTATUS_IF_FAILED +// +// begin_wpp config +// FUNC RETURN_NTSTATUS_IF_FAILED{LEVEL=LEVEL_ERROR,FLAGS=FLAG_DEVICE_ALL,IFRLOG=g_AudioDspLog}(RETSTATUS); +// USEPREFIX(RETURN_NTSTATUS_IF_FAILED, "%!STDPREFIX!ERROR:"); +// USESUFFIX(RETURN_NTSTATUS_IF_FAILED, " File:%s, Line:%d - status=%!STATUS!", __FILE__, __LINE__, __statusRet); +// end_wpp + +// MACRO: RETURN_NTSTATUS_IF_FAILED_MSG +// +// begin_wpp config +// FUNC RETURN_NTSTATUS_IF_FAILED_MSG{LEVEL=LEVEL_ERROR,FLAGS=FLAG_DEVICE_ALL,IFRLOG=g_AudioDspLog}(RETSTATUS, MSG, ...); +// USEPREFIX(RETURN_NTSTATUS_IF_FAILED_MSG, "%!STDPREFIX!ERROR:"); +// USESUFFIX(RETURN_NTSTATUS_IF_FAILED_MSG, " - status=%!STATUS!",__statusRet); +// end_wpp + +// MACRO: RETURN_NTSTATUS_IF_FAILED_UNLESS_ALLOWED +// +// begin_wpp config +// FUNC RETURN_NTSTATUS_IF_FAILED_UNLESS_ALLOWED{LEVEL=LEVEL_ERROR,FLAGS=FLAG_DEVICE_ALL,IFRLOG=g_AudioDspLog}(RETSTATUS, ALLOWEDSTATUS); +// USEPREFIX(RETURN_NTSTATUS_IF_FAILED_UNLESS_ALLOWED, "%!STDPREFIX!ERROR:"); +// USESUFFIX(RETURN_NTSTATUS_IF_FAILED_UNLESS_ALLOWED, " File:%s, Line:%d - status=%!STATUS!", __FILE__, __LINE__, __statusRet); +// end_wpp + +// MACRO: RETURN_NTSTATUS_IF_NULL_ALLOC +// +// begin_wpp config +// FUNC RETURN_NTSTATUS_IF_NULL_ALLOC{LEVEL=LEVEL_ERROR,FLAGS=DUMMY}(RETPTR); +// USESUFFIX(RETURN_NTSTATUS_IF_NULL, "status=STATUS_INSUFFICIENT_RESOURCES"); +// end_wpp + +// MACRO: RETURN_NTSTATUS_IF_NULL +// +// begin_wpp config +// FUNC RETURN_NTSTATUS_IF_NULL{LEVEL=LEVEL_ERROR,FLAGS=DUMMY}(RETSTATUS, RETPTR); +// USESUFFIX(RETURN_NTSTATUS_IF_NULL, "status=%!STATUS!", __statusRet); +// end_wpp + +// MACRO: RETURN_NTSTATUS_IF_TRUE +// +// begin_wpp config +// FUNC RETURN_NTSTATUS_IF_TRUE{LEVEL=LEVEL_ERROR,FLAGS=FLAG_DEVICE_ALL,IFRLOG=g_AudioDspLog}(POSCOND, RETSTATUS); +// USESUFFIX(RETURN_NTSTATUS_IF_TRUE, " File:%s, Line:%d - status=%!STATUS!", __FILE__, __LINE__, __statusRet); +// end_wpp + +// MACRO: RETURN_NTSTATUS_IF_TRUE_MSG +// +// begin_wpp config +// FUNC RETURN_NTSTATUS_IF_TRUE_MSG{LEVEL=LEVEL_ERROR,FLAGS=FLAG_DEVICE_ALL,IFRLOG=g_AudioDspLog}(POSCOND, RETSTATUS, MSG, ...); +// USESUFFIX(RETURN_NTSTATUS_IF_TRUE_MSG, " - status=%!STATUS!", __statusRet); +// end_wpp + +// MACRO: RETURN_NTSTATUS_IF_FALSE +// +// begin_wpp config +// FUNC RETURN_NTSTATUS_IF_FALSE{LEVEL=LEVEL_ERROR,FLAGS=DUMMY}(RETSTATUS, NEGCOND); +// USESUFFIX(RETURN_NTSTATUS_IF_FALSE, " File:%s, Line:%d - status=%!STATUS!", __FILE__, __LINE__, __statusRet); +// end_wpp + +// MACRO: RETURN_NTSTATUS +// +// begin_wpp config +// FUNC RETURN_NTSTATUS{LEVEL=LEVEL_ERROR,FLAGS=FLAG_DEVICE_ALL,IFRLOG=g_AudioDspLog}(RETSTATUS); +// USESUFFIX(RETURN_NTSTATUS, " File:%s, Line:%d - status=%!STATUS!", __FILE__, __LINE__, __statusRet); +// end_wpp + +// MACRO: RETURN_NTSTATUS_MSG +// +// begin_wpp config +// FUNC RETURN_NTSTATUS_MSG{LEVEL=LEVEL_ERROR,FLAGS=FLAG_DEVICE_ALL,IFRLOG=g_AudioDspLog}(RETSTATUS, MSG, ...); +// USESUFFIX(RETURN_NTSTATUS_MSG, " - status=%!STATUS!", __statusRet); +// end_wpp + +#define W32 +#define WPP_CHECK_FOR_NULL_STRING //to prevent exceptions due to NULL strings + +#pragma endregion diff --git a/windows/drivers/mic/Inc/AudioFormats.h b/windows/drivers/mic/Inc/AudioFormats.h new file mode 100644 index 000000000..231633557 --- /dev/null +++ b/windows/drivers/mic/Inc/AudioFormats.h @@ -0,0 +1,58 @@ +/*++ + +Copyright (c) Microsoft Corporation. All rights reserved. + + THIS CODE AND INFORMATION IS PROVIDED "AS IS" WITHOUT WARRANTY OF ANY + KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE + IMPLIED WARRANTIES OF MERCHANTABILITY AND/OR FITNESS FOR A PARTICULAR + PURPOSE. + +Module Name: + + AudioFormats.h + +Abstract: + + Contains Audio formats supported for the ACX Sample Drivers + +Environment: + + Kernel mode + +--*/ + +#pragma once + +#define NOBITMAP +#include + +// +// Basic-testing formats. +// +static +KSDATAFORMAT_WAVEFORMATEXTENSIBLE Pcm48000c1 = +{ + { + sizeof(KSDATAFORMAT_WAVEFORMATEXTENSIBLE), + 0, + 0, + 0, + STATICGUIDOF(KSDATAFORMAT_TYPE_AUDIO), + STATICGUIDOF(KSDATAFORMAT_SUBTYPE_PCM), + STATICGUIDOF(KSDATAFORMAT_SPECIFIER_WAVEFORMATEX) + }, + { + { + WAVE_FORMAT_EXTENSIBLE, + 1, + 48000, + 96000, + 2, + 16, + sizeof(WAVEFORMATEXTENSIBLE) - sizeof(WAVEFORMATEX) + }, + 16, + KSAUDIO_SPEAKER_MONO, + STATICGUIDOF(KSDATAFORMAT_SUBTYPE_PCM) + } +}; diff --git a/windows/drivers/mic/Inc/cpp_utils.h b/windows/drivers/mic/Inc/cpp_utils.h new file mode 100644 index 000000000..e0e10ab48 --- /dev/null +++ b/windows/drivers/mic/Inc/cpp_utils.h @@ -0,0 +1,71 @@ +/*++ + +Copyright (c) Microsoft Corporation. All rights reserved. + + THIS CODE AND INFORMATION IS PROVIDED "AS IS" WITHOUT WARRANTY OF ANY + KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE + IMPLIED WARRANTIES OF MERCHANTABILITY AND/OR FITNESS FOR A PARTICULAR + PURPOSE. + +Module Name: + + cpp_utils.h + +Abstract: + + Contains CPP utilities + +Environment: + + Kernel mode + +--*/ + +#pragma once + +// Function scope_exit instantiates scope_exit object +// Constructor accepts lamda as parameter. +// Assign tasks in lamdba to be executed on scope exit. +template +auto scope_exit(F f) +{ + class scope_exit + { + public: + scope_exit(F f) : + _f{ f } + { + } + + ~scope_exit() + { + if (_call) + { + _f(); + } + } + + // Ensures the scope_exit lambda will not be called + void release() + { + _call = false; + } + + // Executes the scope_exit lambda immediately if not yet run; ensures it will not run again + void reset() + { + if (_call) + { + _f(); + _call = false; + } + } + + private: + F _f; + bool _call = true; + }; + + return scope_exit{ f }; +}; + diff --git a/windows/drivers/mic/README.md b/windows/drivers/mic/README.md new file mode 100644 index 000000000..26b904c89 --- /dev/null +++ b/windows/drivers/mic/README.md @@ -0,0 +1,36 @@ +# LibrePodsMic — virtual audio (microphone) driver + +The virtual-microphone driver for the [hi-res mic feature](../../../docs/windows/hires-mic/PLAN.md): +Windows sees a "LibrePods Microphone" that the app feeds with the AirPods' +decoded AAC-ELD audio, so any app (Teams, Zoom, Discord, OBS…) can use it. + +## Origin / license + +This is based on the **Microsoft ACX `AudioCodec` sample** from +[microsoft/Windows-driver-samples](https://github.com/microsoft/Windows-driver-samples/tree/main/audio/Acx/Samples) +(**MIT licensed**), chosen because: + +- it's a **ROOT-enumerated** (software, no-hardware) audio device — i.e. virtual; +- it already has a **capture circuit** (`Common/CaptureCircuit.cpp`) — a mic; +- it uses **ACX** on top of **KMDF** — the same framework family as our + `LibrePodsAAP` driver (unlike SYSVAD's older PortCls). + +We will trim it to capture-only and swap the audio source (`Common/WaveReader.cpp`) +for a PCM feed pushed from the app over an IOCTL/ring buffer. + +## Status + +- [x] **Builds** with VS2026 + WDK 28000 (ACX headers present) — `AudioCodec.sys`. +- [x] **Installs + a virtual mic appears** — `install.ps1` (elevated, Test Mode) + signs + catalogs the driver and creates the `ROOT\AudioCodec` device via + devcon. Confirmed on hardware: Windows shows **"Microphone (AudioCodec Device)"** + in Sound -> Input (and a matching output). **Phase 1 done.** +- [ ] Rename AudioCodec -> LibrePodsMic (INF device description). +- [ ] Trim to capture-only; add the IOCTL PCM bridge (Phase 2) so the app can + push the AirPods' decoded audio into the mic. + +## Build + +`build-wsl.cmd` on the Windows host (VS + WDK) → +`AudioCodec/Driver/x64/Release/AudioCodec.sys`. See +[`../../../docs/windows/hires-mic/PLAN.md`](../../../docs/windows/hires-mic/PLAN.md) for the roadmap. diff --git a/windows/drivers/mic/Shared/Public.h b/windows/drivers/mic/Shared/Public.h new file mode 100644 index 000000000..727ada9bc --- /dev/null +++ b/windows/drivers/mic/Shared/Public.h @@ -0,0 +1,342 @@ +/*++ + +Copyright (c) Microsoft Corporation. All rights reserved. + + THIS CODE AND INFORMATION IS PROVIDED "AS IS" WITHOUT WARRANTY OF ANY + KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE + IMPLIED WARRANTIES OF MERCHANTABILITY AND/OR FITNESS FOR A PARTICULAR + PURPOSE. + +Module Name: + + Public.h + +Abstract: + + Contains structure definitions and function prototypes for the driver + that are public. + +Environment: + + Kernel mode + +--*/ + +/* make prototypes usable from C++ */ +#ifdef __cplusplus +extern "C" { +#endif + +#include +#include +#include +#include +#include "Trace.h" + +#include +#include + +#define PAGED_CODE_SEG __declspec(code_seg("PAGE")) +#define INIT_CODE_SEG __declspec(code_seg("INIT")) + +// Number of msec for idle timeout. +#define IDLE_POWER_TIMEOUT 5000 + +extern RECORDER_LOG g_AudioDspLog; +extern RECORDER_LOG g_AudioDspMcLog; + +// +// Define CODEC device context. +// +typedef struct _CODEC_DEVICE_CONTEXT { + ACXCIRCUIT Render; + ACXCIRCUIT Capture; + WDF_TRI_STATE ExcludeD3Cold; +} CODEC_DEVICE_CONTEXT, * PCODEC_DEVICE_CONTEXT; + +// +// Codec driver prototypes. +// +EVT_WDF_DRIVER_DEVICE_ADD Codec_EvtBusDeviceAdd; +DRIVER_INITIALIZE DriverEntry; +EVT_WDF_DRIVER_UNLOAD AudioCodecDriverUnload; + +// +// Codec device callbacks. +// +EVT_WDF_DEVICE_PREPARE_HARDWARE Codec_EvtDevicePrepareHardware; +EVT_WDF_DEVICE_RELEASE_HARDWARE Codec_EvtDeviceReleaseHardware; +EVT_WDF_DEVICE_D0_ENTRY Codec_EvtDeviceD0Entry; +EVT_WDF_DEVICE_D0_EXIT Codec_EvtDeviceD0Exit; +EVT_WDF_DEVICE_CONTEXT_CLEANUP Codec_EvtDeviceContextCleanup; + +WDF_DECLARE_CONTEXT_TYPE_WITH_NAME(CODEC_DEVICE_CONTEXT, GetCodecDeviceContext) + +// +// Define DSP device context. +// +typedef struct _DSP_DEVICE_CONTEXT { + ACXCIRCUIT Speaker; + ACXCIRCUIT MicArray; + ACXCIRCUIT SpeakerHp; + ACXCIRCUIT MicrophoneHp; + ACXCIRCUIT HDMI; + WDF_TRI_STATE ExcludeD3Cold; +} DSP_DEVICE_CONTEXT, * PDSP_DEVICE_CONTEXT; + +// +// Dsp driver prototypes. +// +EVT_WDF_DRIVER_DEVICE_ADD Dsp_EvtBusDeviceAdd; +EVT_WDF_DRIVER_UNLOAD AudioDspDriverUnload; + +// +// Dsp device callbacks. +// +EVT_WDF_DEVICE_PREPARE_HARDWARE Dsp_EvtDevicePrepareHardware; +EVT_WDF_DEVICE_RELEASE_HARDWARE Dsp_EvtDeviceReleaseHardware; +EVT_WDF_DEVICE_D0_ENTRY Dsp_EvtDeviceD0Entry; +EVT_WDF_DEVICE_D0_EXIT Dsp_EvtDeviceD0Exit; +EVT_WDF_DEVICE_CONTEXT_CLEANUP Dsp_EvtDeviceContextCleanup; + +WDF_DECLARE_CONTEXT_TYPE_WITH_NAME(DSP_DEVICE_CONTEXT, GetDspDeviceContext) + +// +// Define CODECMC (multicircuit codec) device context. +// +typedef struct _CODECMC_DEVICE_CONTEXT +{ + ACXCIRCUIT Render; + ACXCIRCUIT Capture; + ACXCOMPOSITETEMPLATE Composite[2]; + ULONG refComposite[2]; + WDF_TRI_STATE ExcludeD3Cold; +} CODECMC_DEVICE_CONTEXT, *PCODECMC_DEVICE_CONTEXT; + +// +// Multicircuit codec driver prototypes. +// +EVT_WDF_DRIVER_DEVICE_ADD CodecMc_EvtBusDeviceAdd; +DRIVER_INITIALIZE DriverEntry; +EVT_WDF_DRIVER_UNLOAD AudioCodecMcDriverUnload; + +// +// Multicircuit codec device callbacks. +// +EVT_WDF_DEVICE_PREPARE_HARDWARE CodecMc_EvtDevicePrepareHardware; +EVT_WDF_DEVICE_RELEASE_HARDWARE CodecMc_EvtDeviceReleaseHardware; +EVT_WDF_DEVICE_D0_ENTRY CodecMc_EvtDeviceD0Entry; +EVT_WDF_DEVICE_D0_EXIT CodecMc_EvtDeviceD0Exit; +EVT_WDF_DEVICE_CONTEXT_CLEANUP CodecMc_EvtDeviceContextCleanup; + +WDF_DECLARE_CONTEXT_TYPE_WITH_NAME(CODECMC_DEVICE_CONTEXT, GetCodecMcDeviceContext) + +// +// Composite type definition used in multicircuit codec. +// +typedef enum +{ + CompositeType_RENDER, + CompositeType_CAPTURE +} CompositeType; + +// +// Define DSPMC (multicircuit dsp) device context. +// +typedef struct _DSPMC_DEVICE_CONTEXT +{ + ACXCIRCUIT Render; + ACXCIRCUIT Capture; + WDF_TRI_STATE ExcludeD3Cold; +} DSPMC_DEVICE_CONTEXT, *PDSPMC_DEVICE_CONTEXT; + +// +// Multicircuit dsp driver prototypes. +// +EVT_WDF_DRIVER_DEVICE_ADD DspMc_EvtBusDeviceAdd; +EVT_WDF_DRIVER_UNLOAD AudioDspMcDriverUnload; + +// +// Multicircuit dsp device callbacks. +// +EVT_WDF_DEVICE_PREPARE_HARDWARE DspMc_EvtDevicePrepareHardware; +EVT_WDF_DEVICE_RELEASE_HARDWARE DspMc_EvtDeviceReleaseHardware; +EVT_WDF_DEVICE_D0_ENTRY DspMc_EvtDeviceD0Entry; +EVT_WDF_DEVICE_D0_EXIT DspMc_EvtDeviceD0Exit; +EVT_WDF_DEVICE_CONTEXT_CLEANUP DspMc_EvtDeviceContextCleanup; + +WDF_DECLARE_CONTEXT_TYPE_WITH_NAME(DSPMC_DEVICE_CONTEXT, GetDspMcDeviceContext) + +// Factory circuit callbacks for multicircuit dsp. +EVT_ACX_FACTORY_CIRCUIT_CREATE_CIRCUITDEVICE RenderMCDsp_EvtAcxFactoryCircuitCreateCircuitDevice; +EVT_ACX_FACTORY_CIRCUIT_CREATE_CIRCUIT RenderMCDsp_EvtAcxFactoryCircuitCreateCircuit; + +EVT_ACX_FACTORY_CIRCUIT_CREATE_CIRCUITDEVICE CaptureMCDsp_EvtAcxFactoryCircuitCreateCircuitDevice; +EVT_ACX_FACTORY_CIRCUIT_CREATE_CIRCUIT CaptureMCDsp_EvtAcxFactoryCircuitCreateCircuit; + + +/* make internal prototypes usable from C++ */ +#ifdef __cplusplus +} +#endif + +// Used to store the registry settings path for the driver: +extern UNICODE_STRING g_RegistryPath; + +// Driver tag name: +extern ULONG DeviceDriverTag; + +// The idle timeout in msec for power policy structure: +extern ULONG IdleTimeoutMsec; + +#define DECLARE_CONST_ACXOBJECTBAG_MULTICIRCUIT_SAMPLE_PROPERTY_NAME(name) \ + DECLARE_CONST_UNICODE_STRING(name, L"mc_" #name) + +__drv_requiresIRQL(PASSIVE_LEVEL) +PAGED_CODE_SEG +NTSTATUS +CopyRegistrySettingsPath( + _In_ PUNICODE_STRING RegistryPath +); + +PAGED_CODE_SEG +NTSTATUS +Codec_SetPowerPolicy( + _In_ WDFDEVICE Device +); + +PAGED_CODE_SEG +NTSTATUS +Dsp_SetPowerPolicy( + _In_ WDFDEVICE Device +); + +PAGED_CODE_SEG +NTSTATUS +CodecMc_SetPowerPolicy( + _In_ WDFDEVICE Device +); + +PAGED_CODE_SEG +NTSTATUS +DspMc_SetPowerPolicy( + _In_ WDFDEVICE Device +); + +PAGED_CODE_SEG +NTSTATUS +CodecR_AddStaticRender( + _In_ WDFDEVICE Device, + _In_ const GUID * ComponentGuid, + _In_ const UNICODE_STRING * CircuitName +); + +PAGED_CODE_SEG +NTSTATUS +CodecC_AddStaticCapture( + _In_ WDFDEVICE Device, + _In_ const GUID * ComponentGuid, + _In_ const GUID * MicCustomName, + _In_ const UNICODE_STRING * CircuitName +); + +PAGED_CODE_SEG +NTSTATUS +Speaker_AddStaticRender( + _In_ WDFDEVICE Device, + _In_ GUID ComponentGuid, + _In_ UNICODE_STRING CircuitName, + _In_ BOOLEAN IsHeadphones +); + +PAGED_CODE_SEG +NTSTATUS +MicArray_AddStaticCapture( + _In_ WDFDEVICE Device, + _In_ GUID ComponentGuid, + _In_ GUID MicCustomName, + _In_ UNICODE_STRING CircuitName +); + +PAGED_CODE_SEG +NTSTATUS +MicrophoneHp_AddStaticCapture( + _In_ WDFDEVICE Device, + _In_ GUID ComponentGuid, + _In_ GUID MicCustomName, + _In_ UNICODE_STRING CircuitName +); + +PAGED_CODE_SEG +NTSTATUS +SpeakerHp_AddStaticRender( + _In_ WDFDEVICE Device, + _In_ GUID ComponentGuid, + _In_ UNICODE_STRING CircuitName +); + +PAGED_CODE_SEG +NTSTATUS +HDMI_AddStaticRender( + _In_ WDFDEVICE Device, + _In_ GUID ComponentGuid, + _In_ UNICODE_STRING CircuitName +); + +PAGED_CODE_SEG +NTSTATUS +RenderMC_AddStaticRender( + _In_ WDFDEVICE Device, + _In_ const GUID * ComponentGuid, + _In_ const UNICODE_STRING * CircuitName, + _In_ const UNICODE_STRING * Uri +); + +PAGED_CODE_SEG +NTSTATUS +CaptureMC_AddStaticCapture( + _In_ WDFDEVICE Device, + _In_ const GUID * ComponentGuid, + _In_ const GUID * MicCustomName, + _In_ const UNICODE_STRING * CircuitName, + _In_ const UNICODE_STRING * Uri +); + +PAGED_CODE_SEG +NTSTATUS +CodecC_CircuitCleanup( + _In_ ACXCIRCUIT Device +); + +PAGED_CODE_SEG +NTSTATUS +MicArray_CircuitCleanup( + _In_ ACXCIRCUIT Device +); + +PAGED_CODE_SEG +NTSTATUS +CaptureMCDsp_CircuitCleanup( + _In_ ACXCIRCUIT Device +); + +PAGED_CODE_SEG +NTSTATUS +DspMc_AddFactoryCircuit( + _In_ WDFDRIVER Driver, + _In_ WDFDEVICE Device +); + +NTSTATUS +CodecMc_AddRenderComposites(_In_ WDFDEVICE Device); + +PAGED_CODE_SEG +NTSTATUS +CodecMc_AddCaptureComposites(_In_ WDFDEVICE Device); + +PAGED_CODE_SEG +NTSTATUS +CodecMc_AddComposites(_In_ WDFDEVICE Device, _In_ CompositeType compositeType); + +NTSTATUS +CodecMc_RemoveComposites(_In_ WDFDEVICE Device); diff --git a/windows/drivers/mic/Shared/Trace.h b/windows/drivers/mic/Shared/Trace.h new file mode 100644 index 000000000..552021297 --- /dev/null +++ b/windows/drivers/mic/Shared/Trace.h @@ -0,0 +1,33 @@ +/*++ + +Copyright (c) Microsoft Corporation + +Module Name: + +Trace.h + +--*/ + +#pragma once + +#include +#include // For TRACE_LEVEL definitions + +#define WPP_TOTAL_BUFFER_SIZE (PAGE_SIZE) +#define WPP_ERROR_PARTITION_SIZE (WPP_TOTAL_BUFFER_SIZE/4) + +// {1945C417-844C-48C6-9BCE-3AA2113A2B69} +#define WPP_CONTROL_GUIDS \ +WPP_DEFINE_CONTROL_GUID(DrvLogger,(1945C417,844C,48C6,9BCE,3AA2113A2B69), \ + WPP_DEFINE_BIT(FLAG_DEVICE_ALL) /* bit 0 = 0x00000001 */ \ + WPP_DEFINE_BIT(FLAG_FUNCTION) /* bit 1 = 0x00000002 */ \ + WPP_DEFINE_BIT(FLAG_INFO) /* bit 2 = 0x00000004 */ \ + WPP_DEFINE_BIT(FLAG_PNP) /* bit 3 = 0x00000008 */ \ + WPP_DEFINE_BIT(FLAG_POWER) /* bit 4 = 0x00000010 */ \ + WPP_DEFINE_BIT(FLAG_STREAM) /* bit 5 = 0x00000020 */ \ + WPP_DEFINE_BIT(FLAG_INIT) /* bit 6 = 0x00000040 */ \ + WPP_DEFINE_BIT(FLAG_DDI) /* bit 7 = 0x00000080 */ \ + WPP_DEFINE_BIT(FLAG_GENERIC) /* bit 8 = 0x00000100 */ \ + ) + +#include "trace_macros.h" diff --git a/windows/drivers/mic/install.ps1 b/windows/drivers/mic/install.ps1 new file mode 100644 index 000000000..3143eb6e7 --- /dev/null +++ b/windows/drivers/mic/install.ps1 @@ -0,0 +1,65 @@ +<# + install.ps1 - test-sign + install the LibrePodsMic virtual audio driver, and + create its ROOT-enumerated device so a virtual microphone appears. + + RUN AS ADMINISTRATOR, in Test Mode (bcdedit /set testsigning on + reboot, + Secure Boot off). Consider a system restore point first - this adds a virtual + audio device. + + Pass the build output dir that holds AudioCodec.sys + AudioCodec.inf. + Default: the Release build under this driver tree. +#> +param( + [string]$Dir = (Join-Path $PSScriptRoot 'AudioCodec\Driver\x64\Release') +) +$ErrorActionPreference = 'Stop' + +$sys = Join-Path $Dir 'AudioCodec.sys' +$inf = Join-Path $Dir 'AudioCodec.inf' +foreach ($f in @($sys, $inf)) { if (-not (Test-Path $f)) { throw "Missing $f - build first." } } + +$kit = 'C:\Program Files (x86)\Windows Kits\10' +$inf2cat = Join-Path $kit 'bin\10.0.28000.0\x86\Inf2Cat.exe' +$devcon = Join-Path $kit 'Tools\10.0.28000.0\x64\devcon.exe' +$signtool = (Get-ChildItem "$kit\bin" -Recurse -Filter signtool.exe | + Where-Object { $_.FullName -match 'x64' } | Select-Object -First 1).FullName + +# 1. A package folder with the .sys + .inf together, then a catalog. +$pkg = Join-Path $env:TEMP 'LibrePodsMicPkg' +Remove-Item $pkg -Recurse -Force -ErrorAction SilentlyContinue +New-Item -ItemType Directory -Force -Path $pkg | Out-Null +Copy-Item $sys, $inf $pkg -Force +Write-Host '==> Generating catalog...' +& $inf2cat /driver:$pkg /os:10_X64 +$cat = Join-Path $pkg 'audiocodec.cat' + +# 2. Test code-signing cert, trusted for driver loading. +Write-Host '==> Creating + trusting a test certificate...' +$cert = New-SelfSignedCertificate -Type CodeSigningCert ` + -Subject 'CN=LibrePods Test Cert' ` + -CertStoreLocation Cert:\LocalMachine\My ` + -KeyUsage DigitalSignature -KeyExportPolicy Exportable +$store = Get-Item "Cert:\LocalMachine\My\$($cert.Thumbprint)" +foreach ($name in 'Root', 'TrustedPublisher') { + $s = New-Object System.Security.Cryptography.X509Certificates.X509Store($name, 'LocalMachine') + $s.Open('ReadWrite'); $s.Add($store); $s.Close() +} + +Write-Host '==> Signing driver + catalog...' +$pkgSys = Join-Path $pkg 'AudioCodec.sys' +& $signtool sign /v /fd SHA256 /sm /s My /sha1 $cert.Thumbprint $pkgSys +& $signtool sign /v /fd SHA256 /sm /s My /sha1 $cert.Thumbprint $cat + +# 3. Install the driver + create the ROOT device (devcon install does both). +# Remove any existing instance first so re-running updates cleanly instead of +# stacking a second virtual device. +$pkgInf = Join-Path $pkg 'AudioCodec.inf' +Write-Host '==> Removing any existing ROOT\AudioCodec device...' +& $devcon remove 'ROOT\AudioCodec' 2>&1 | Out-Null +Start-Sleep -Seconds 1 +Write-Host '==> Installing + creating the ROOT\AudioCodec device...' +& $devcon install $pkgInf 'ROOT\AudioCodec' + +Write-Host '' +Write-Host '==> Done. Check Settings -> System -> Sound -> Input for a new mic,' +Write-Host ' and Device Manager -> Sound, video and game controllers.' diff --git a/windows/drivers/mic/prebuilt/AudioCodec.inf b/windows/drivers/mic/prebuilt/AudioCodec.inf new file mode 100755 index 000000000..a4f4e6005 Binary files /dev/null and b/windows/drivers/mic/prebuilt/AudioCodec.inf differ diff --git a/windows/drivers/mic/prebuilt/AudioCodec.sys b/windows/drivers/mic/prebuilt/AudioCodec.sys new file mode 100755 index 000000000..b7f22fc8d Binary files /dev/null and b/windows/drivers/mic/prebuilt/AudioCodec.sys differ diff --git a/windows/drivers/mic/prebuilt/README.md b/windows/drivers/mic/prebuilt/README.md new file mode 100644 index 000000000..bcc559822 --- /dev/null +++ b/windows/drivers/mic/prebuilt/README.md @@ -0,0 +1,9 @@ +# Prebuilt LibrePodsMic driver package + +The compiled virtual-microphone driver (`AudioCodec.sys` + `.inf`) so you can +install it **without building it** — no Visual Studio / C++ / WDK required. + +To install, run [`../install.ps1`](../install.ps1) from an **admin** PowerShell — +it generates the catalog (`inf2cat`), test-signs everything with a fresh cert, and +uses `devcon` to (re)create the `ROOT\AudioCodec` device, so a virtual microphone +appears in Sound > Input. diff --git a/windows/drivers/mic/rename-mic.ps1 b/windows/drivers/mic/rename-mic.ps1 new file mode 100644 index 000000000..c06ef578a --- /dev/null +++ b/windows/drivers/mic/rename-mic.ps1 @@ -0,0 +1,57 @@ +<# + rename-mic.ps1 - set the LibrePodsMic virtual microphone's display name (e.g. + the connected AirPods' name). RUN AS ADMINISTRATOR. + + .\rename-mic.ps1 "AirPods Pro de Pedro" + + MMDevices property values are REG_BINARY serialized PROPVARIANTs (4-byte type + tag + UTF-16 string), not plain strings - this reads/writes them correctly. + It matches the AudioCodec capture endpoint by DeviceDesc/FriendlyName, sets + PKEY_Device_FriendlyName, and refreshes the audio endpoint service so apps + pick it up without a reboot. (Plan B for when IPolicyConfig isn't available.) +#> +param([Parameter(Mandatory)][string]$Name) +$ErrorActionPreference = 'Stop' + +$base = 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\MMDevices\Audio\Capture' +$descKey = '{a45c254e-df1c-4efd-8020-67d146a850e0},2' # PKEY_Device_DeviceDesc +$fnKey = '{a45c254e-df1c-4efd-8020-67d146a850e0},14' # PKEY_Device_FriendlyName + +# Decode a REG_BINARY PROPVARIANT string value (skip the 4-byte type tag). +function Decode-PropStr($bytes) { + if (-not $bytes -or $bytes.Length -le 4) { return '' } + $s = [System.Text.Encoding]::Unicode.GetString($bytes[4..($bytes.Length - 1)]) + return $s.TrimEnd([char]0) +} +# Encode a string as a VT_LPWSTR PROPVARIANT REG_BINARY (0x1F tag + UTF-16 + NUL). +function Encode-PropStr([string]$s) { + $prefix = [byte[]](0x1F, 0x00, 0x00, 0x00) + $data = [System.Text.Encoding]::Unicode.GetBytes($s + [char]0) + return $prefix + $data +} + +$target = $null +Write-Host '==> Capture endpoints found:' +Get-ChildItem $base | ForEach-Object { + $props = Join-Path $_.PSPath 'Properties' + $p = Get-ItemProperty -Path $props -ErrorAction SilentlyContinue + $desc = if ($p.$descKey) { Decode-PropStr $p.$descKey } else { '' } + $fn = if ($p.$fnKey) { Decode-PropStr $p.$fnKey } else { '' } + Write-Host (" [{0}] desc='{1}' friendly='{2}'" -f $_.PSChildName, $desc, $fn) + if ($desc -like '*LibrePods*' -or $fn -like '*LibrePods*' -or + $desc -like '*AudioCodec*' -or $fn -like '*AudioCodec*') { + $target = $props + } +} + +if (-not $target) { + Write-Host 'No LibrePods capture endpoint matched. Is the LibrePodsMic driver installed?' + exit 1 +} + +Set-ItemProperty -Path $target -Name $fnKey -Value (Encode-PropStr $Name) -Type Binary +Write-Host "==> Set FriendlyName -> '$Name'" + +Write-Host '==> Refreshing the audio endpoint service (brief audio drop)...' +Restart-Service -Name AudioEndpointBuilder -Force +Write-Host '==> Done. Check Sound settings / Discord - it should show the new name.' diff --git a/windows/installer/install.ps1 b/windows/installer/install.ps1 new file mode 100644 index 000000000..2d30d1529 --- /dev/null +++ b/windows/installer/install.ps1 @@ -0,0 +1,126 @@ +<# + LibrePods for Windows — one-shot installer. + + Installs BOTH kernel drivers (test-signed on the fly): + • LibrePodsAAP — opens the AirPods AAP L2CAP channel (battery, ANC, mic, …). + • LibrePodsMic — a virtual microphone so any app can use the AirPods mic. + Then copies the daemon + WinUI app to %LOCALAPPDATA%\LibrePods and adds them to + startup (the WinUI app launches minimised to the tray). + + RUN AS ADMINISTRATOR, and only AFTER you have: + 1. Backed up your BitLocker recovery key. + 2. Disabled Secure Boot in your firmware/BIOS. + 3. Enabled test signing: bcdedit /set testsigning on (then rebooted). + + Signing the drivers needs signtool.exe (from the Windows SDK/WDK). Everything + else — driver packages, devcon, apps — is bundled in this folder. + + Usage (elevated): .\install.ps1 +#> +$ErrorActionPreference = 'Stop' +$here = Split-Path -Parent $MyInvocation.MyCommand.Path +$dest = Join-Path $env:LOCALAPPDATA 'LibrePods' + +# ---- locate tools ----------------------------------------------------------- +$signtool = (Get-ChildItem 'C:\Program Files (x86)\Windows Kits\10\bin' -Recurse -Filter signtool.exe -EA SilentlyContinue | + Where-Object { $_.FullName -match 'x64' } | Select-Object -First 1).FullName +if (-not $signtool) { throw 'signtool.exe not found — install the Windows SDK/WDK (needed to test-sign the drivers).' } +$devcon = Join-Path $here 'tools\devcon.exe' # bundled; creates the ROOT\AudioCodec device + +# ---- driver files ----------------------------------------------------------- +$aap = @{ sys = Join-Path $here 'driver\LibrePodsAAP.sys'; cat = Join-Path $here 'driver\librepodsaap.cat'; inf = Join-Path $here 'driver\LibrePodsAAP.inf' } +$mic = @{ sys = Join-Path $here 'driver-mic\AudioCodec.sys'; cat = Join-Path $here 'driver-mic\audiocodec.cat'; inf = Join-Path $here 'driver-mic\AudioCodec.inf' } +foreach ($f in $aap.Values) { if (-not (Test-Path $f)) { throw "Missing $f" } } +$haveMic = (Test-Path $mic.sys) -and (Test-Path $mic.inf) -and (Test-Path $devcon) + +# ---- 1. test code-signing cert, trusted for driver loading ------------------ +Write-Host '==> Creating + trusting a test code-signing certificate...' +$cert = New-SelfSignedCertificate -Type CodeSigningCert ` + -Subject 'CN=LibrePods Test Cert' ` + -CertStoreLocation Cert:\LocalMachine\My ` + -KeyUsage DigitalSignature -KeyExportPolicy Exportable +$store = Get-Item "Cert:\LocalMachine\My\$($cert.Thumbprint)" +foreach ($name in 'Root', 'TrustedPublisher') { + $s = New-Object System.Security.Cryptography.X509Certificates.X509Store($name, 'LocalMachine') + $s.Open('ReadWrite'); $s.Add($store); $s.Close() +} + +# ---- 2. sign the AAP driver; (re)generate + sign the mic catalog ------------ +function Sign($path) { & $signtool sign /v /fd SHA256 /sm /s My /sha1 $cert.Thumbprint $path } +Write-Host '==> Signing LibrePodsAAP...' +Sign $aap.sys; Sign $aap.cat +if ($haveMic) { + # The mic catalog is regenerated here (inf2cat) so it matches the shipped .sys, + # then signed. inf2cat ships with the WDK; fall back to a bundled catalog if absent. + $inf2cat = (Get-ChildItem 'C:\Program Files (x86)\Windows Kits\10\bin' -Recurse -Filter inf2cat.exe -EA SilentlyContinue | Select-Object -First 1).FullName + if ($inf2cat) { & $inf2cat /driver:(Split-Path $mic.inf) /os:10_X64 | Out-Null } + Write-Host '==> Signing LibrePodsMic...' + Sign $mic.sys + if (Test-Path $mic.cat) { Sign $mic.cat } +} + +# ---- 3. install LibrePodsAAP (PnP profile driver, via pnputil) -------------- +Write-Host '==> Removing any previously installed LibrePodsAAP package...' +$oem = $null +pnputil /enum-drivers | ForEach-Object { + if ($_ -match 'Published Name\s*:\s*(oem\d+\.inf)') { $oem = $matches[1] } + if ($_ -match 'Original Name\s*:\s*LibrePodsAAP\.inf' -and $oem) { + pnputil /delete-driver $oem /uninstall /force | Out-Null + } +} +Write-Host '==> Installing LibrePodsAAP...' +pnputil /add-driver $aap.inf /install + +# ---- 4. install LibrePodsMic (ROOT-enumerated device, via devcon) ----------- +if ($haveMic) { + Write-Host '==> Removing any existing ROOT\AudioCodec (mic) device...' + & $devcon remove 'ROOT\AudioCodec' 2>&1 | Out-Null + Start-Sleep -Seconds 1 + Write-Host '==> Installing LibrePodsMic (virtual microphone)...' + & $devcon install $mic.inf 'ROOT\AudioCodec' +} else { + Write-Host '==> (Skipping LibrePodsMic — driver-mic\ or tools\devcon.exe not bundled.)' +} + +# ---- 5. copy the apps ------------------------------------------------------- +# The daemon owns the driver + AAP session + mic; the WinUI app is its IPC client. +Write-Host "==> Copying the apps to $dest" +New-Item -ItemType Directory -Force -Path $dest | Out-Null +Copy-Item (Join-Path $here 'librepodsd.exe') $dest -Force +foreach ($dll in 'avcodec-61.dll', 'avutil-59.dll', 'swresample-5.dll') { + $p = Join-Path $here $dll + if (Test-Path $p) { Copy-Item $p $dest -Force } +} +# The WinUI app ships as a self-contained folder. +if (Test-Path (Join-Path $here 'winui')) { + Copy-Item (Join-Path $here 'winui') $dest -Recurse -Force +} + +# ---- 6. auto-start at login ------------------------------------------------- +# The daemon is the always-on background process (per-user, in the session — NOT a +# SYSTEM service, which couldn't touch the user's audio/mic). The WinUI app starts +# minimised to the tray (--tray) and is the UI; closing its window hides it back. +Write-Host '==> Adding the daemon + WinUI app to startup...' +$startup = [Environment]::GetFolderPath('Startup') +$ws = New-Object -ComObject WScript.Shell + +$lnkd = $ws.CreateShortcut((Join-Path $startup 'LibrePods Daemon.lnk')) +$lnkd.TargetPath = Join-Path $dest 'librepodsd.exe' +$lnkd.WorkingDirectory = $dest +$lnkd.Description = 'LibrePods background daemon' +$lnkd.Save() + +$winui = Join-Path $dest 'winui\librepods-winui.exe' +if (Test-Path $winui) { + $lnk = $ws.CreateShortcut((Join-Path $startup 'LibrePods.lnk')) + $lnk.TargetPath = $winui + $lnk.Arguments = '--tray' + $lnk.WorkingDirectory = Split-Path $winui + $lnk.Description = 'LibrePods AirPods control' + $lnk.Save() +} + +Write-Host '' +Write-Host '==> Done. A reboot is needed to finish the driver install.' +Write-Host ' After reboot, connect your AirPods — the WinUI app (tray) shows battery' +Write-Host ' + Noise Control, and "AirPods …" appears as a microphone in Sound > Input.' diff --git a/windows/installer/tools/devcon.exe b/windows/installer/tools/devcon.exe new file mode 100644 index 000000000..7ab117a3e Binary files /dev/null and b/windows/installer/tools/devcon.exe differ diff --git a/windows/ipc/Cargo.toml b/windows/ipc/Cargo.toml new file mode 100644 index 000000000..40beb73cd --- /dev/null +++ b/windows/ipc/Cargo.toml @@ -0,0 +1,9 @@ +[package] +name = "librepods-ipc" +version = "0.1.0" +edition = "2021" +description = "Shared IPC protocol (Command/Event types) between librepodsd and the LibrePods UIs." + +[dependencies] +serde = { version = "1", features = ["derive"] } +serde_json = "1" diff --git a/windows/ipc/src/lib.rs b/windows/ipc/src/lib.rs new file mode 100644 index 000000000..6e3b35e9c --- /dev/null +++ b/windows/ipc/src/lib.rs @@ -0,0 +1,180 @@ +//! Shared IPC protocol between `librepodsd` (the driver-owning daemon) and the +//! LibrePods UIs (the tray + the full app). Newline-delimited JSON over a Windows +//! named pipe — see `../../../docs/windows/daemon-ipc/PLAN.md`. + +use serde::{Deserialize, Serialize}; + +/// Two one-directional named pipes (a single duplex pipe deadlocks: a Windows +/// *synchronous* handle serializes I/O, so a blocking ReadFile for commands +/// stalls the WriteFile for events on the same handle). The daemon only WRITES +/// events on `PIPE_EVENTS` and only READS commands on `PIPE_CMDS`, so no handle +/// ever does both directions concurrently. +pub const PIPE_EVENTS: &str = r"\\.\pipe\LibrePods-events"; +pub const PIPE_CMDS: &str = r"\\.\pipe\LibrePods-cmds"; + +/// Raw L2CAP proxy for the full app (Phase 3): the daemon owns the exclusive +/// driver, so the app can't open it — it runs its AAP session over these instead. +/// The daemon writes each incoming AAP packet to `PIPE_L2CAP_RX` (length-prefixed: +/// a u16 LE length, then the bytes) and reads the app's outgoing packets (same +/// framing) from `PIPE_L2CAP_TX`, forwarding them to the driver. One pipe per +/// direction (a sync duplex handle would deadlock). +pub const PIPE_L2CAP_RX: &str = r"\\.\pipe\LibrePods-l2cap-rx"; +pub const PIPE_L2CAP_TX: &str = r"\\.\pipe\LibrePods-l2cap-tx"; + +/// Battery levels (percent), each optional — a packet may carry only some. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] +pub struct Battery { + pub left: Option, + pub right: Option, + pub case: Option, + pub headphone: Option, + // Per-component charging flag (default false for backward-compatible parsing). + #[serde(default)] + pub left_charging: bool, + #[serde(default)] + pub right_charging: bool, + #[serde(default)] + pub case_charging: bool, + #[serde(default)] + pub headphone_charging: bool, +} + +/// The daemon's authoritative state, pushed to clients on connect and on change. +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] +pub struct Snapshot { + pub connected: bool, + pub dev_name: String, + pub battery: Battery, + /// Noise-control mode: 0 = unknown, 1 = off, 2 = ANC, 3 = transparency, 4 = adaptive. + pub anc: u8, + /// An app is currently recording from the virtual mic (hi-res stream on). + pub mic_recording: bool, + /// Auto-enable the hi-res mic on recording (vs. manual control). + pub auto_mode: bool, + /// Conversational Awareness: lower the volume automatically when you speak. + pub conversational_awareness: bool, + /// Adaptive/Personalized Volume: adjust the volume to the environment. + pub adaptive_volume: bool, + /// Allow the "Off" option in noise control (vs. only ANC/Transparency/Adaptive). + pub allow_off: bool, + /// System output volume 0..=100 (the default render endpoint — the AirPods + /// when they're active). Owned by the daemon so it can duck for CA. + pub volume: u8, + /// The output is muted. + pub muted: bool, + /// Latest validated heart-rate BPM (AirPods Pro 3 RTBuddy). `None` when HR + /// monitoring is off or no sample has been decoded yet. Opt-in (drains battery). + pub heart_rate: Option, + /// Device metadata parsed from the 0x1D packet (model number, firmware, serial). + /// Empty until that packet arrives. The serial is sensitive — the app keeps + /// this info hidden behind a reveal toggle. (serde default: older snapshots.) + #[serde(default)] + pub model: String, + #[serde(default)] + pub firmware: String, + #[serde(default)] + pub serial: String, +} + +/// A toggleable AAP control-command setting (the `id` byte of a 0x09 control +/// command). Values are sent as 0x01 (on) / 0x02 (off). +pub mod feature { + pub const ADAPTIVE_VOLUME: u8 = 0x26; + pub const CONVERSATIONAL_AWARENESS: u8 = 0x28; + pub const ALLOW_OFF: u8 = 0x34; +} + +/// Client → daemon. (Volume stays client-side via WASAPI — not the exclusive +/// resource — so it isn't routed through the daemon.) +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(tag = "cmd", rename_all = "snake_case")] +pub enum Command { + /// Sent on connect; the daemon replies with a `State` snapshot. + Hello { kind: ClientKind }, + /// Set noise-control mode (1..=4). + SetAnc { mode: u8 }, + /// Set the hi-res mic mode (auto-enable and/or manual override). + SetMicMode { auto: bool, manual: bool }, + /// Toggle an AAP control-command setting (see the `feature` module). + SetFeature { feature: u8, on: bool }, + /// Set a raw AAP control-command value (opcode 0x09) — e.g. Adaptive noise + /// strength (id 0x2E, value 0..=100). For settings that aren't on/off. + SetControl { id: u8, value: u8 }, + /// Nudge the output volume by `delta` percent (the daemon owns volume). + StepVolume { delta: i32 }, + /// Set the output volume to an absolute percent 0..=100 (e.g. a slider). + SetVolume { percent: u8 }, + /// Mute/unmute the output. + ToggleMute, + /// Enable/disable AirPods Pro 3 heart-rate monitoring (opt-in; off by + /// default because it drains battery). On sends the RTBuddy enable sequence; + /// off sends the stop frame and clears `heart_rate`. + SetHeartRate { on: bool }, + /// AirPods Pro 3 hearing assistance (accessibility amplification). On enables + /// hearing-assist over AAP (0x2C/0x33), switches to Transparency, and writes the + /// full settings to the ATT/GATT (PSM 0x001F, handle 0x2A); off disables it. + /// `left_eq`/`right_eq` are the per-person audiogram: 8 bands of hearing loss in + /// dB HL (250/500/1k/2k/3k/4k/6k/8k Hz). `amplification` 0..=1 overall gain, + /// `balance` -1(L)..=1(R), `tone` -1..=1, `ambient_noise_reduction`/`own_voice` + /// 0..=1, plus conversation boost. + SetHearingAid { + on: bool, + left_eq: Vec, + right_eq: Vec, + amplification: f32, + balance: f32, + tone: f32, + conversation_boost: bool, + ambient_noise_reduction: f32, + own_voice: f32, + }, + /// Start the AAP session (the user accepted the "connect?" prompt). + Connect, + /// Force a clean reconnect (the "Repair connection" button): re-assert the + /// connect request AND drop the current driver so the daemon reopens a fresh + /// AAP session + ATT channel — used to recover a wedged / desynced link where + /// the OS still shows the AirPods connected but our session is stale. + RepairConnection, + /// Release the AAP control session (the "Disconnect" button). Stops + /// controlling the AirPods without stealing the audio link from the OS. + Disconnect, + /// Rename the AirPods (sends the 0x1A rename command). + SetName { name: String }, + /// Request a fresh `State` snapshot. + GetState, + /// Stop the daemon too (e.g. from the tray's "Quit"). + Shutdown, +} + +/// Daemon → client. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(tag = "event", rename_all = "snake_case")] +pub enum Event { + /// Full state, pushed on connect and whenever it changes. + State(Snapshot), + /// A notification for the client to render with its overlay UI. + Overlay { title: String, body: String }, + /// The device is nearby (BLE) but not connected — the client shows a + /// clickable card; a click sends `Command::Connect`. + ConnectPrompt { name: String }, +} + +/// Which UI a client is. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ClientKind { + Tray, + App, +} + +/// Serialize a message as one NDJSON line (trailing `\n`). +pub fn to_line(v: &T) -> String { + let mut s = serde_json::to_string(v).unwrap_or_default(); + s.push('\n'); + s +} + +/// Parse one NDJSON line into a message. +pub fn from_line Deserialize<'de>>(line: &str) -> Option { + serde_json::from_str(line.trim()).ok() +} diff --git a/windows/startup.ps1 b/windows/startup.ps1 new file mode 100644 index 000000000..3586b1412 --- /dev/null +++ b/windows/startup.ps1 @@ -0,0 +1,43 @@ +<# + startup.ps1 - make a LibrePods Windows app launch at user login (or remove it). + + Per-user, NO admin needed. Copies the exe to a stable location + (%LOCALAPPDATA%\LibrePods) and drops a shortcut in the Startup folder, so it + survives even if the WSL build target is cleaned. + + Install (default = the WinUI app): + .\startup.ps1 + .\startup.ps1 -Exe "C:\path\to\librepods-winui.exe" + Remove: + .\startup.ps1 -Remove +#> +param( + [string]$Exe = "$env:LOCALAPPDATA\LibrePods\librepods-winui.exe", + [string]$Arguments = '--tray', # WinUI starts hidden to the tray at login + [string]$Name = 'LibrePods', + [switch]$Remove +) + +$ErrorActionPreference = 'Stop' +$startup = [Environment]::GetFolderPath('Startup') +$lnk = Join-Path $startup "$Name.lnk" + +if ($Remove) { + if (Test-Path $lnk) { Remove-Item $lnk; Write-Host "Removed $lnk" } + else { Write-Host "No startup shortcut to remove." } + return +} + +if (-not (Test-Path $Exe)) { throw "Exe not found: $Exe (copy it there first, or pass -Exe)" } + +$ws = New-Object -ComObject WScript.Shell +$s = $ws.CreateShortcut($lnk) +$s.TargetPath = $Exe +$s.Arguments = $Arguments +$s.WorkingDirectory = Split-Path $Exe +$s.Description = 'LibrePods AirPods control' +$s.Save() + +Write-Host "==> Startup shortcut created:" +Write-Host " $lnk -> $Exe" +Write-Host "It will launch at your next login. To undo: .\startup.ps1 -Remove" diff --git a/windows/winui/.gitignore b/windows/winui/.gitignore new file mode 100644 index 000000000..5e2a1b0fd --- /dev/null +++ b/windows/winui/.gitignore @@ -0,0 +1,6 @@ +# C# / .NET / WinUI build artifacts +bin/ +obj/ +*.user +.vs/ +Generated Files/ diff --git a/windows/winui/LibrePods.WinUI.sln b/windows/winui/LibrePods.WinUI.sln new file mode 100644 index 000000000..09708358a --- /dev/null +++ b/windows/winui/LibrePods.WinUI.sln @@ -0,0 +1,22 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.11.0.0 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "LibrePods.WinUI", "LibrePods.WinUI\LibrePods.WinUI.csproj", "{4B2A6C10-7F3E-4E9C-9D2A-1F0B7A2C5E31}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|x64 = Debug|x64 + Release|x64 = Release|x64 + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {4B2A6C10-7F3E-4E9C-9D2A-1F0B7A2C5E31}.Debug|x64.ActiveCfg = Debug|x64 + {4B2A6C10-7F3E-4E9C-9D2A-1F0B7A2C5E31}.Debug|x64.Build.0 = Debug|x64 + {4B2A6C10-7F3E-4E9C-9D2A-1F0B7A2C5E31}.Release|x64.ActiveCfg = Release|x64 + {4B2A6C10-7F3E-4E9C-9D2A-1F0B7A2C5E31}.Release|x64.Build.0 = Release|x64 + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection +EndGlobal diff --git a/windows/winui/LibrePods.WinUI/App.xaml b/windows/winui/LibrePods.WinUI/App.xaml new file mode 100644 index 000000000..ccab47c08 --- /dev/null +++ b/windows/winui/LibrePods.WinUI/App.xaml @@ -0,0 +1,53 @@ + + + + + + + + + + + #FF039BE5 + #FF0288C7 + #FF039BE5 + #FF29AEEA + #FF57C1EF + + + + #FF29AEEA + #FF29AEEA + #FF039BE5 + #FF0288C7 + #FF01579B + + + + + + + + + + + + + + + diff --git a/windows/winui/LibrePods.WinUI/App.xaml.cs b/windows/winui/LibrePods.WinUI/App.xaml.cs new file mode 100644 index 000000000..000f91169 --- /dev/null +++ b/windows/winui/LibrePods.WinUI/App.xaml.cs @@ -0,0 +1,159 @@ +using LibrePods.WinUI.Ipc; +using LibrePods.WinUI.Popup; +using LibrePods.WinUI.Services; +using LibrePods.WinUI.Tray; +using Microsoft.UI.Xaml; + +namespace LibrePods.WinUI; + +/// The app is primarily a tray client of `librepodsd`. It starts hidden to the +/// tray: the DaemonClient connects (spawning the daemon if needed), the main +/// window is created but not shown, and the tray icon drives Open/Quit. +public partial class App : Application +{ + public DaemonClient Daemon { get; } = new(); + + private MainWindow? _window; + private TrayController? _tray; + + // The connection "island" popup. A single instance is reused so reconnect + // spam re-populates it rather than stacking multiple popups; it self-closes + // after its animation and clears this reference. `_podsConnected` tracks the + // AirPods connected state so we fire only on the false→true transition. + private IslandWindow? _island; + private bool _podsConnected; + // Last-seen model number (from the 0x1D metadata), so message-mode island + // popups can show the right device render even without a full Snapshot. + private string _lastModel = ""; + + public App() + { + // InitializeComponent loads App.xaml, which creates the single Loc instance + // (); its ctor publishes Loc.Instance for code. + InitializeComponent(); + } + + protected override void OnLaunched(LaunchActivatedEventArgs args) + { + // The main window is created hidden; closing it hides back to the tray. + _window = new MainWindow(Daemon); + + _tray = new TrayController( + onOpen: () => _window.ShowFromTray(), + onQuit: ExitApp, + client: Daemon); + _tray.Show(); + + // Register native toast notifications (Windows App SDK AppNotificationManager) + // before the daemon starts producing overlays. A toast click ("action=open") + // reactivates the window — marshal to the UI thread, same as the tray "Open". + Notifier.Register(() => + _window.DispatcherQueue.TryEnqueue(() => _window.ShowFromTray())); + + // Surface daemon overlays as the centred floating island (not a Windows + // toast — the toast was the corner card the user didn't want). Marshalled + // to the UI thread. + Daemon.OverlayReceived += (title, body) => + _window.DispatcherQueue.TryEnqueue(() => ShowIslandMessage(title, body)); + + // Drive the tray's tooltip / menu / icon badge from daemon state. Snapshots + // arrive on a background thread; marshal to the UI thread (same as the window). + Daemon.SnapshotReceived += s => + _window.DispatcherQueue.TryEnqueue(() => _tray?.UpdateSnapshot(s)); + + // Show the connection island on the AirPods connect transition only + // (Snapshot.Connected false→true). Tracking the snapshot's connected flag + // — rather than the pipe-level ConnectionChanged — means a daemon pipe + // reconnect (which re-pushes the same connected=true snapshot) does not + // re-fire the popup. Marshalled to the UI thread. + Daemon.SnapshotReceived += s => + _window.DispatcherQueue.TryEnqueue(() => + { + if (!string.IsNullOrEmpty(s.Model)) + { + _lastModel = s.Model; + AppSettings.SetLastModel(s.Model); // survive restarts for the connect island + } + var now = s.Connected; + if (now && !_podsConnected) ShowIsland(s); + _podsConnected = now; + }); + + // If a previous daemon is still running (an orphan from a crashed or + // force-killed session), shut it down GRACEFULLY first so THIS app owns a + // single clean instance — the new daemon then opens the driver fresh, + // instead of hitting "driver open FAILED" against a leaked handle. + DaemonClient.ShutdownExistingDaemon(); + + // Begin talking to the daemon (background reader + writer loops). + Daemon.Start(); + + // Show the window on launch — unless started hidden. Autostart (startup.ps1) + // passes "--tray", so at login only the tray icon appears and the window + // opens on demand (double-click the tray, or "Open"); closing it hides it + // back to the tray. + var argv = Environment.GetCommandLineArgs(); + bool startHidden = Array.IndexOf(argv, "--tray") >= 0 + || Array.IndexOf(argv, "--minimized") >= 0; + if (!startHidden) _window.Activate(); + } + + /// Create (or reuse) the single island window and play the connect popup. + /// Must run on the UI thread. Fully guarded — a popup failure never crashes + /// the app; at worst there is simply no island. + private void ShowIsland(Snapshot snapshot) + { + try + { + if (_island is null) + { + _island = new IslandWindow(); + // The island hides + reuses itself after each slide-out (it never + // calls Window.Close, which fail-fasts in WinUI's theme teardown). + // Keep the Closed handler only as a shutdown safety net. + _island.Closed += (_, _) => _island = null; + } + _island.ShowConnected(snapshot); + } + catch + { + _island = null; + } + } + + /// Show a daemon overlay as the centred floating island (message mode). + private void ShowIslandMessage(string title, string body) + { + try + { + if (_island is null) + { + _island = new IslandWindow(); + _island.Closed += (_, _) => _island = null; + } + _island.ShowMessage(title, body, _lastModel); + } + catch + { + _island = null; + } + } + + /// The tray "Quit": stop the daemon too and exit. On Windows one front-end + /// runs at a time, and this app spawned the daemon, so quitting it should not + /// leave a headless librepodsd lingering. (Closing the *window* only hides to + /// the tray — the daemon stays; Quit is the full teardown.) + public void ExitApp() + { + try + { + Daemon.Shutdown(); // tell librepodsd to exit + System.Threading.Thread.Sleep(150); // let the writer flush it + } + catch { } + Notifier.Unregister(); + _tray?.Dispose(); + Daemon.Dispose(); + Exit(); + } +} diff --git a/windows/winui/LibrePods.WinUI/Assets/airpods.png b/windows/winui/LibrePods.WinUI/Assets/airpods.png new file mode 100644 index 000000000..681ee750a Binary files /dev/null and b/windows/winui/LibrePods.WinUI/Assets/airpods.png differ diff --git a/windows/winui/LibrePods.WinUI/Assets/airpods_1.png b/windows/winui/LibrePods.WinUI/Assets/airpods_1.png new file mode 100644 index 000000000..681ee750a Binary files /dev/null and b/windows/winui/LibrePods.WinUI/Assets/airpods_1.png differ diff --git a/windows/winui/LibrePods.WinUI/Assets/airpods_1_buds.png b/windows/winui/LibrePods.WinUI/Assets/airpods_1_buds.png new file mode 100644 index 000000000..8bea6a255 Binary files /dev/null and b/windows/winui/LibrePods.WinUI/Assets/airpods_1_buds.png differ diff --git a/windows/winui/LibrePods.WinUI/Assets/airpods_1_case.png b/windows/winui/LibrePods.WinUI/Assets/airpods_1_case.png new file mode 100644 index 000000000..be694b048 Binary files /dev/null and b/windows/winui/LibrePods.WinUI/Assets/airpods_1_case.png differ diff --git a/windows/winui/LibrePods.WinUI/Assets/airpods_1_left.png b/windows/winui/LibrePods.WinUI/Assets/airpods_1_left.png new file mode 100644 index 000000000..88e13948e Binary files /dev/null and b/windows/winui/LibrePods.WinUI/Assets/airpods_1_left.png differ diff --git a/windows/winui/LibrePods.WinUI/Assets/airpods_1_right.png b/windows/winui/LibrePods.WinUI/Assets/airpods_1_right.png new file mode 100644 index 000000000..76495bee9 Binary files /dev/null and b/windows/winui/LibrePods.WinUI/Assets/airpods_1_right.png differ diff --git a/windows/winui/LibrePods.WinUI/Assets/airpods_2.png b/windows/winui/LibrePods.WinUI/Assets/airpods_2.png new file mode 100644 index 000000000..681ee750a Binary files /dev/null and b/windows/winui/LibrePods.WinUI/Assets/airpods_2.png differ diff --git a/windows/winui/LibrePods.WinUI/Assets/airpods_2_buds.png b/windows/winui/LibrePods.WinUI/Assets/airpods_2_buds.png new file mode 100644 index 000000000..8bea6a255 Binary files /dev/null and b/windows/winui/LibrePods.WinUI/Assets/airpods_2_buds.png differ diff --git a/windows/winui/LibrePods.WinUI/Assets/airpods_2_case.png b/windows/winui/LibrePods.WinUI/Assets/airpods_2_case.png new file mode 100644 index 000000000..be694b048 Binary files /dev/null and b/windows/winui/LibrePods.WinUI/Assets/airpods_2_case.png differ diff --git a/windows/winui/LibrePods.WinUI/Assets/airpods_2_left.png b/windows/winui/LibrePods.WinUI/Assets/airpods_2_left.png new file mode 100644 index 000000000..88e13948e Binary files /dev/null and b/windows/winui/LibrePods.WinUI/Assets/airpods_2_left.png differ diff --git a/windows/winui/LibrePods.WinUI/Assets/airpods_2_right.png b/windows/winui/LibrePods.WinUI/Assets/airpods_2_right.png new file mode 100644 index 000000000..76495bee9 Binary files /dev/null and b/windows/winui/LibrePods.WinUI/Assets/airpods_2_right.png differ diff --git a/windows/winui/LibrePods.WinUI/Assets/airpods_3.png b/windows/winui/LibrePods.WinUI/Assets/airpods_3.png new file mode 100644 index 000000000..681ee750a Binary files /dev/null and b/windows/winui/LibrePods.WinUI/Assets/airpods_3.png differ diff --git a/windows/winui/LibrePods.WinUI/Assets/airpods_3_buds.png b/windows/winui/LibrePods.WinUI/Assets/airpods_3_buds.png new file mode 100644 index 000000000..8bea6a255 Binary files /dev/null and b/windows/winui/LibrePods.WinUI/Assets/airpods_3_buds.png differ diff --git a/windows/winui/LibrePods.WinUI/Assets/airpods_3_case.png b/windows/winui/LibrePods.WinUI/Assets/airpods_3_case.png new file mode 100644 index 000000000..be694b048 Binary files /dev/null and b/windows/winui/LibrePods.WinUI/Assets/airpods_3_case.png differ diff --git a/windows/winui/LibrePods.WinUI/Assets/airpods_3_left.png b/windows/winui/LibrePods.WinUI/Assets/airpods_3_left.png new file mode 100644 index 000000000..88e13948e Binary files /dev/null and b/windows/winui/LibrePods.WinUI/Assets/airpods_3_left.png differ diff --git a/windows/winui/LibrePods.WinUI/Assets/airpods_3_right.png b/windows/winui/LibrePods.WinUI/Assets/airpods_3_right.png new file mode 100644 index 000000000..76495bee9 Binary files /dev/null and b/windows/winui/LibrePods.WinUI/Assets/airpods_3_right.png differ diff --git a/windows/winui/LibrePods.WinUI/Assets/airpods_4.png b/windows/winui/LibrePods.WinUI/Assets/airpods_4.png new file mode 100644 index 000000000..681ee750a Binary files /dev/null and b/windows/winui/LibrePods.WinUI/Assets/airpods_4.png differ diff --git a/windows/winui/LibrePods.WinUI/Assets/airpods_4_buds.png b/windows/winui/LibrePods.WinUI/Assets/airpods_4_buds.png new file mode 100644 index 000000000..8bea6a255 Binary files /dev/null and b/windows/winui/LibrePods.WinUI/Assets/airpods_4_buds.png differ diff --git a/windows/winui/LibrePods.WinUI/Assets/airpods_4_case.png b/windows/winui/LibrePods.WinUI/Assets/airpods_4_case.png new file mode 100644 index 000000000..be694b048 Binary files /dev/null and b/windows/winui/LibrePods.WinUI/Assets/airpods_4_case.png differ diff --git a/windows/winui/LibrePods.WinUI/Assets/airpods_4_left.png b/windows/winui/LibrePods.WinUI/Assets/airpods_4_left.png new file mode 100644 index 000000000..88e13948e Binary files /dev/null and b/windows/winui/LibrePods.WinUI/Assets/airpods_4_left.png differ diff --git a/windows/winui/LibrePods.WinUI/Assets/airpods_4_right.png b/windows/winui/LibrePods.WinUI/Assets/airpods_4_right.png new file mode 100644 index 000000000..76495bee9 Binary files /dev/null and b/windows/winui/LibrePods.WinUI/Assets/airpods_4_right.png differ diff --git a/windows/winui/LibrePods.WinUI/Assets/airpods_pro_1.png b/windows/winui/LibrePods.WinUI/Assets/airpods_pro_1.png new file mode 100644 index 000000000..681ee750a Binary files /dev/null and b/windows/winui/LibrePods.WinUI/Assets/airpods_pro_1.png differ diff --git a/windows/winui/LibrePods.WinUI/Assets/airpods_pro_1_buds.png b/windows/winui/LibrePods.WinUI/Assets/airpods_pro_1_buds.png new file mode 100644 index 000000000..8bea6a255 Binary files /dev/null and b/windows/winui/LibrePods.WinUI/Assets/airpods_pro_1_buds.png differ diff --git a/windows/winui/LibrePods.WinUI/Assets/airpods_pro_1_case.png b/windows/winui/LibrePods.WinUI/Assets/airpods_pro_1_case.png new file mode 100644 index 000000000..be694b048 Binary files /dev/null and b/windows/winui/LibrePods.WinUI/Assets/airpods_pro_1_case.png differ diff --git a/windows/winui/LibrePods.WinUI/Assets/airpods_pro_1_left.png b/windows/winui/LibrePods.WinUI/Assets/airpods_pro_1_left.png new file mode 100644 index 000000000..88e13948e Binary files /dev/null and b/windows/winui/LibrePods.WinUI/Assets/airpods_pro_1_left.png differ diff --git a/windows/winui/LibrePods.WinUI/Assets/airpods_pro_1_right.png b/windows/winui/LibrePods.WinUI/Assets/airpods_pro_1_right.png new file mode 100644 index 000000000..76495bee9 Binary files /dev/null and b/windows/winui/LibrePods.WinUI/Assets/airpods_pro_1_right.png differ diff --git a/windows/winui/LibrePods.WinUI/Assets/airpods_pro_2.png b/windows/winui/LibrePods.WinUI/Assets/airpods_pro_2.png new file mode 100644 index 000000000..681ee750a Binary files /dev/null and b/windows/winui/LibrePods.WinUI/Assets/airpods_pro_2.png differ diff --git a/windows/winui/LibrePods.WinUI/Assets/airpods_pro_2_buds.png b/windows/winui/LibrePods.WinUI/Assets/airpods_pro_2_buds.png new file mode 100644 index 000000000..8bea6a255 Binary files /dev/null and b/windows/winui/LibrePods.WinUI/Assets/airpods_pro_2_buds.png differ diff --git a/windows/winui/LibrePods.WinUI/Assets/airpods_pro_2_case.png b/windows/winui/LibrePods.WinUI/Assets/airpods_pro_2_case.png new file mode 100644 index 000000000..be694b048 Binary files /dev/null and b/windows/winui/LibrePods.WinUI/Assets/airpods_pro_2_case.png differ diff --git a/windows/winui/LibrePods.WinUI/Assets/airpods_pro_2_left.png b/windows/winui/LibrePods.WinUI/Assets/airpods_pro_2_left.png new file mode 100644 index 000000000..88e13948e Binary files /dev/null and b/windows/winui/LibrePods.WinUI/Assets/airpods_pro_2_left.png differ diff --git a/windows/winui/LibrePods.WinUI/Assets/airpods_pro_2_right.png b/windows/winui/LibrePods.WinUI/Assets/airpods_pro_2_right.png new file mode 100644 index 000000000..76495bee9 Binary files /dev/null and b/windows/winui/LibrePods.WinUI/Assets/airpods_pro_2_right.png differ diff --git a/windows/winui/LibrePods.WinUI/Assets/airpods_pro_3.png b/windows/winui/LibrePods.WinUI/Assets/airpods_pro_3.png new file mode 100644 index 000000000..681ee750a Binary files /dev/null and b/windows/winui/LibrePods.WinUI/Assets/airpods_pro_3.png differ diff --git a/windows/winui/LibrePods.WinUI/Assets/airpods_pro_3_buds.png b/windows/winui/LibrePods.WinUI/Assets/airpods_pro_3_buds.png new file mode 100644 index 000000000..8bea6a255 Binary files /dev/null and b/windows/winui/LibrePods.WinUI/Assets/airpods_pro_3_buds.png differ diff --git a/windows/winui/LibrePods.WinUI/Assets/airpods_pro_3_case.png b/windows/winui/LibrePods.WinUI/Assets/airpods_pro_3_case.png new file mode 100644 index 000000000..be694b048 Binary files /dev/null and b/windows/winui/LibrePods.WinUI/Assets/airpods_pro_3_case.png differ diff --git a/windows/winui/LibrePods.WinUI/Assets/airpods_pro_3_left.png b/windows/winui/LibrePods.WinUI/Assets/airpods_pro_3_left.png new file mode 100644 index 000000000..88e13948e Binary files /dev/null and b/windows/winui/LibrePods.WinUI/Assets/airpods_pro_3_left.png differ diff --git a/windows/winui/LibrePods.WinUI/Assets/airpods_pro_3_right.png b/windows/winui/LibrePods.WinUI/Assets/airpods_pro_3_right.png new file mode 100644 index 000000000..76495bee9 Binary files /dev/null and b/windows/winui/LibrePods.WinUI/Assets/airpods_pro_3_right.png differ diff --git a/windows/winui/LibrePods.WinUI/Assets/anc_adaptive.png b/windows/winui/LibrePods.WinUI/Assets/anc_adaptive.png new file mode 100644 index 000000000..b0863357c Binary files /dev/null and b/windows/winui/LibrePods.WinUI/Assets/anc_adaptive.png differ diff --git a/windows/winui/LibrePods.WinUI/Assets/anc_nc.png b/windows/winui/LibrePods.WinUI/Assets/anc_nc.png new file mode 100644 index 000000000..0b4ec6e88 Binary files /dev/null and b/windows/winui/LibrePods.WinUI/Assets/anc_nc.png differ diff --git a/windows/winui/LibrePods.WinUI/Assets/anc_transparency.png b/windows/winui/LibrePods.WinUI/Assets/anc_transparency.png new file mode 100644 index 000000000..b03b74cbf Binary files /dev/null and b/windows/winui/LibrePods.WinUI/Assets/anc_transparency.png differ diff --git a/windows/winui/LibrePods.WinUI/Assets/app.ico b/windows/winui/LibrePods.WinUI/Assets/app.ico new file mode 100644 index 000000000..bfad64d90 Binary files /dev/null and b/windows/winui/LibrePods.WinUI/Assets/app.ico differ diff --git a/windows/winui/LibrePods.WinUI/Assets/icon.png b/windows/winui/LibrePods.WinUI/Assets/icon.png new file mode 100644 index 000000000..02fc4302f Binary files /dev/null and b/windows/winui/LibrePods.WinUI/Assets/icon.png differ diff --git a/windows/winui/LibrePods.WinUI/Assets/tray.ico b/windows/winui/LibrePods.WinUI/Assets/tray.ico new file mode 100644 index 000000000..bfad64d90 Binary files /dev/null and b/windows/winui/LibrePods.WinUI/Assets/tray.ico differ diff --git a/windows/winui/LibrePods.WinUI/Controls/AdaptiveNoiseCard.xaml b/windows/winui/LibrePods.WinUI/Controls/AdaptiveNoiseCard.xaml new file mode 100644 index 000000000..7797d59e2 --- /dev/null +++ b/windows/winui/LibrePods.WinUI/Controls/AdaptiveNoiseCard.xaml @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/windows/winui/LibrePods.WinUI/Controls/DeviceHeader.xaml.cs b/windows/winui/LibrePods.WinUI/Controls/DeviceHeader.xaml.cs new file mode 100644 index 000000000..aced6105c --- /dev/null +++ b/windows/winui/LibrePods.WinUI/Controls/DeviceHeader.xaml.cs @@ -0,0 +1,106 @@ +using System; +using LibrePods.WinUI.Ipc; +using LibrePods.WinUI.Services; +using Microsoft.UI.Xaml; +using Microsoft.UI.Xaml.Controls; +using Microsoft.UI.Xaml.Input; +using Microsoft.UI.Xaml.Media; +using Microsoft.UI.Xaml.Media.Imaging; + +namespace LibrePods.WinUI.Controls; + +/// Device header card: product image + name (editable via a pencil), connection +/// dot/status, an inline Connect link while disconnected, and a Disconnect button +/// while connected. Renders a Snapshot; actions forward to the daemon. +public sealed partial class DeviceHeader : UserControl +{ + public DaemonClient? Client { get; set; } + + // The artwork family currently shown, so we only reload the image when the + // detected model actually changes (snapshots arrive several times a second). + private string? _artFamily; + + public DeviceHeader() + { + InitializeComponent(); + } + + /// Render the header from a daemon Snapshot (name + connection state). + public void Update(Snapshot s) + { + // Don't clobber the name while the user is editing it. + if (NameEdit.Visibility != Visibility.Visible) + DeviceName.Text = string.IsNullOrWhiteSpace(s.DevName) ? "LibrePods" : s.DevName; + + // Model-aware product image (falls back to the generic airpods.png until + // the 0x1D metadata arrives / for unknown models). + var family = DeviceArt.Family(s.Model); + if (family != _artFamily) + { + _artFamily = family; + DeviceImage.Source = new BitmapImage(new Uri(DeviceArt.MainImage(s.Model))); + } + + StatusText.Text = Localize.Get(s.Connected ? "Status_Connected" : "Status_Disconnected"); + StatusDot.Fill = new SolidColorBrush( + s.Connected ? Microsoft.UI.Colors.LimeGreen : Microsoft.UI.Colors.Gray); + ConnectButton.Visibility = s.Connected ? Visibility.Collapsed : Visibility.Visible; + // Rename + Disconnect only make sense while connected; Repair only while not. + RenameBtn.Visibility = s.Connected ? Visibility.Visible : Visibility.Collapsed; + DisconnectButton.Visibility = s.Connected ? Visibility.Visible : Visibility.Collapsed; + RepairButton.Visibility = s.Connected ? Visibility.Collapsed : Visibility.Visible; + if (!s.Connected) ExitEdit(); + } + + /// The events pipe dropped — reflect that we're no longer hearing from the daemon. + public void ShowWaitingForDaemon() => StatusText.Text = Localize.Get("Status_WaitingForDaemon"); + + private void Connect_Click(object sender, RoutedEventArgs e) => Client?.Connect(); + private void Disconnect_Click(object sender, RoutedEventArgs e) => Client?.Disconnect(); + private void Repair_Click(object sender, RoutedEventArgs e) => Client?.RepairConnection(); + + // ---- Rename ------------------------------------------------------------ + + private void Rename_Click(object sender, RoutedEventArgs e) + { + NameBox.Text = DeviceName.Text; + NameView.Visibility = Visibility.Collapsed; + NameEdit.Visibility = Visibility.Visible; + NameBox.Focus(FocusState.Programmatic); + NameBox.SelectAll(); + } + + private void Submit_Click(object sender, RoutedEventArgs e) + { + var name = NameBox.Text?.Trim() ?? ""; + if (name.Length > 0) + { + Client?.SetName(name); + DeviceName.Text = name; // optimistic + ExitEdit(); + // The daemon renames the device, but Windows only reflects the new + // name after a disconnect/reconnect — tell the user so the unchanged + // OS name doesn't read as a failure. + RenameHint.Target = DeviceName; + RenameHint.Title = Localize.Get("Rename_WindowsNoticeTitle"); + RenameHint.Subtitle = Localize.Get("Rename_WindowsNoticeBody"); + RenameHint.IsOpen = true; + return; + } + ExitEdit(); + } + + private void Cancel_Click(object sender, RoutedEventArgs e) => ExitEdit(); + + private void NameBox_KeyDown(object sender, KeyRoutedEventArgs e) + { + if (e.Key == Windows.System.VirtualKey.Enter) Submit_Click(sender, e); + else if (e.Key == Windows.System.VirtualKey.Escape) ExitEdit(); + } + + private void ExitEdit() + { + NameEdit.Visibility = Visibility.Collapsed; + NameView.Visibility = Visibility.Visible; + } +} diff --git a/windows/winui/LibrePods.WinUI/Controls/FeaturesCard.xaml b/windows/winui/LibrePods.WinUI/Controls/FeaturesCard.xaml new file mode 100644 index 000000000..718777be9 --- /dev/null +++ b/windows/winui/LibrePods.WinUI/Controls/FeaturesCard.xaml @@ -0,0 +1,25 @@ + + + + + + + + + + + + + diff --git a/windows/winui/LibrePods.WinUI/Controls/FeaturesCard.xaml.cs b/windows/winui/LibrePods.WinUI/Controls/FeaturesCard.xaml.cs new file mode 100644 index 000000000..6b039bc39 --- /dev/null +++ b/windows/winui/LibrePods.WinUI/Controls/FeaturesCard.xaml.cs @@ -0,0 +1,53 @@ +using LibrePods.WinUI.Ipc; +using Microsoft.UI.Xaml; +using Microsoft.UI.Xaml.Controls; + +namespace LibrePods.WinUI.Controls; + +/// Feature toggles: Conversational Awareness, Adaptive Volume, and Allow "Off" +/// mode. Each maps to a set_feature command. +public sealed partial class FeaturesCard : UserControl +{ + public DaemonClient? Client { get; set; } + + private bool _applying; + + public FeaturesCard() + { + InitializeComponent(); + } + + /// Render the three toggles from a daemon Snapshot. + public void Update(Snapshot s) + { + _applying = true; + try + { + ConvAwarenessSwitch.IsOn = s.ConversationalAwareness; + AdaptiveVolumeSwitch.IsOn = s.AdaptiveVolume; + AllowOffSwitch.IsOn = s.AllowOff; + } + finally + { + _applying = false; + } + } + + private void ConvAwareness_Toggled(object sender, RoutedEventArgs e) + { + if (_applying) return; + Client?.SetFeature(Feature.ConversationalAwareness, ConvAwarenessSwitch.IsOn); + } + + private void AdaptiveVolume_Toggled(object sender, RoutedEventArgs e) + { + if (_applying) return; + Client?.SetFeature(Feature.AdaptiveVolume, AdaptiveVolumeSwitch.IsOn); + } + + private void AllowOff_Toggled(object sender, RoutedEventArgs e) + { + if (_applying) return; + Client?.SetFeature(Feature.AllowOff, AllowOffSwitch.IsOn); + } +} diff --git a/windows/winui/LibrePods.WinUI/Controls/HearingAidCard.xaml b/windows/winui/LibrePods.WinUI/Controls/HearingAidCard.xaml new file mode 100644 index 000000000..b5b1c616e --- /dev/null +++ b/windows/winui/LibrePods.WinUI/Controls/HearingAidCard.xaml @@ -0,0 +1,59 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/windows/winui/LibrePods.WinUI/Controls/HearingAidCard.xaml.cs b/windows/winui/LibrePods.WinUI/Controls/HearingAidCard.xaml.cs new file mode 100644 index 000000000..693767b3c --- /dev/null +++ b/windows/winui/LibrePods.WinUI/Controls/HearingAidCard.xaml.cs @@ -0,0 +1,124 @@ +using System; +using System.Linq; +using LibrePods.WinUI.Ipc; +using Microsoft.UI.Xaml; +using Microsoft.UI.Xaml.Controls; +using Microsoft.UI.Xaml.Controls.Primitives; + +namespace LibrePods.WinUI.Controls; + +/// Hearing assistance (AirPods Pro 3, experimental). A per-person audiogram (8-band +/// hearing loss in dB HL, left + right) plus amplification / balance / tone sliders +/// and conversation boost. The daemon enables hearing-assist over AAP, switches to +/// Transparency, and writes it all to the ATT/GATT. Changes are debounced (each +/// apply is a ~1.3 s enable + ATT round-trip). +public sealed partial class HearingAidCard : UserControl +{ + public DaemonClient? Client { get; set; } + + private static readonly string[] Freqs = + { "250 Hz", "500 Hz", "1 kHz", "2 kHz", "3 kHz", "4 kHz", "6 kHz", "8 kHz" }; + + private readonly NumberBox[] _leftEq = new NumberBox[8]; + private readonly NumberBox[] _rightEq = new NumberBox[8]; + // Long-ish: filling in the audiogram touches many boxes in a row and each apply + // is a heavy ATT round-trip on the daemon, so coalesce a burst of edits into one + // apply once the user pauses (the daemon also drops superseded applies). + private readonly DispatcherTimer _debounce = new() { Interval = TimeSpan.FromMilliseconds(1500) }; + + public HearingAidCard() + { + InitializeComponent(); + BuildAudiogramGrid(); + _debounce.Tick += (_, _) => { _debounce.Stop(); Apply(); }; + } + + private void BuildAudiogramGrid() + { + AudiogramGrid.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto }); + AudiogramGrid.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) }); + AudiogramGrid.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) }); + + AudiogramGrid.RowDefinitions.Add(new RowDefinition { Height = GridLength.Auto }); + AddCell(Header("Hz"), 0, 0); + AddCell(Header("L"), 0, 1); + AddCell(Header("R"), 0, 2); + + for (int i = 0; i < 8; i++) + { + int row = i + 1; + AudiogramGrid.RowDefinitions.Add(new RowDefinition { Height = GridLength.Auto }); + AddCell(new TextBlock { Text = Freqs[i], VerticalAlignment = VerticalAlignment.Center }, row, 0); + _leftEq[i] = MakeEqBox(); + _rightEq[i] = MakeEqBox(); + AddCell(_leftEq[i], row, 1); + AddCell(_rightEq[i], row, 2); + } + } + + private static TextBlock Header(string t) => new() { Text = t, Opacity = 0.6 }; + + private NumberBox MakeEqBox() + { + var nb = new NumberBox + { + Minimum = 0, + Maximum = 90, + Value = 0, + SmallChange = 5, + LargeChange = 10, + SpinButtonPlacementMode = NumberBoxSpinButtonPlacementMode.Hidden, + IsEnabled = false, + }; + nb.ValueChanged += (_, _) => { if (EnableSwitch.IsOn) { _debounce.Stop(); _debounce.Start(); } }; + return nb; + } + + private void AddCell(FrameworkElement el, int row, int col) + { + Grid.SetRow(el, row); + Grid.SetColumn(el, col); + AudiogramGrid.Children.Add(el); + } + + private void Enable_Toggled(object sender, RoutedEventArgs e) + { + bool on = EnableSwitch.IsOn; + AmpSlider.IsEnabled = on; + BalanceSlider.IsEnabled = on; + ToneSlider.IsEnabled = on; + ConvBoostSwitch.IsEnabled = on; + foreach (var b in _leftEq) b.IsEnabled = on; + foreach (var b in _rightEq) b.IsEnabled = on; + _debounce.Stop(); + Apply(); // enabling/disabling applies immediately + } + + private void Settings_Changed(object sender, RangeBaseValueChangedEventArgs e) + { + if (EnableSwitch.IsOn) { _debounce.Stop(); _debounce.Start(); } + } + + private void ConvBoost_Toggled(object sender, RoutedEventArgs e) + { + if (EnableSwitch.IsOn) { _debounce.Stop(); _debounce.Start(); } + } + + private static float EqVal(NumberBox nb) => double.IsNaN(nb.Value) ? 0f : (float)nb.Value; + + private void Apply() + { + Client?.SetHearingAid(new SetHearingAidCmd + { + On = EnableSwitch.IsOn, + LeftEq = _leftEq.Select(EqVal).ToArray(), + RightEq = _rightEq.Select(EqVal).ToArray(), + Amplification = (float)(AmpSlider.Value / 100.0), // 0..1 + Balance = (float)(BalanceSlider.Value / 100.0), // -1..1 + Tone = (float)(ToneSlider.Value / 100.0), // -1..1 + ConversationBoost = ConvBoostSwitch.IsOn, + AmbientNoiseReduction = 0f, + OwnVoice = 0f, + }); + } +} diff --git a/windows/winui/LibrePods.WinUI/Controls/HeartRateCard.xaml b/windows/winui/LibrePods.WinUI/Controls/HeartRateCard.xaml new file mode 100644 index 000000000..85a0242a2 --- /dev/null +++ b/windows/winui/LibrePods.WinUI/Controls/HeartRateCard.xaml @@ -0,0 +1,39 @@ + + + + + + + + + + + + + + + + + + diff --git a/windows/winui/LibrePods.WinUI/Controls/HeartRateCard.xaml.cs b/windows/winui/LibrePods.WinUI/Controls/HeartRateCard.xaml.cs new file mode 100644 index 000000000..539ad517e --- /dev/null +++ b/windows/winui/LibrePods.WinUI/Controls/HeartRateCard.xaml.cs @@ -0,0 +1,41 @@ +using LibrePods.WinUI.Ipc; +using Microsoft.UI.Xaml; +using Microsoft.UI.Xaml.Controls; + +namespace LibrePods.WinUI.Controls; + +/// Heart-rate monitoring toggle + BPM readout (AirPods Pro 3, experimental). The +/// toggle is user-driven — the snapshot has no "monitoring on" flag, only the BPM +/// value — so Update touches only the reading. +public sealed partial class HeartRateCard : UserControl +{ + public DaemonClient? Client { get; set; } + + private bool _applying; + + public HeartRateCard() + { + InitializeComponent(); + } + + /// Render the BPM value from a daemon Snapshot (no reading → em dash). + public void Update(Snapshot s) + { + _applying = true; + try + { + HeartRateBpm.Text = s.HeartRate is ushort bpm ? bpm.ToString() : "—"; + } + finally + { + _applying = false; + } + } + + private void HeartRate_Toggled(object sender, RoutedEventArgs e) + { + if (_applying) return; + Client?.SetHeartRate(HeartRateSwitch.IsOn); + if (!HeartRateSwitch.IsOn) HeartRateBpm.Text = "—"; + } +} diff --git a/windows/winui/LibrePods.WinUI/Controls/MicCard.xaml b/windows/winui/LibrePods.WinUI/Controls/MicCard.xaml new file mode 100644 index 000000000..722c28aea --- /dev/null +++ b/windows/winui/LibrePods.WinUI/Controls/MicCard.xaml @@ -0,0 +1,24 @@ + + + + + + + + + + + + + diff --git a/windows/winui/LibrePods.WinUI/Controls/MicCard.xaml.cs b/windows/winui/LibrePods.WinUI/Controls/MicCard.xaml.cs new file mode 100644 index 000000000..eff87560b --- /dev/null +++ b/windows/winui/LibrePods.WinUI/Controls/MicCard.xaml.cs @@ -0,0 +1,52 @@ +using LibrePods.WinUI.Ipc; +using LibrePods.WinUI.Services; +using Microsoft.UI.Xaml; +using Microsoft.UI.Xaml.Controls; + +namespace LibrePods.WinUI.Controls; + +/// Hi-res microphone control: auto-enable-on-recording toggle + a manual +/// enable-now toggle. Both send a set_mic_mode carrying the pair; the manual +/// handlers need the current recording state, so the last snapshot's value is kept. +public sealed partial class MicCard : UserControl +{ + public DaemonClient? Client { get; set; } + + private bool _applying; + + // Last-known mic_recording, needed to compose set_mic_mode from the handlers. + private bool _recording; + + public MicCard() + { + InitializeComponent(); + } + + /// Render the mic status + toggles from a daemon Snapshot. + public void Update(Snapshot s) + { + _applying = true; + try + { + _recording = s.MicRecording; + MicStatusText.Text = Localize.Get(s.MicRecording ? "Mic_Recording" : "Mic_Idle"); + MicAutoSwitch.IsOn = s.AutoMode; + MicManualToggle.IsChecked = s.MicRecording && !s.AutoMode; + } + finally + { + _applying = false; + } + } + + private void MicAuto_Toggled(object sender, RoutedEventArgs e) + { + if (_applying) return; + // Keep the current manual/recording state while flipping auto. + Client?.SetMicMode(auto: MicAutoSwitch.IsOn, manual: _recording); + } + + private void MicManual_Click(object sender, RoutedEventArgs e) => + // Manual toggle: turn the hi-res stream on/off, auto off. + Client?.SetMicMode(auto: false, manual: !_recording); +} diff --git a/windows/winui/LibrePods.WinUI/Controls/NoiseControlCard.xaml b/windows/winui/LibrePods.WinUI/Controls/NoiseControlCard.xaml new file mode 100644 index 000000000..075a208f0 --- /dev/null +++ b/windows/winui/LibrePods.WinUI/Controls/NoiseControlCard.xaml @@ -0,0 +1,53 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/windows/winui/LibrePods.WinUI/Controls/NoiseControlCard.xaml.cs b/windows/winui/LibrePods.WinUI/Controls/NoiseControlCard.xaml.cs new file mode 100644 index 000000000..1f67eaf06 --- /dev/null +++ b/windows/winui/LibrePods.WinUI/Controls/NoiseControlCard.xaml.cs @@ -0,0 +1,82 @@ +using System; +using LibrePods.WinUI.Ipc; +using LibrePods.WinUI.Services; +using Microsoft.UI.Xaml; +using Microsoft.UI.Xaml.Controls; +using Microsoft.UI.Xaml.Controls.Primitives; // ToggleButton lives here, not in Controls + +namespace LibrePods.WinUI.Controls; + +/// Noise-control mode as a segmented row of icon buttons (Off / Noise Cancellation +/// / Transparency / Adaptive). anc 1..4 selects a button; 0 (unknown) selects none. +/// "Off" is gated on the daemon's allow_off flag. Single-selection is enforced +/// here (ToggleButtons don't do it for us). +public sealed partial class NoiseControlCard : UserControl +{ + public DaemonClient? Client { get; set; } + + private bool _applying; + private readonly ToggleButton[] _buttons; + + public NoiseControlCard() + { + InitializeComponent(); + + // Index 0..3 == anc value 1..4. + _buttons = new[] { AncOffBtn, AncNcBtn, AncTransBtn, AncAdaptiveBtn }; + + // Tooltips reuse the existing localized mode names. + ToolTipService.SetToolTip(AncOffBtn, Localize.Get("Anc_Off")); + ToolTipService.SetToolTip(AncNcBtn, Localize.Get("Anc_NoiseCancellation")); + ToolTipService.SetToolTip(AncTransBtn, Localize.Get("Anc_Transparency")); + ToolTipService.SetToolTip(AncAdaptiveBtn, Localize.Get("Anc_Adaptive")); + } + + /// Render the selected mode + "Off" availability from a daemon Snapshot. + public void Update(Snapshot s) + { + _applying = true; + try + { + AncOffBtn.IsEnabled = s.AllowOff; + + int selected = s.Anc is >= 1 and <= 4 ? s.Anc : 0; // 0 = none + for (int i = 0; i < _buttons.Length; i++) + _buttons[i].IsChecked = (i + 1) == selected; + + AncModeLabel.Text = selected switch + { + 1 => Localize.Get("Anc_Off"), + 2 => Localize.Get("Anc_NoiseCancellation"), + 3 => Localize.Get("Anc_Transparency"), + 4 => Localize.Get("Anc_Adaptive"), + _ => "—", + }; + } + finally + { + _applying = false; + } + } + + private void Anc_Click(object sender, RoutedEventArgs e) + { + if (_applying) return; + if (sender is not ToggleButton btn) return; + + // Radio behaviour: this button wins, the rest clear. Re-clicking the active + // button would otherwise uncheck it — force it back on (there is no "unset"). + byte anc = Convert.ToByte((string)btn.Tag); + _applying = true; + try + { + foreach (var b in _buttons) b.IsChecked = b == btn; + } + finally + { + _applying = false; + } + + Client?.SetAnc(anc); // 1..4 + } +} diff --git a/windows/winui/LibrePods.WinUI/Controls/VolumeCard.xaml b/windows/winui/LibrePods.WinUI/Controls/VolumeCard.xaml new file mode 100644 index 000000000..673f98fe5 --- /dev/null +++ b/windows/winui/LibrePods.WinUI/Controls/VolumeCard.xaml @@ -0,0 +1,34 @@ + + + + + + + + + + + + + + + + + + + diff --git a/windows/winui/LibrePods.WinUI/Controls/VolumeCard.xaml.cs b/windows/winui/LibrePods.WinUI/Controls/VolumeCard.xaml.cs new file mode 100644 index 000000000..ec9d86c4f --- /dev/null +++ b/windows/winui/LibrePods.WinUI/Controls/VolumeCard.xaml.cs @@ -0,0 +1,53 @@ +using LibrePods.WinUI.Ipc; +using LibrePods.WinUI.Services; +using Microsoft.UI.Xaml; +using Microsoft.UI.Xaml.Controls; +using Microsoft.UI.Xaml.Controls.Primitives; + +namespace LibrePods.WinUI.Controls; + +/// Volume slider + mute. The slider is bidirectional, so pushing a snapshot value +/// into it must not echo back to the daemon as a SetVolume — two guards prevent the +/// feedback loop (assigning Slider.Value fires ValueChanged synchronously). +public sealed partial class VolumeCard : UserControl +{ + public DaemonClient? Client { get; set; } + + // Set while a snapshot is being applied to any control in this card. + private bool _applying; + + // Extra guard specifically for the slider: assigning Slider.Value fires + // ValueChanged synchronously and must never be echoed back as a SetVolume. + private bool _suppressVolume; + + public VolumeCard() + { + InitializeComponent(); + } + + /// Render the slider / mute state from a daemon Snapshot. + public void Update(Snapshot s) + { + _applying = true; + try + { + _suppressVolume = true; + VolumeSlider.Value = s.Volume; + _suppressVolume = false; + VolumeText.Text = s.Muted ? Localize.Get("Volume_Muted") : $"{s.Volume}%"; + MuteToggle.IsChecked = s.Muted; + } + finally + { + _applying = false; + } + } + + private void Volume_ValueChanged(object sender, RangeBaseValueChangedEventArgs e) + { + if (_applying || _suppressVolume) return; + Client?.SetVolume((byte)e.NewValue); + } + + private void Mute_Click(object sender, RoutedEventArgs e) => Client?.ToggleMute(); +} diff --git a/windows/winui/LibrePods.WinUI/Ipc/DaemonClient.cs b/windows/winui/LibrePods.WinUI/Ipc/DaemonClient.cs new file mode 100644 index 000000000..cf9fdaec3 --- /dev/null +++ b/windows/winui/LibrePods.WinUI/Ipc/DaemonClient.cs @@ -0,0 +1,293 @@ +using System.Diagnostics; +using System.IO; +using System.IO.Pipes; +using System.Text; +using System.Threading.Channels; + +namespace LibrePods.WinUI.Ipc; + +/// IPC client to `librepodsd` over the two one-directional named pipes: +/// * read State/Overlay/ConnectPrompt events from "LibrePods-events" (In) +/// * write commands to "LibrePods-cmds" (Out) +/// +/// A single duplex pipe deadlocks a Windows sync handle, so the daemon keeps the +/// directions on separate pipes; we mirror that. Both directions run on their own +/// background task and reconnect (spawning the daemon if it isn't running), so a +/// stalled pipe never freezes the UI thread. Events are raised on a background +/// thread — subscribers must marshal to the UI via DispatcherQueue. +public sealed class DaemonClient : IDisposable +{ + private const string EventsPipe = "LibrePods-events"; + private const string CommandsPipe = "LibrePods-cmds"; + private const string DaemonExe = "librepodsd.exe"; + + // Raised on a background thread. + public event Action? SnapshotReceived; + public event Action? OverlayReceived; + public event Action? ConnectPromptReceived; + public event Action? ConnectionChanged; // true = events pipe connected + + private readonly Channel _outgoing = + Channel.CreateUnbounded(new UnboundedChannelOptions + { + SingleReader = true, + SingleWriter = false, + }); + + private readonly CancellationTokenSource _cts = new(); + private bool _started; + + /// Start the two async pipe loops. They are launched directly (no Task.Run): + /// each hits its first `await` at ConnectAsync almost immediately and then + /// runs entirely on the thread pool via ConfigureAwait(false), so nothing + /// blocks the caller or the UI thread. Both loops swallow their own + /// exceptions, so the discarded tasks never fault unobserved. + public void Start() + { + if (_started) return; + _started = true; + _ = ReadLoopAsync(_cts.Token); + _ = WriteLoopAsync(_cts.Token); + } + + // ---- Command helpers --------------------------------------------------- + + public void Send(object command) + { + var bytes = Encoding.UTF8.GetBytes(Wire.ToLine(command)); + _outgoing.Writer.TryWrite(bytes); + } + + public void SendHello() => Send(new HelloCmd()); + public void RequestState() => Send(new GetStateCmd()); + public void SetAnc(byte mode) => Send(new SetAncCmd { Mode = mode }); + public void SetMicMode(bool auto, bool manual) => Send(new SetMicModeCmd { Auto = auto, Manual = manual }); + public void SetFeature(byte feature, bool on) => Send(new SetFeatureCmd { Feature = feature, On = on }); + public void SetControl(byte id, byte value) => Send(new SetControlCmd { Id = id, Value = value }); + public void SetHearingAid(SetHearingAidCmd cmd) => Send(cmd); + public void StepVolume(int delta) => Send(new StepVolumeCmd { Delta = delta }); + public void SetVolume(byte percent) => Send(new SetVolumeCmd { Percent = percent }); + public void ToggleMute() => Send(new ToggleMuteCmd()); + public void SetHeartRate(bool on) => Send(new SetHeartRateCmd { On = on }); + public void Connect() => Send(new ConnectCmd()); + public void Disconnect() => Send(new DisconnectCmd()); + public void RepairConnection() => Send(new RepairConnectionCmd()); + public void SetName(string name) => Send(new SetNameCmd { Name = name }); + public void Shutdown() => Send(new ShutdownCmd()); + + // ---- Reader ------------------------------------------------------------ + + private async Task ReadLoopAsync(CancellationToken ct) + { + while (!ct.IsCancellationRequested) + { + NamedPipeClientStream? pipe = null; + try + { + pipe = new NamedPipeClientStream(".", EventsPipe, PipeDirection.In, + PipeOptions.Asynchronous); + try + { + await pipe.ConnectAsync(500, ct).ConfigureAwait(false); + } + catch (TimeoutException) + { + // Daemon not up yet — launch it (it's a sibling exe) and retry. + TrySpawnDaemon(); + await Task.Delay(500, ct).ConfigureAwait(false); + continue; + } + + ConnectionChanged?.Invoke(true); + + // On (re)connect, announce ourselves and pull a fresh snapshot. + SendHello(); + RequestState(); + + using var reader = new StreamReader(pipe, Encoding.UTF8, + detectEncodingFromByteOrderMarks: false); + string? line; + while ((line = await reader.ReadLineAsync(ct).ConfigureAwait(false)) is not null) + { + Dispatch(Wire.ParseEvent(line)); + } + } + catch (OperationCanceledException) + { + break; + } + catch (IOException) + { + // Pipe closed / daemon exited — fall through to reconnect. + } + catch (Exception) + { + // Never let the loop die; back off and retry. + } + finally + { + pipe?.Dispose(); + ConnectionChanged?.Invoke(false); + } + + try { await Task.Delay(500, ct).ConfigureAwait(false); } + catch (OperationCanceledException) { break; } + } + } + + private void Dispatch(DaemonEvent? ev) + { + switch (ev) + { + case DaemonEvent.State s: + SnapshotReceived?.Invoke(s.Snapshot); + break; + case DaemonEvent.Overlay o: + OverlayReceived?.Invoke(o.Title, LibrePods.WinUI.Services.OverlayText.Resolve(o.Body)); + break; + case DaemonEvent.ConnectPrompt p: + ConnectPromptReceived?.Invoke(p.Name); + break; + } + } + + // ---- Writer ------------------------------------------------------------ + + private async Task WriteLoopAsync(CancellationToken ct) + { + while (!ct.IsCancellationRequested) + { + NamedPipeClientStream? pipe = null; + try + { + pipe = new NamedPipeClientStream(".", CommandsPipe, PipeDirection.Out, + PipeOptions.Asynchronous); + try + { + await pipe.ConnectAsync(500, ct).ConfigureAwait(false); + } + catch (TimeoutException) + { + // The reader loop is responsible for spawning the daemon. + await Task.Delay(500, ct).ConfigureAwait(false); + continue; + } + + // Drain the outgoing queue until the pipe breaks. + while (!ct.IsCancellationRequested) + { + var msg = await _outgoing.Reader.ReadAsync(ct).ConfigureAwait(false); + await pipe.WriteAsync(msg, ct).ConfigureAwait(false); + await pipe.FlushAsync(ct).ConfigureAwait(false); + } + } + catch (OperationCanceledException) + { + break; + } + catch (IOException) + { + // Pipe broke — reconnect. (The in-flight message is lost, which + // matches the tray's best-effort behaviour.) + } + catch (Exception) + { + } + finally + { + pipe?.Dispose(); + } + + try { await Task.Delay(500, ct).ConfigureAwait(false); } + catch (OperationCanceledException) { break; } + } + } + + // ---- Daemon launch ----------------------------------------------------- + + /// If a `librepodsd` is already running when this app starts (e.g. an orphan + /// from a crashed or force-killed session), ask it to exit GRACEFULLY over the + /// commands pipe and wait for it to go — so this app owns a single clean + /// instance and the freshly-spawned daemon can open the driver. The daemon + /// releases the exclusive AAP driver handle on Shutdown; we deliberately do + /// NOT hard-kill a stuck one — a kill leaks that handle and sticks the devnode + /// in Code 38 (CM_PROB_DRIVER_FAILED_PRIOR_UNLOAD), i.e. worse than leaving it + /// for the single-instance mutex + a reboot to resolve. Best-effort and fully + /// guarded: any failure just falls through to the normal connect/spawn path. + public static void ShutdownExistingDaemon() + { + Process[] existing; + try { existing = Process.GetProcessesByName("librepodsd"); } + catch { return; } + if (existing.Length == 0) return; + + try + { + using var pipe = new NamedPipeClientStream(".", CommandsPipe, + PipeDirection.Out, PipeOptions.None); + pipe.Connect(500); + // Byte-identical to a normal command (Wire.ToLine carries the newline). + var bytes = Encoding.UTF8.GetBytes(Wire.ToLine(new ShutdownCmd())); + pipe.Write(bytes, 0, bytes.Length); + pipe.Flush(); + } + catch + { + // Couldn't reach it (stuck daemon) — leave it be; never hard-kill. + foreach (var p in existing) p.Dispose(); + return; + } + + // Let it actually exit (releasing the driver) before we spawn a fresh one. + foreach (var p in existing) + { + try { p.WaitForExit(2000); } catch { } + p.Dispose(); + } + } + + private static void TrySpawnDaemon() + { + try + { + var path = FindDaemon(); + if (path is null) return; + Process.Start(new ProcessStartInfo + { + FileName = path, + UseShellExecute = false, + CreateNoWindow = true, + WorkingDirectory = Path.GetDirectoryName(path)!, + }); + } + catch + { + // Best effort — if we can't spawn it, keep retrying the connect. + } + } + + /// Locate librepodsd.exe: next to us (the deployed layout has every exe in the + /// same folder), else the standard install dir %LOCALAPPDATA%\LibrePods — so a + /// VS debug run (from bin\...) still finds and launches the installed daemon + /// without needing the Rust tray to be up first. + private static string? FindDaemon() + { + string[] candidates = + { + Path.Combine(AppContext.BaseDirectory, DaemonExe), + Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), + "LibrePods", DaemonExe), + }; + foreach (var c in candidates) + if (File.Exists(c)) return c; + return null; + } + + public void Dispose() + { + try { _cts.Cancel(); } catch { } + _outgoing.Writer.TryComplete(); + _cts.Dispose(); + } +} diff --git a/windows/winui/LibrePods.WinUI/Ipc/Messages.cs b/windows/winui/LibrePods.WinUI/Ipc/Messages.cs new file mode 100644 index 000000000..f0a4eac35 --- /dev/null +++ b/windows/winui/LibrePods.WinUI/Ipc/Messages.cs @@ -0,0 +1,243 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace LibrePods.WinUI.Ipc; + +// The IPC contract mirrors windows/ipc/src/lib.rs (serde, snake_case, tagged by +// `event` / `cmd`). Every field is spelled with an explicit [JsonPropertyName] so +// the wire names never depend on a naming policy. + +/// Battery levels (percent), each optional — a packet may carry only some. +public sealed class Battery +{ + [JsonPropertyName("left")] public byte? Left { get; set; } + [JsonPropertyName("right")] public byte? Right { get; set; } + [JsonPropertyName("case")] public byte? Case { get; set; } + [JsonPropertyName("headphone")] public byte? Headphone { get; set; } + + // Per-component charging flag (mirrors the daemon's ipc::Battery). + [JsonPropertyName("left_charging")] public bool LeftCharging { get; set; } + [JsonPropertyName("right_charging")] public bool RightCharging { get; set; } + [JsonPropertyName("case_charging")] public bool CaseCharging { get; set; } + [JsonPropertyName("headphone_charging")] public bool HeadphoneCharging { get; set; } +} + +/// The daemon's authoritative state, pushed on connect and on every change. +public sealed class Snapshot +{ + [JsonPropertyName("connected")] public bool Connected { get; set; } + [JsonPropertyName("dev_name")] public string DevName { get; set; } = ""; + [JsonPropertyName("battery")] public Battery Battery { get; set; } = new(); + + /// 0 = unknown, 1 = Off, 2 = Noise Cancellation, 3 = Transparency, 4 = Adaptive. + [JsonPropertyName("anc")] public byte Anc { get; set; } + + [JsonPropertyName("mic_recording")] public bool MicRecording { get; set; } + [JsonPropertyName("auto_mode")] public bool AutoMode { get; set; } + [JsonPropertyName("conversational_awareness")] public bool ConversationalAwareness { get; set; } + [JsonPropertyName("adaptive_volume")] public bool AdaptiveVolume { get; set; } + [JsonPropertyName("allow_off")] public bool AllowOff { get; set; } + [JsonPropertyName("volume")] public byte Volume { get; set; } + [JsonPropertyName("muted")] public bool Muted { get; set; } + [JsonPropertyName("heart_rate")] public ushort? HeartRate { get; set; } + + // Device metadata from the 0x1D packet (empty until it arrives). Serial is + // sensitive — the Settings page keeps these hidden behind a reveal toggle. + [JsonPropertyName("model")] public string Model { get; set; } = ""; + [JsonPropertyName("firmware")] public string Firmware { get; set; } = ""; + [JsonPropertyName("serial")] public string Serial { get; set; } = ""; +} + +/// AAP control-command feature ids (the `id` byte of a 0x09 control command). +public static class Feature +{ + public const byte AdaptiveVolume = 0x26; // 38 + public const byte ConversationalAwareness = 0x28; // 40 + public const byte AllowOff = 0x34; // 52 +} + +/// The raw control id for Adaptive-Audio noise strength (value 0..=100). +/// (Named ControlId to avoid clashing with Microsoft.UI.Xaml.Controls.Control.) +public static class ControlId +{ + public const byte AdaptiveNoiseStrength = 0x2E; // 46 +} + +// --------------------------------------------------------------------------- +// Commands (app → daemon). Each carries its snake_case `cmd` tag literally. +// --------------------------------------------------------------------------- + +public sealed class HelloCmd +{ + [JsonPropertyName("cmd")] public string Cmd => "hello"; + [JsonPropertyName("kind")] public string Kind => "app"; // "tray" | "app" +} + +public sealed class GetStateCmd +{ + [JsonPropertyName("cmd")] public string Cmd => "get_state"; +} + +public sealed class SetAncCmd +{ + [JsonPropertyName("cmd")] public string Cmd => "set_anc"; + [JsonPropertyName("mode")] public byte Mode { get; init; } // 1..=4 +} + +public sealed class SetMicModeCmd +{ + [JsonPropertyName("cmd")] public string Cmd => "set_mic_mode"; + [JsonPropertyName("auto")] public bool Auto { get; init; } + [JsonPropertyName("manual")] public bool Manual { get; init; } +} + +public sealed class SetFeatureCmd +{ + [JsonPropertyName("cmd")] public string Cmd => "set_feature"; + [JsonPropertyName("feature")] public byte Feature { get; init; } + [JsonPropertyName("on")] public bool On { get; init; } +} + +public sealed class SetControlCmd +{ + [JsonPropertyName("cmd")] public string Cmd => "set_control"; + [JsonPropertyName("id")] public byte Id { get; init; } + [JsonPropertyName("value")] public byte Value { get; init; } +} + +public sealed class StepVolumeCmd +{ + [JsonPropertyName("cmd")] public string Cmd => "step_volume"; + [JsonPropertyName("delta")] public int Delta { get; init; } +} + +public sealed class SetVolumeCmd +{ + [JsonPropertyName("cmd")] public string Cmd => "set_volume"; + [JsonPropertyName("percent")] public byte Percent { get; init; } +} + +public sealed class ToggleMuteCmd +{ + [JsonPropertyName("cmd")] public string Cmd => "toggle_mute"; +} + +public sealed class SetHeartRateCmd +{ + [JsonPropertyName("cmd")] public string Cmd => "set_heart_rate"; + [JsonPropertyName("on")] public bool On { get; init; } +} + +public sealed class SetHearingAidCmd +{ + [JsonPropertyName("cmd")] public string Cmd => "set_hearing_aid"; + [JsonPropertyName("on")] public bool On { get; init; } + [JsonPropertyName("left_eq")] public float[] LeftEq { get; init; } = new float[8]; + [JsonPropertyName("right_eq")] public float[] RightEq { get; init; } = new float[8]; + [JsonPropertyName("amplification")] public float Amplification { get; init; } + [JsonPropertyName("balance")] public float Balance { get; init; } + [JsonPropertyName("tone")] public float Tone { get; init; } + [JsonPropertyName("conversation_boost")] public bool ConversationBoost { get; init; } + [JsonPropertyName("ambient_noise_reduction")] public float AmbientNoiseReduction { get; init; } + [JsonPropertyName("own_voice")] public float OwnVoice { get; init; } +} + +public sealed class ConnectCmd +{ + [JsonPropertyName("cmd")] public string Cmd => "connect"; +} + +public sealed class DisconnectCmd +{ + [JsonPropertyName("cmd")] public string Cmd => "disconnect"; +} + +public sealed class RepairConnectionCmd +{ + [JsonPropertyName("cmd")] public string Cmd => "repair_connection"; +} + +public sealed class SetNameCmd +{ + [JsonPropertyName("cmd")] public string Cmd => "set_name"; + [JsonPropertyName("name")] public string Name { get; init; } = ""; +} + +public sealed class ShutdownCmd +{ + [JsonPropertyName("cmd")] public string Cmd => "shutdown"; +} + +// --------------------------------------------------------------------------- +// Event parsing (daemon → app). Dispatched on the `event` tag. +// --------------------------------------------------------------------------- + +public abstract record DaemonEvent +{ + /// Full state, pushed on connect and whenever it changes. + public sealed record State(Snapshot Snapshot) : DaemonEvent; + + /// A transient notification to render (toast / InfoBar / balloon). + public sealed record Overlay(string Title, string Body) : DaemonEvent; + + /// Device nearby (BLE) but not connected — show a clickable "Connect?" card. + public sealed record ConnectPrompt(string Name) : DaemonEvent; +} + +public static class Wire +{ + public static readonly JsonSerializerOptions Json = new() + { + DefaultIgnoreCondition = JsonIgnoreCondition.Never, + }; + + /// Serialize a command as one NDJSON line (trailing '\n'). + public static string ToLine(object command) + => JsonSerializer.Serialize(command, command.GetType(), Json) + "\n"; + + /// Parse one NDJSON line into a DaemonEvent, or null if unrecognized. + public static DaemonEvent? ParseEvent(string line) + { + line = line.Trim(); + if (line.Length == 0) return null; + + try + { + using var doc = JsonDocument.Parse(line); + var root = doc.RootElement; + if (!root.TryGetProperty("event", out var tagProp)) return null; + var tag = tagProp.GetString(); + + switch (tag) + { + case "state": + // The snapshot is flattened alongside the tag (serde + // `Event::State(Snapshot)` with `#[serde(tag = "event")]` + // inlines the struct's fields), so deserialize the whole + // object into a Snapshot; the extra "event" key is ignored. + var snap = root.Deserialize(Json) ?? new Snapshot(); + return new DaemonEvent.State(snap); + + case "overlay": + return new DaemonEvent.Overlay( + GetStr(root, "title"), + GetStr(root, "body")); + + case "connect_prompt": + return new DaemonEvent.ConnectPrompt(GetStr(root, "name")); + + default: + return null; + } + } + catch (JsonException) + { + return null; + } + } + + private static string GetStr(JsonElement el, string name) + => el.TryGetProperty(name, out var p) && p.ValueKind == JsonValueKind.String + ? p.GetString() ?? "" + : ""; +} diff --git a/windows/winui/LibrePods.WinUI/LibrePods.WinUI.csproj b/windows/winui/LibrePods.WinUI/LibrePods.WinUI.csproj new file mode 100644 index 000000000..d9073b50b --- /dev/null +++ b/windows/winui/LibrePods.WinUI/LibrePods.WinUI.csproj @@ -0,0 +1,78 @@ + + + + WinExe + net10.0-windows10.0.19041.0 + 10.0.17763.0 + LibrePods.WinUI + + + librepods-winui + + x64 + win-x64 + + true + enable + enable + latest + + + None + true + true + false + + app.manifest + Assets\app.ico + + + en-US + false + + + + + + + + + + + + + + + + + + + + + + + + + + + PreserveNewest + + + PreserveNewest + + + + PreserveNewest + + + + diff --git a/windows/winui/LibrePods.WinUI/MainWindow.xaml b/windows/winui/LibrePods.WinUI/MainWindow.xaml new file mode 100644 index 000000000..f696c2b4b --- /dev/null +++ b/windows/winui/LibrePods.WinUI/MainWindow.xaml @@ -0,0 +1,70 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/windows/winui/LibrePods.WinUI/MainWindow.xaml.cs b/windows/winui/LibrePods.WinUI/MainWindow.xaml.cs new file mode 100644 index 000000000..95b47693f --- /dev/null +++ b/windows/winui/LibrePods.WinUI/MainWindow.xaml.cs @@ -0,0 +1,224 @@ +using System.IO; +using LibrePods.WinUI.Ipc; +using LibrePods.WinUI.Services; +using Microsoft.UI.Windowing; +using Microsoft.UI.Xaml; +using Microsoft.UI.Xaml.Controls; +using Microsoft.UI.Xaml.Media; +using Microsoft.UI.Xaml.Media.Imaging; +using Windows.Graphics; + +namespace LibrePods.WinUI; + +/// The Fluent shell: a NavigationView that content-swaps between the DevicePage +/// and SettingsPage UserControls. It owns no card logic — it wires the daemon +/// client into the pages, forwards daemon events (marshalled to the UI thread) to +/// the device page, and applies the settings page's theme choice to the window +/// root. Closing the window hides it to the tray rather than exiting. +public sealed partial class MainWindow : Window +{ + private readonly DaemonClient _client; + + public MainWindow(DaemonClient client) + { + _client = client; + InitializeComponent(); + + // Native Fluent look: Mica backdrop, theme-aware via system resources. + SystemBackdrop = new MicaBackdrop(); + + // Hand the client to the pages: DevicePage fans it out to its cards; the + // SettingsPage uses it for "Refresh state". + DevicePageView.Client = _client; + SettingsPageView.Client = _client; + + // The settings theme picker owns the choice; the window root owns the theme. + SettingsPageView.ThemeChanged += theme => + { + if (RootGrid is not null) RootGrid.RequestedTheme = theme; + }; + // Restore the theme saved on a previous run (it wasn't persisted before). + if (RootGrid is not null) RootGrid.RequestedTheme = AppSettings.Theme; + SettingsPageView.InitThemeSelector(AppSettings.ThemeIndex); + + // The experimental heart-rate opt-in lives in Settings; the DevicePage owns + // the card. Refresh its visibility live when the toggle flips. + SettingsPageView.ExperimentalVisibilityChanged += () => DevicePageView.RefreshExperimentalVisibility(); + SettingsPageView.InitExperimentalSetting(); + + // Wider default so the responsive 2-column device layout shows at launch. + AppWindow.Resize(new SizeInt32(1000, 800)); + // Enforce a minimum size — the layout breaks if the window is dragged + // absurdly narrow (no app is usable at ~100px). Clamp on resize. + AppWindow.Changed += (sender, e) => + { + if (!e.DidSizeChange) return; + const int minW = 420, minH = 540; + var sz = sender.Size; + if (sz.Width < minW || sz.Height < minH) + sender.Resize(new SizeInt32(Math.Max(sz.Width, minW), Math.Max(sz.Height, minH))); + }; + TrySetWindowIcon(); + + // Start on the device page. + NavView.SelectedItem = DeviceNavItem; + + // The built-in NavigationView Settings item is OS-localized; re-label it + // from our Loc service so it follows the in-app language too (live). The + // SettingsItem only exists once the control template is applied (Loaded). + NavView.Loaded += (_, _) => LocalizeSettingsNavItem(); + Services.Loc.Instance.PropertyChanged += (_, _) => + DispatcherQueue.TryEnqueue(() => + { + LocalizeSettingsNavItem(); + if (_lastSnapshot is { } s) RenderSnapshot(s); + }); + + // Close hides to tray (the app keeps running as an IPC client). + AppWindow.Closing += OnClosing; + + // Daemon → UI. These fire on a background thread; marshal to the UI queue. + _client.SnapshotReceived += OnSnapshot; + _client.OverlayReceived += OnOverlay; + _client.ConnectPromptReceived += OnConnectPrompt; + _client.ConnectionChanged += OnConnectionChanged; + } + + private void TrySetWindowIcon() + { + try + { + var ico = Path.Combine(AppContext.BaseDirectory, "Assets", "app.ico"); + if (File.Exists(ico)) AppWindow.SetIcon(ico); + } + catch { } + } + + /// Show + focus the window (from the tray icon / "Open"). + public void ShowFromTray() + { + AppWindow.Show(); + Activate(); + } + + private void OnClosing(AppWindow sender, AppWindowClosingEventArgs args) + { + args.Cancel = true; // don't destroy the window… + AppWindow.Hide(); // …just hide it back to the tray. + } + + // ---- Navigation -------------------------------------------------------- + + private void Nav_SelectionChanged(NavigationView sender, NavigationViewSelectionChangedEventArgs args) + { + var settings = args.IsSettingsSelected; + DevicePageView.Visibility = settings ? Visibility.Collapsed : Visibility.Visible; + SettingsPageView.Visibility = settings ? Visibility.Visible : Visibility.Collapsed; + } + + /// Re-label the built-in NavigationView Settings item from the Loc service. + private void LocalizeSettingsNavItem() + { + if (NavView.SettingsItem is NavigationViewItem item) + item.Content = Localize.Get("SettingsTitle.Text"); + } + + // ---- Daemon events (marshalled to the UI thread) ----------------------- + + private void OnSnapshot(Snapshot s) => + DispatcherQueue.TryEnqueue(() => + { + _lastSnapshot = s; + RenderSnapshot(s); + }); + + // Re-render the last snapshot on a language change so code-picked strings (the + // header status, mic state, ANC mode name…) — which are only set when a snapshot + // arrives — refresh into the new language instead of lingering in the old one. + private void RenderSnapshot(Snapshot s) + { + _connected = s.Connected; + + // The device NavigationViewItem mirrors the header: name, a model-aware + // icon, and a compact battery summary (visible in the expanded pane). + NavDeviceName.Text = string.IsNullOrWhiteSpace(s.DevName) ? "LibrePods" : s.DevName; + + var family = DeviceArt.Family(s.Model); + if (family != _navArtFamily) + { + _navArtFamily = family; + try { NavDeviceIcon.Source = new BitmapImage(new Uri(DeviceArt.MainImage(s.Model))); } + catch { } + } + + // Lowest earbud reading (or the headphone band for Max) — one number is + // enough at nav width; the full L/R/Case breakdown is on the card. + byte? summary = LowestReading(s.Battery.Left, s.Battery.Right, s.Battery.Headphone); + if (summary is byte v) + { + NavDeviceBattery.Text = $"{v}%"; + NavDeviceBattery.Visibility = Visibility.Visible; + } + else + { + NavDeviceBattery.Visibility = Visibility.Collapsed; + } + + // A live connection dismisses any stale "Connect?" prompt (in-app InfoBar + // + the centred popup window). + if (s.Connected) + { + DevicePageView.DismissConnectPrompt(); + try { _connectPrompt?.Close(); } catch { } + } + + DevicePageView.Update(s); + SettingsPageView.UpdateDeviceInfo(s); + } + + /// The lowest valid (<=100) battery reading among the given components, or null + /// when none report. The 0xFF "absent" sentinel (>100) is ignored. + private static byte? LowestReading(params byte?[] values) + { + byte? lowest = null; + foreach (var value in values) + if (value is byte v and <= 100 && (lowest is null || v < lowest)) + lowest = v; + return lowest; + } + + private void OnOverlay(string title, string body) => + DispatcherQueue.TryEnqueue(() => DevicePageView.ShowOverlay(title, body)); + + private Popup.ConnectPromptWindow? _connectPrompt; + private bool _connected; + private string? _navArtFamily; + private Snapshot? _lastSnapshot; + + private void OnConnectPrompt(string name) => + DispatcherQueue.TryEnqueue(() => + { + // Already connected — a proximity "Connect?" prompt is stale (the daemon + // can still emit it from a BLE advertisement while the AACP session is + // live). Suppress both the popup and the in-app InfoBar. + if (_connected) return; + + // The iOS-style centred "Connect?" popup — shows even when the app is + // hidden to the tray. Also mirror it in the in-app InfoBar. + try + { + _connectPrompt?.Close(); + _connectPrompt = new Popup.ConnectPromptWindow(name, () => _client.Connect()); + _connectPrompt.Closed += (_, _) => _connectPrompt = null; + _connectPrompt.ShowPrompt(); + } + catch { } + DevicePageView.ShowConnectPrompt(name); + }); + + private void OnConnectionChanged(bool connected) => + DispatcherQueue.TryEnqueue(() => + { + if (!connected) DevicePageView.ShowWaitingForDaemon(); + }); +} diff --git a/windows/winui/LibrePods.WinUI/Pages/DevicePage.xaml b/windows/winui/LibrePods.WinUI/Pages/DevicePage.xaml new file mode 100644 index 000000000..6c8fa1686 --- /dev/null +++ b/windows/winui/LibrePods.WinUI/Pages/DevicePage.xaml @@ -0,0 +1,85 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/windows/winui/LibrePods.WinUI/Pages/DevicePage.xaml.cs b/windows/winui/LibrePods.WinUI/Pages/DevicePage.xaml.cs new file mode 100644 index 000000000..2c07b307d --- /dev/null +++ b/windows/winui/LibrePods.WinUI/Pages/DevicePage.xaml.cs @@ -0,0 +1,128 @@ +using LibrePods.WinUI.Ipc; +using LibrePods.WinUI.Services; +using Microsoft.UI.Xaml; +using Microsoft.UI.Xaml.Controls; + +namespace LibrePods.WinUI.Pages; + +/// The device view: header + transient InfoBars + the responsive 2-column card +/// grid. It owns no daemon logic beyond fanning a Snapshot out to each card and +/// rendering the overlay / connect-prompt InfoBars. +public sealed partial class DevicePage : UserControl +{ + private DaemonClient? _client; + + /// The daemon client, propagated to the header + every command-sending card. + public DaemonClient? Client + { + get => _client; + set + { + _client = value; + Header.Client = value; + VolumeCard.Client = value; + AdaptiveNoiseCard.Client = value; + NoiseControlCard.Client = value; + FeaturesCard.Client = value; + MicCard.Client = value; + HeartRateCard.Client = value; + HearingAidCard.Client = value; + } + } + + public DevicePage() + { + InitializeComponent(); + // Experimental cards (heart-rate + hearing-aid) are hidden by default. Show + // them only when the user opts in via Settings ▸ Experimental. + ApplyExperimentalVisibility(); + + // Daemon overlays are one-shot strings resolved when they arrive, so one + // shown before a language change stays in the old language. Dismiss it on a + // culture switch (the next overlay renders in the new language). + Services.Loc.Instance.PropertyChanged += (_, _) => + DispatcherQueue.TryEnqueue(() => OverlayBar.IsOpen = false); + } + + /// Re-read the experimental opt-in (call after the Settings toggle changes so the + /// cards appear/disappear without an app restart). + public void RefreshExperimentalVisibility() => ApplyExperimentalVisibility(); + + /// Show/hide every experimental card from the single experimental opt-in. + private void ApplyExperimentalVisibility() + { + var vis = AppSettings.EnableExperimental ? Visibility.Visible : Visibility.Collapsed; + HeartRateCard.Visibility = vis; + HearingAidCard.Visibility = vis; + } + + /// Fan a fresh Snapshot out to the header and every card that renders state. + /// (AdaptiveNoiseCard is stateless — send-only — so it is skipped.) + public void Update(Snapshot s) + { + Header.Update(s); + BatteryCard.Update(s); + VolumeCard.Update(s); + NoiseControlCard.Update(s); + FeaturesCard.Update(s); + MicCard.Update(s); + HeartRateCard.Update(s); + + // Disable every interactive control while there's no proper connection — a + // disconnected/desynced link can't apply commands, so the controls shouldn't + // look actionable (Battery stays as an informational read-out). Use "Reparar + // ligação" in the header to force a clean reconnect. + bool on = s.Connected; + VolumeCard.IsEnabled = on; + AdaptiveNoiseCard.IsEnabled = on; + NoiseControlCard.IsEnabled = on; + FeaturesCard.IsEnabled = on; + MicCard.IsEnabled = on; + HeartRateCard.IsEnabled = on; + HearingAidCard.IsEnabled = on; + } + + /// Show a transient daemon overlay in the InfoBar. + public void ShowOverlay(string title, string body) + { + OverlayBar.Title = title; + OverlayBar.Message = body; + OverlayBar.Severity = InfoBarSeverity.Informational; + OverlayBar.IsOpen = true; + } + + /// Show the "nearby — connect?" prompt with a Connect action. + public void ShowConnectPrompt(string name) + { + ConnectPromptBar.Title = Localize.Get("ConnectPrompt_Title", name); + ConnectPromptBar.Message = Localize.Get("ConnectPrompt_Message"); + var btn = new Button { Content = Localize.Get("Action_Connect") }; + btn.Click += (_, _) => + { + _client?.Connect(); + ConnectPromptBar.IsOpen = false; + }; + ConnectPromptBar.ActionButton = btn; + ConnectPromptBar.IsOpen = true; + } + + /// Hide the "nearby — connect?" prompt (e.g. once the device is connected). + public void DismissConnectPrompt() => ConnectPromptBar.IsOpen = false; + + /// The events pipe dropped — surface it in the header. + public void ShowWaitingForDaemon() => Header.ShowWaitingForDaemon(); + + /// Responsive layout: two side-by-side columns only when the page is wide + /// enough (measured on the page's own content width, since the nav pane is + /// separate); otherwise the right column stacks below the left (one column). + private void OnSizeChanged(object sender, SizeChangedEventArgs e) + { + bool wide = e.NewSize.Width >= 720; + Grid.SetRow(RightColumn, wide ? 0 : 1); + Grid.SetColumn(RightColumn, wide ? 1 : 0); + Col1.Width = wide ? new GridLength(1, GridUnitType.Star) : new GridLength(0); + // In one-column mode the collapsed Col1 still reserves ColumnSpacing (16px), + // leaving a phantom gap on the right — drop the spacing when single-column. + CardsGrid.ColumnSpacing = wide ? 16 : 0; + } +} diff --git a/windows/winui/LibrePods.WinUI/Pages/SettingsPage.xaml b/windows/winui/LibrePods.WinUI/Pages/SettingsPage.xaml new file mode 100644 index 000000000..7156c6ccd --- /dev/null +++ b/windows/winui/LibrePods.WinUI/Pages/SettingsPage.xaml @@ -0,0 +1,180 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/windows/winui/LibrePods.WinUI/Pages/SettingsPage.xaml.cs b/windows/winui/LibrePods.WinUI/Pages/SettingsPage.xaml.cs new file mode 100644 index 000000000..616415973 --- /dev/null +++ b/windows/winui/LibrePods.WinUI/Pages/SettingsPage.xaml.cs @@ -0,0 +1,133 @@ +using System; +using LibrePods.WinUI.Ipc; +using LibrePods.WinUI.Services; +using Microsoft.UI.Xaml; +using Microsoft.UI.Xaml.Controls; + +namespace LibrePods.WinUI.Pages; + +/// The settings view: theme picker, default front-end switch, refresh, and About. +/// Theme changes are surfaced to the host (which owns the themed root) via the +/// ThemeChanged event; everything else is self-contained. +public sealed partial class SettingsPage : UserControl +{ + /// The daemon client, used by "Refresh state". + public DaemonClient? Client { get; set; } + + /// Raised when the user picks a theme. The host applies it to the window root. + public event Action? ThemeChanged; + + /// Raised when the experimental opt-in changes, so the host can refresh the + /// DevicePage experimental-card visibility live. + public event Action? ExperimentalVisibilityChanged; + + private bool _applyingLang; + private bool _applyingStartup; + + public SettingsPage() + { + InitializeComponent(); + + // Reflect the current "run at Windows login" state without firing the toggle. + _applyingStartup = true; + StartupSetting.IsOn = StartupService.IsEnabled(); + _applyingStartup = false; + + // App version in the About card (for bug reports). + try + { + var v = System.Reflection.Assembly.GetExecutingAssembly().GetName().Version; + if (v is not null) VersionText.Text = $"v{v.Major}.{v.Minor}.{v.Build}"; + } + catch { } + + InitLanguageCombo(); + } + + /// Select the persisted UI language on load, without firing the restart hint. + private void InitLanguageCombo() + { + _applyingLang = true; + try + { + var tag = AppSettings.LanguageTag; + foreach (var obj in LanguageCombo.Items) + if (obj is ComboBoxItem item && (string)(item.Tag ?? "") == tag) + { + LanguageCombo.SelectedItem = item; + break; + } + if (LanguageCombo.SelectedItem is null) LanguageCombo.SelectedIndex = 0; // System + } + finally { _applyingLang = false; } + } + + private void Language_SelectionChanged(object sender, SelectionChangedEventArgs e) + { + if (_applyingLang) return; + var tag = (LanguageCombo.SelectedItem as ComboBoxItem)?.Tag as string ?? ""; + AppSettings.SetLanguageTag(tag); + Loc.Instance.SetCulture(tag); // live — every {StaticResource Loc} binding re-fetches + } + + /// Set the theme radio to the persisted choice on startup, without re-firing + /// ThemeChanged (the host applies the saved theme itself). + public void InitThemeSelector(int index) + { + if (index >= 0 && index <= 2) ThemeButtons.SelectedIndex = index; + } + + /// Set the experimental toggle to the persisted value on startup (without + /// re-firing the change event). + public void InitExperimentalSetting() => ExperimentalSetting.IsOn = AppSettings.EnableExperimental; + + private void ExperimentalSetting_Toggled(object sender, RoutedEventArgs e) + { + AppSettings.SetEnableExperimental(ExperimentalSetting.IsOn); // persist across restarts + ExperimentalVisibilityChanged?.Invoke(); + } + + private void StartupSetting_Toggled(object sender, RoutedEventArgs e) + { + if (_applyingStartup) return; + StartupService.SetEnabled(StartupSetting.IsOn); + } + + private void Theme_SelectionChanged(object sender, SelectionChangedEventArgs e) + { + var index = ThemeButtons.SelectedIndex; + AppSettings.SetThemeIndex(index); // persist across restarts + ThemeChanged?.Invoke(index switch + { + 1 => ElementTheme.Light, + 2 => ElementTheme.Dark, + _ => ElementTheme.Default, + }); + } + + /// Fill the (hidden-by-default) device-info card from the 0x1D metadata. + public void UpdateDeviceInfo(Snapshot s) + { + ModelText.Text = FriendlyModel(s.Model); + FirmwareText.Text = string.IsNullOrWhiteSpace(s.Firmware) ? "—" : s.Firmware; + SerialText.Text = string.IsNullOrWhiteSpace(s.Serial) ? "—" : s.Serial; + } + + /// Map a known model number to a friendly name, else show the raw number. + private static string FriendlyModel(string model) => model switch + { + "" => "—", + "A3064" => "AirPods Pro 3 (A3064)", + "A2968" or "A2931" or "A2699" => $"AirPods Pro 2 ({model})", + "A2084" or "A2083" => $"AirPods Pro ({model})", + _ => model, + }; + + // The device info (incl. serial) is covered by a frosted blur; the eye toggles it. + private void RevealInfo_Toggled(object sender, RoutedEventArgs e) + { + if (InfoBlur is not null) + InfoBlur.Visibility = RevealInfo.IsChecked == true ? Visibility.Collapsed : Visibility.Visible; + } + +} diff --git a/windows/winui/LibrePods.WinUI/Popup/ConnectPromptWindow.cs b/windows/winui/LibrePods.WinUI/Popup/ConnectPromptWindow.cs new file mode 100644 index 000000000..63a723f87 --- /dev/null +++ b/windows/winui/LibrePods.WinUI/Popup/ConnectPromptWindow.cs @@ -0,0 +1,195 @@ +using System; +using System.IO; +using System.Runtime.InteropServices; +using LibrePods.WinUI.Services; +using Microsoft.UI.Dispatching; +using Microsoft.UI.Windowing; +using Microsoft.UI.Xaml; +using Microsoft.UI.Xaml.Controls; +using Microsoft.UI.Xaml.Media; +using Microsoft.UI.Xaml.Media.Imaging; +using Windows.Graphics; + +namespace LibrePods.WinUI.Popup; + +/// A centred, borderless, always-on-top card shown when the AirPods are nearby +/// (the case is opened) but not connected — the iOS-style "Connect?" prompt with +/// the device image, name and a Connect button. Unlike the passive connection +/// island, this one is INTERACTIVE, so it is activatable (a no-activate window +/// wouldn't route the button click). It self-closes after a timeout, on Connect, +/// or on dismiss. Every windowing call is guarded so a popup failure never crashes +/// the app — at worst there is simply no prompt. +public sealed class ConnectPromptWindow : Window +{ + private readonly DispatcherQueueTimer _autoClose; + private bool _closed; + + public ConnectPromptWindow(string name, Action onConnect) + { + Title = "LibrePods"; + + var display = string.IsNullOrWhiteSpace(name) ? Localize.Get("Island_DefaultName") : name; + + // ---- Content: a rounded acrylic card built in code (no XAML needed) ---- + var image = new Image + { + Width = 96, + Height = 96, + Stretch = Stretch.Uniform, + HorizontalAlignment = HorizontalAlignment.Center, + }; + try { image.Source = new BitmapImage(new Uri(ImageForName(display))); } catch { } + + var title = new TextBlock + { + Text = display, + FontSize = 17, + FontWeight = Microsoft.UI.Text.FontWeights.SemiBold, + HorizontalAlignment = HorizontalAlignment.Center, + TextAlignment = TextAlignment.Center, + }; + var subtitle = new TextBlock + { + Text = Localize.Get("ConnectPrompt_Nearby"), + FontSize = 12, + HorizontalAlignment = HorizontalAlignment.Center, + }; + if (Application.Current.Resources.TryGetValue("TextFillColorSecondaryBrush", out var sb) && sb is Brush secondary) + subtitle.Foreground = secondary; + + var connect = new Button + { + Content = Localize.Get("ConnectPrompt_Connect"), + HorizontalAlignment = HorizontalAlignment.Stretch, + }; + if (Application.Current.Resources.TryGetValue("AccentButtonStyle", out var st) && st is Style accent) + connect.Style = accent; + connect.Click += (_, _) => { try { onConnect(); } catch { } SafeClose(); }; + + var dismiss = new Button + { + Content = Localize.Get("ConnectPrompt_Dismiss"), + HorizontalAlignment = HorizontalAlignment.Stretch, + }; + dismiss.Click += (_, _) => SafeClose(); + + var panel = new StackPanel { Spacing = 10, Padding = new Thickness(20, 18, 20, 20) }; + panel.Children.Add(title); + panel.Children.Add(subtitle); + panel.Children.Add(image); + panel.Children.Add(connect); + panel.Children.Add(dismiss); + + // Solid, opaque, theme-matched background — an acrylic backdrop on this + // small tool window left a white window edge showing around the card. + var root = new Grid { RequestedTheme = AppSettings.Theme }; + if (Application.Current.Resources.TryGetValue("SolidBackgroundFillColorBaseBrush", out var bg) && bg is Brush bgBrush) + root.Background = bgBrush; + else + root.Background = new SolidColorBrush(Microsoft.UI.Colors.Black); + root.Children.Add(panel); + Content = root; + + try { ConfigurePresenter(); } catch { } + + _autoClose = DispatcherQueue.CreateTimer(); + _autoClose.Interval = TimeSpan.FromSeconds(20); + _autoClose.IsRepeating = false; + _autoClose.Tick += (_, _) => SafeClose(); + + Closed += (_, _) => { _closed = true; try { _autoClose.Stop(); } catch { } }; + + root.Loaded += (_, _) => { try { Prepare(); } catch { } }; + } + + /// Show the prompt (activating it so the button works). + public void ShowPrompt() + { + if (_closed) return; + try + { + AppWindow.Show(); + Activate(); + _autoClose.Start(); + } + catch { SafeClose(); } + } + + private void ConfigurePresenter() + { + AppWindow.IsShownInSwitchers = false; + if (AppWindow.Presenter is OverlappedPresenter p) + { + p.SetBorderAndTitleBar(false, false); + p.IsAlwaysOnTop = true; + p.IsResizable = false; + p.IsMaximizable = false; + p.IsMinimizable = false; + } + } + + /// Size to content and centre on the display's work area, then round the corners. + private void Prepare() + { + double scale = (Content as FrameworkElement)?.XamlRoot?.RasterizationScale ?? 1.0; + if (scale <= 0) scale = 1.0; + + var fe = (FrameworkElement)Content; + fe.Measure(new Windows.Foundation.Size(300, double.PositiveInfinity)); + double wDip = 300, hDip = fe.DesiredSize.Height > 0 ? fe.DesiredSize.Height : 260; + + int w = (int)Math.Ceiling(wDip * scale); + int h = (int)Math.Ceiling(hDip * scale); + try { AppWindow.Resize(new SizeInt32(w, h)); } catch { } + + var area = DisplayArea.GetFromWindowId(AppWindow.Id, DisplayAreaFallback.Nearest); + var work = area.WorkArea; + int x = work.X + (work.Width - w) / 2; + int y = work.Y + work.Height - h - (int)Math.Round(56 * scale); // bottom-centre + try { AppWindow.Move(new PointInt32(x, y)); } catch { } + + try { RoundCorners(); } catch { } + } + + private void SafeClose() + { + if (_closed) return; + try { Close(); } catch { _closed = true; } + } + + // Best-effort map from device name to a bundled AirPods image (else generic). + private static string ImageForName(string devName) + { + const string root = "ms-appx:///Assets/"; + var n = (devName ?? string.Empty).ToLowerInvariant(); + if (n.Contains("pro")) + { + if (n.Contains("3")) return root + "airpods_pro_3_case.png"; + if (n.Contains("1")) return root + "airpods_pro_1_case.png"; + return root + "airpods_pro_2_case.png"; + } + if (n.Contains("4")) return root + "airpods_4_case.png"; + if (n.Contains("3")) return root + "airpods_3_case.png"; + if (n.Contains("2")) return root + "airpods_2_case.png"; + if (n.Contains("1")) return root + "airpods_1_case.png"; + return root + "airpods.png"; + } + + private void RoundCorners() + { + var hwnd = WinRT.Interop.WindowNative.GetWindowHandle(this); + int pref = DWMWCP_ROUND; + DwmSetWindowAttribute(hwnd, DWMWA_WINDOW_CORNER_PREFERENCE, ref pref, sizeof(int)); + // Windows 11 paints a border colour around every window — that's the white + // edge around the card. DWMWA_COLOR_NONE removes it. + int none = unchecked((int)0xFFFFFFFE); // DWMWA_COLOR_NONE + DwmSetWindowAttribute(hwnd, DWMWA_BORDER_COLOR, ref none, sizeof(int)); + } + + private const int DWMWA_WINDOW_CORNER_PREFERENCE = 33; + private const int DWMWA_BORDER_COLOR = 34; + private const int DWMWCP_ROUND = 2; + + [DllImport("dwmapi.dll", SetLastError = true)] + private static extern int DwmSetWindowAttribute(nint hwnd, int attribute, ref int pvAttribute, int cbAttribute); +} diff --git a/windows/winui/LibrePods.WinUI/Popup/IslandView.xaml b/windows/winui/LibrePods.WinUI/Popup/IslandView.xaml new file mode 100644 index 000000000..8869f997e --- /dev/null +++ b/windows/winui/LibrePods.WinUI/Popup/IslandView.xaml @@ -0,0 +1,145 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/windows/winui/LibrePods.WinUI/Popup/IslandView.xaml.cs b/windows/winui/LibrePods.WinUI/Popup/IslandView.xaml.cs new file mode 100644 index 000000000..d63cfc4bb --- /dev/null +++ b/windows/winui/LibrePods.WinUI/Popup/IslandView.xaml.cs @@ -0,0 +1,120 @@ +using LibrePods.WinUI.Ipc; +using LibrePods.WinUI.Services; +using Microsoft.UI.Xaml; +using Microsoft.UI.Xaml.Controls; +using Microsoft.UI.Xaml.Media.Imaging; + +namespace LibrePods.WinUI.Popup; + +/// The visual of the connection "island": AirPods case image on the left, device +/// name + L/R/Case battery on the right. Pure presentation — it has no timers and +/// no window logic (that lives in IslandWindow); it only renders a Snapshot. +public sealed partial class IslandView : UserControl +{ + public IslandView() + { + InitializeComponent(); + + // Kick off the product-render animation once laid out: a one-shot scale-in + // entrance, then a continuous gentle float. Wrapped so an animation failure + // can never break the popup (worst case: a static image). + Loaded += (_, _) => + { + try { Entrance.Begin(); } catch { } + try { FloatLoop.Begin(); } catch { } + }; + } + + /// Populate the card from a daemon Snapshot (name + battery + model image). + public void Apply(Snapshot s) + { + // Connection-card mode: render + battery, no message line / mode glyph. + MessageBody.Visibility = Visibility.Collapsed; + DeviceImage.Visibility = Visibility.Visible; + ModeIcon.Visibility = Visibility.Collapsed; + BatteryRow.Visibility = Visibility.Visible; + + DeviceName.Text = string.IsNullOrWhiteSpace(s.DevName) ? Localize.Get("Island_DefaultName") : s.DevName; + + SetImage(s.Model); + + SetBattery(LeftItem, LeftBar, LeftText, s.Battery.Left, s.Battery.LeftCharging); + SetBattery(RightItem, RightBar, RightText, s.Battery.Right, s.Battery.RightCharging); + SetBattery(CaseItem, CaseBar, CaseText, s.Battery.Case, s.Battery.CaseCharging); + } + + /// Message mode: show a title + body (e.g. an ANC change), hiding the render + /// and battery — so daemon overlays render as the centred island instead of a + /// Windows toast. + public void ApplyMessage(string title, string body, string? model = null) + { + DeviceName.Text = string.IsNullOrWhiteSpace(title) ? Localize.Get("Island_DefaultName") : title; + MessageBody.Text = body ?? ""; + MessageBody.Visibility = Visibility.Visible; + BatteryRow.Visibility = Visibility.Collapsed; + + // A noise-control change shows the mode's glyph — a vector FontIcon so it + // follows the theme (the raster ANC art was fixed-colour and washed out on + // one theme). Everything else shows the device render. + var glyph = ModeGlyph(body); + if (glyph is not null) + { + ModeIcon.Glyph = glyph; + ModeIcon.Visibility = Visibility.Visible; + DeviceImage.Visibility = Visibility.Collapsed; + } + else + { + SetImage(model); + DeviceImage.Visibility = Visibility.Visible; + ModeIcon.Visibility = Visibility.Collapsed; + } + } + + /// The Segoe Fluent glyph for a noise-control mode body (localized match), or + /// null when the message isn't a mode change. Char codes (not literal PUA chars) + /// keep the source clean. Matches the tray menu's mode glyphs. + private static string? ModeGlyph(string body) + { + if (body == Localize.Get("Anc_Off")) return ((char)0xE7E8).ToString(); // power + if (body == Localize.Get("Anc_NoiseCancellation")) return ((char)0xE7F6).ToString(); // headphone + if (body == Localize.Get("Anc_Transparency")) return ((char)0xE890).ToString(); // view + if (body == Localize.Get("Anc_Adaptive")) return ((char)0xE72C).ToString(); // refresh + return null; + } + + /// Set the product render from the model number. When the live model isn't known + /// yet (the 0x1D metadata lags the connect popup), fall back to the last-seen + /// model cached across runs, then to the generic airpods.png. + private void SetImage(string? model) + { + var m = string.IsNullOrEmpty(model) ? AppSettings.LastModel : model; + TrySetSource(DeviceArt.MainImage(m)); + } + + private void TrySetSource(string uri) + { + try { DeviceImage.Source = new BitmapImage(new Uri(uri)); } + catch { /* leave the XAML default (airpods.png) */ } + } + + /// Show a battery component only when it carries a real reading. Values >100 + /// (e.g. the 0xFF "absent" sentinel) collapse the whole item, matching the + /// main window's handling but hiding empty bars on the compact island. + private static void SetBattery(FrameworkElement item, ProgressBar bar, TextBlock text, byte? value, bool charging) + { + if (value is byte v and <= 100) + { + bar.Value = v; + // Append a bolt when charging (status byte 0x01 / 0x05). + text.Text = charging ? $"{v}% ⚡" : $"{v}%"; + item.Visibility = Visibility.Visible; + } + else + { + bar.Value = 0; + text.Text = "—"; + item.Visibility = Visibility.Collapsed; + } + } +} diff --git a/windows/winui/LibrePods.WinUI/Popup/IslandWindow.cs b/windows/winui/LibrePods.WinUI/Popup/IslandWindow.cs new file mode 100644 index 000000000..270251b00 --- /dev/null +++ b/windows/winui/LibrePods.WinUI/Popup/IslandWindow.cs @@ -0,0 +1,362 @@ +using System.Diagnostics; +using System.Runtime.InteropServices; +using LibrePods.WinUI.Ipc; +using LibrePods.WinUI.Services; +using Microsoft.UI.Dispatching; +using Microsoft.UI.Windowing; +using Microsoft.UI.Xaml; +using Microsoft.UI.Xaml.Media; +using Windows.Graphics; + +namespace LibrePods.WinUI.Popup; + +/// A borderless, always-on-top, no-taskbar island that slides down from the +/// top-centre of the work area when the AirPods connect, holds ~4s, then slides +/// back up and closes itself. +/// +/// It hosts an on a DesktopAcrylic backdrop with +/// DWM-rounded corners, so the whole window IS the rounded translucent card — +/// this avoids needing true per-pixel window transparency (which WinUI 3 does not +/// reliably support). The slide is a smooth eased animation of the window's +/// screen position driven by a DispatcherQueueTimer (a transform of the whole +/// card, not per-frame redrawing); the fade rides the same interpolation on the +/// content's Opacity. +/// +/// Every Win32 / windowing call is wrapped so a popup failure can never crash the +/// app — the worst case is simply no island. +public sealed class IslandWindow : Window +{ + // Card geometry (device-independent pixels). Width matches IslandView's Width. + // The Apple-style card is a narrow vertical layout (name / render / battery), + // so it is taller than the old horizontal strip — the real height is measured + // from the view's DesiredSize; this fallback is only used if that measure fails. + private const double DesignWidthDip = 300; + private const double FallbackHeightDip = 210; + private const double TopMarginDip = 12; + + // Animation timing. + private const double SlideMs = 300; + private const double HoldMs = 4000; + + private enum Phase { In, Hold, Out } + + private readonly IslandView _view = new(); + private readonly DispatcherQueueTimer _timer; + private readonly Stopwatch _clock = new(); + + private Phase _phase = Phase.In; + private bool _shown; + private bool _prepared; + private bool _loaded; + private bool _closed; + + // Physical-pixel geometry, computed once the XamlRoot scale is known. + private int _width; + private int _height; + private int _centerX; + private int _targetY; + private int _startY; + + public IslandWindow() + { + Content = _view; + Title = "LibrePods"; + + try { ConfigurePresenter(); } catch { } + try { SystemBackdrop = new DesktopAcrylicBackdrop(); } catch { } + + _timer = DispatcherQueue.CreateTimer(); + _timer.Interval = TimeSpan.FromMilliseconds(16); // ~60 fps + _timer.Tick += OnTick; + + // Geometry + the slide-in start once the content is laid out (its + // XamlRoot — and therefore the rasterization scale — is available then). + _view.Loaded += OnViewLoaded; + + Closed += (_, _) => { _closed = true; try { _timer.Stop(); } catch { } }; + } + + /// Match the island to the app's chosen theme — the DesktopAcrylic tint and the + /// ThemeResource text brushes follow the content's RequestedTheme. Default just + /// follows the system. Applied on each show in case the theme changed meanwhile. + private void ApplyTheme() + { + try { _view.RequestedTheme = AppSettings.Theme; } catch { } + } + + /// Show the island for a fresh connection, or — if one is already on screen — + /// re-populate it and reset the hold so reconnect spam never stacks popups. + public void ShowConnected(Snapshot s) + { + if (_closed) return; + try + { + ApplyTheme(); + _view.Apply(s); + + if (!_shown) + { + _shown = true; + // Show without activating so it never steals focus. The no-activate + // / tool-window ex-styles and rounded corners are applied here (the + // HWND exists once AppWindow does). + try { ApplyExStyles(); } catch { } + try { RoundCorners(); } catch { } + AppWindow.Show(false); + // First-ever show: the slide-in is kicked off from OnViewLoaded (the + // XamlRoot isn't ready yet). Reuse after a hide: the view is already + // loaded, so start it now. + if (_loaded) StartSlideIn(); + } + else + { + // Already visible: snap to the resting spot and restart the hold. + _view.Opacity = 1; + _phase = Phase.Hold; + _clock.Restart(); + if (_prepared) + { + try { AppWindow.Move(new PointInt32(_centerX, _targetY)); } catch { } + } + try { AppWindow.Show(false); } catch { } + if (!_timer.IsRunning) _timer.Start(); + } + } + catch + { + // Never let a popup refresh crash the app. + } + } + + /// Show a daemon overlay as the centred island (message mode) instead of a + /// Windows toast — same slide/hold as the connection card. + public void ShowMessage(string title, string body, string? model = null) + { + if (_closed) return; + try + { + ApplyTheme(); + _view.ApplyMessage(title, body, model); + + if (!_shown) + { + _shown = true; + try { ApplyExStyles(); } catch { } + try { RoundCorners(); } catch { } + AppWindow.Show(false); + if (_loaded) StartSlideIn(); // reuse after a hide (see ShowConnected) + } + else + { + _view.Opacity = 1; + _phase = Phase.Hold; + _clock.Restart(); + if (_prepared) + { + try { AppWindow.Move(new PointInt32(_centerX, _targetY)); } catch { } + } + try { AppWindow.Show(false); } catch { } + if (!_timer.IsRunning) _timer.Start(); + } + } + catch + { + // Never let a popup refresh crash the app. + } + } + + // ---- Presenter / interop ---------------------------------------------- + + private void ConfigurePresenter() + { + AppWindow.IsShownInSwitchers = false; + if (AppWindow.Presenter is OverlappedPresenter p) + { + p.SetBorderAndTitleBar(false, false); + p.IsAlwaysOnTop = true; + p.IsResizable = false; + p.IsMaximizable = false; + p.IsMinimizable = false; + } + } + + private void OnViewLoaded(object sender, RoutedEventArgs e) + { + _loaded = true; + if (_closed) return; + // A show requested before the view had loaded (the first show) starts here, + // once the XamlRoot/scale is available. + if (_shown) StartSlideIn(); + } + + /// Begin the slide-in → hold → slide-out cycle from the top. Callable on every + /// show (the first waits for OnViewLoaded; reuse-after-hide calls it directly). + private void StartSlideIn() + { + if (_closed) return; + try + { + Prepare(); + _phase = Phase.In; + _view.Opacity = 0; + try { AppWindow.Move(new PointInt32(_centerX, _startY)); } catch { } + _clock.Restart(); + if (!_timer.IsRunning) _timer.Start(); + } + catch + { + // If we can't set up the slide, just hide quietly. + SafeClose(); + } + } + + /// Compute physical-pixel size/position from the current display work area and + /// the content's rasterization scale. + private void Prepare() + { + if (_prepared) return; + + double scale = _view.XamlRoot?.RasterizationScale ?? 1.0; + if (scale <= 0) scale = 1.0; + + // Measure the card's NATURAL height at the fixed width. ActualHeight here + // is the (screen-tall) window it currently fills — circular — which made + // the popup gigantic; DesiredSize is the content's real ~92px. + _view.Measure(new Windows.Foundation.Size(DesignWidthDip, double.PositiveInfinity)); + double heightDip = _view.DesiredSize.Height > 0 ? _view.DesiredSize.Height : FallbackHeightDip; + + _width = (int)Math.Ceiling(DesignWidthDip * scale); + _height = (int)Math.Ceiling(heightDip * scale); + try { AppWindow.Resize(new SizeInt32(_width, _height)); } catch { } + + // Centre horizontally on the display containing this window; rest a small + // margin above the bottom of the work area, and slide in from off the + // bottom edge (rising up into view, then sliding back down to close). + var area = DisplayArea.GetFromWindowId(AppWindow.Id, DisplayAreaFallback.Nearest); + var work = area.WorkArea; + int margin = (int)Math.Round(TopMarginDip * scale); + _centerX = work.X + (work.Width - _width) / 2; + _targetY = work.Y + work.Height - _height - margin; + _startY = work.Y + work.Height; // fully off the bottom + + _prepared = true; + } + + // ---- Slide / hold / slide-out driver ---------------------------------- + + private void OnTick(DispatcherQueueTimer sender, object args) + { + if (_closed) { try { _timer.Stop(); } catch { } return; } + + try + { + double elapsed = _clock.Elapsed.TotalMilliseconds; + + switch (_phase) + { + case Phase.In: + { + double t = Math.Clamp(elapsed / SlideMs, 0, 1); + double k = EaseOut(t); + Move(Lerp(_startY, _targetY, k)); + _view.Opacity = t; + if (t >= 1) + { + _view.Opacity = 1; + _phase = Phase.Hold; + _clock.Restart(); + } + break; + } + case Phase.Hold: + { + if (elapsed >= HoldMs) + { + _phase = Phase.Out; + _clock.Restart(); + } + break; + } + case Phase.Out: + { + double t = Math.Clamp(elapsed / SlideMs, 0, 1); + double k = EaseOut(t); + Move(Lerp(_targetY, _startY, k)); + _view.Opacity = 1 - t; + if (t >= 1) + { + try { _timer.Stop(); } catch { } + SafeClose(); + } + break; + } + } + } + catch + { + try { _timer.Stop(); } catch { } + SafeClose(); + } + } + + private void Move(double y) + { + try { AppWindow.Move(new PointInt32(_centerX, (int)Math.Round(y))); } catch { } + } + + /// End the current popup. Deliberately does NOT call Window.Close(): closing a + /// WinUI 3 Window that carries a DesktopAcrylicBackdrop + themed content, from a + /// DispatcherQueueTimer tick, fail-fasts inside the XAML theme-resource teardown + /// (Microsoft_UI_Xaml!OverrideXamlResourcePropertyBag, 0xC000027B) — a native + /// crash a try/catch can't stop. Instead we hide the single window and reset the + /// animation state so the next show slides it back in (the reuse the design + /// already intends). The window then lives for the app's lifetime. + private void SafeClose() + { + if (_closed) return; + try { _timer.Stop(); } catch { } + try { AppWindow.Hide(); } catch { } + _shown = false; + _phase = Phase.In; + } + + private static double Lerp(double a, double b, double t) => a + (b - a) * t; + + // Cubic ease-out: fast start, gentle settle — the iOS/Android island feel. + private static double EaseOut(double t) => 1 - Math.Pow(1 - t, 3); + + // ---- Win32 ex-styles + DWM rounded corners ---------------------------- + + private void ApplyExStyles() + { + var hwnd = WinRT.Interop.WindowNative.GetWindowHandle(this); + nint ex = GetWindowLongPtr(hwnd, GWL_EXSTYLE); + // NOACTIVATE: never take focus (no focus-steal). TOOLWINDOW: keep it off + // the taskbar and Alt-Tab (belt-and-braces with IsShownInSwitchers=false). + ex |= WS_EX_NOACTIVATE | WS_EX_TOOLWINDOW; + SetWindowLongPtr(hwnd, GWL_EXSTYLE, ex); + } + + private void RoundCorners() + { + var hwnd = WinRT.Interop.WindowNative.GetWindowHandle(this); + int pref = DWMWCP_ROUND; + // No-op on Windows 10 (square corners); harmless there. + DwmSetWindowAttribute(hwnd, DWMWA_WINDOW_CORNER_PREFERENCE, ref pref, sizeof(int)); + } + + private const int GWL_EXSTYLE = -20; + private const nint WS_EX_NOACTIVATE = 0x08000000; + private const nint WS_EX_TOOLWINDOW = 0x00000080; + private const int DWMWA_WINDOW_CORNER_PREFERENCE = 33; + private const int DWMWCP_ROUND = 2; + + [DllImport("user32.dll", EntryPoint = "GetWindowLongPtrW", SetLastError = true)] + private static extern nint GetWindowLongPtr(nint hWnd, int nIndex); + + [DllImport("user32.dll", EntryPoint = "SetWindowLongPtrW", SetLastError = true)] + private static extern nint SetWindowLongPtr(nint hWnd, int nIndex, nint dwNewLong); + + [DllImport("dwmapi.dll", SetLastError = true)] + private static extern int DwmSetWindowAttribute(nint hwnd, int attribute, ref int pvAttribute, int cbAttribute); +} diff --git a/windows/winui/LibrePods.WinUI/Popup/TrayMenuView.xaml b/windows/winui/LibrePods.WinUI/Popup/TrayMenuView.xaml new file mode 100644 index 000000000..60c9de0de --- /dev/null +++ b/windows/winui/LibrePods.WinUI/Popup/TrayMenuView.xaml @@ -0,0 +1,159 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/windows/winui/LibrePods.WinUI/Popup/TrayMenuView.xaml.cs b/windows/winui/LibrePods.WinUI/Popup/TrayMenuView.xaml.cs new file mode 100644 index 000000000..76f258868 --- /dev/null +++ b/windows/winui/LibrePods.WinUI/Popup/TrayMenuView.xaml.cs @@ -0,0 +1,75 @@ +using System; +using System.Collections.Generic; +using LibrePods.WinUI.Ipc; +using LibrePods.WinUI.Services; +using Microsoft.UI.Xaml; +using Microsoft.UI.Xaml.Controls; +using Microsoft.UI.Xaml.Media.Imaging; + +namespace LibrePods.WinUI.Popup; + +/// The content of the custom tray menu: a themed, icon-per-row list rendered as a +/// UserControl so it follows the app theme and carries per-item artwork (things the +/// WinUI MenuFlyout / native Win32 menu can't do in the tray). Pure view — the +/// window owns positioning + dismissal; actions are surfaced as callbacks. +public sealed partial class TrayMenuView : UserControl +{ + public Action? OnAnc; + public Action? OnMute; + public Action? OnOpen; + public Action? OnQuit; + public Action? OnDismiss; + + public TrayMenuView() + { + InitializeComponent(); + } + + /// Fill the menu from the latest snapshot. + public void Apply(Snapshot s) + { + HeaderName.Text = string.IsNullOrWhiteSpace(s.DevName) ? "LibrePods" : s.DevName; + + var model = string.IsNullOrEmpty(s.Model) ? AppSettings.LastModel : s.Model; + try { HeaderImage.Source = new BitmapImage(new Uri(DeviceArt.MainImage(model))); } + catch { } + + var parts = new List(); + if (Present(s.Battery.Left) is byte l) parts.Add($"L {l}%"); + if (Present(s.Battery.Right) is byte r) parts.Add($"R {r}%"); + if (Present(s.Battery.Case) is byte c) parts.Add($"{Localize.Get("Tray_CaseShort")} {c}%"); + BatteryText.Text = parts.Count > 0 ? string.Join(" · ", parts) : Localize.Get("Tray_NoBatteryData"); + + AncLabel.Text = Localize.Get("Tray_NoiseControl"); + AncOffText.Text = Localize.Get("Anc_Off"); + AncNcText.Text = Localize.Get("Anc_NoiseCancellation"); + AncTransText.Text = Localize.Get("Anc_Transparency"); + AncAdaptiveText.Text = Localize.Get("Anc_Adaptive"); + + AncOffRow.IsEnabled = s.AllowOff; + AncOffCheck.Visibility = Check(s.Anc == 1); + AncNcCheck.Visibility = Check(s.Anc == 2); + AncTransCheck.Visibility = Check(s.Anc == 3); + AncAdaptiveCheck.Visibility = Check(s.Anc == 4); + + MuteText.Text = Localize.Get(s.Muted ? "Action_Unmute" : "Action_Mute"); + OpenText.Text = Localize.Get("Action_Open"); + QuitText.Text = Localize.Get("Action_Quit"); + } + + private static Visibility Check(bool on) => on ? Visibility.Visible : Visibility.Collapsed; + private static byte? Present(byte? v) => v is byte b and <= 100 ? b : null; + + private void Fire(Action? a) + { + try { a?.Invoke(); } finally { OnDismiss?.Invoke(); } + } + + private void AncOff_Click(object s, RoutedEventArgs e) => Fire(() => OnAnc?.Invoke(1)); + private void AncNc_Click(object s, RoutedEventArgs e) => Fire(() => OnAnc?.Invoke(2)); + private void AncTrans_Click(object s, RoutedEventArgs e) => Fire(() => OnAnc?.Invoke(3)); + private void AncAdaptive_Click(object s, RoutedEventArgs e) => Fire(() => OnAnc?.Invoke(4)); + private void Mute_Click(object s, RoutedEventArgs e) => Fire(() => OnMute?.Invoke()); + private void Open_Click(object s, RoutedEventArgs e) => Fire(() => OnOpen?.Invoke()); + private void Quit_Click(object s, RoutedEventArgs e) => Fire(() => OnQuit?.Invoke()); +} diff --git a/windows/winui/LibrePods.WinUI/Popup/TrayMenuWindow.cs b/windows/winui/LibrePods.WinUI/Popup/TrayMenuWindow.cs new file mode 100644 index 000000000..8d4f692b4 --- /dev/null +++ b/windows/winui/LibrePods.WinUI/Popup/TrayMenuWindow.cs @@ -0,0 +1,172 @@ +using System; +using System.Runtime.InteropServices; +using LibrePods.WinUI.Ipc; +using LibrePods.WinUI.Services; +using Microsoft.UI.Windowing; +using Microsoft.UI.Xaml; +using Microsoft.UI.Xaml.Media; +using Windows.Graphics; + +namespace LibrePods.WinUI.Popup; + +/// A borderless, themed, self-dismissing window that hosts +/// as the tray's context menu. Replaces the WinUI MenuFlyout (which clipped to ~1 +/// char in H.NotifyIcon's SecondWindow host) and the native Win32 menu (no per-item +/// icons, no app theme). It appears at the cursor, growing up from the tray, and +/// closes when it loses focus or an item is chosen. Every windowing call is guarded. +public sealed class TrayMenuWindow : Window +{ + private const double MenuWidthDip = 260; + private const double FallbackHeightDip = 420; + + private readonly TrayMenuView _view = new(); + private readonly DaemonClient _client; + private readonly Action _onOpen; + private readonly Action _onQuit; + private bool _closed; + private bool _positioned; + + public TrayMenuWindow(DaemonClient client, Action onOpen, Action onQuit) + { + _client = client; + _onOpen = onOpen; + _onQuit = onQuit; + + Content = _view; + Title = "LibrePods menu"; + + _view.OnAnc = a => _client.SetAnc(a); + _view.OnMute = () => _client.ToggleMute(); + _view.OnOpen = () => _onOpen(); + _view.OnQuit = () => _onQuit(); + _view.OnDismiss = SafeClose; + + try { ConfigurePresenter(); } catch { } + try { SystemBackdrop = new DesktopAcrylicBackdrop(); } catch { } + + _view.Loaded += OnViewLoaded; + // Dismiss when focus leaves the menu (click elsewhere / Esc handled by focus). + Activated += OnActivated; + Closed += (_, _) => _closed = true; + } + + /// Populate + show the menu at the current cursor position. + public void ShowAt(Snapshot snapshot) + { + if (_closed) return; + try + { + _view.RequestedTheme = AppSettings.Theme; + _view.Apply(snapshot); + try { ApplyExStyles(); } catch { } + try { RoundCorners(); } catch { } + // Realize the HWND + lay out the content; final placement happens in + // OnViewLoaded once the rasterization scale is known. + AppWindow.Show(true); + } + catch { SafeClose(); } + } + + private void OnViewLoaded(object sender, RoutedEventArgs e) + { + if (_closed) return; + try + { + double scale = _view.XamlRoot?.RasterizationScale ?? 1.0; + if (scale <= 0) scale = 1.0; + + _view.Measure(new Windows.Foundation.Size(MenuWidthDip, double.PositiveInfinity)); + double heightDip = _view.DesiredSize.Height > 0 ? _view.DesiredSize.Height : FallbackHeightDip; + double widthDip = _view.DesiredSize.Width > 0 ? _view.DesiredSize.Width : MenuWidthDip; + + int w = (int)Math.Ceiling(widthDip * scale); + int h = (int)Math.Ceiling(heightDip * scale); + try { AppWindow.Resize(new SizeInt32(w, h)); } catch { } + + var area = DisplayArea.GetFromWindowId(AppWindow.Id, DisplayAreaFallback.Nearest); + var work = area.WorkArea; + GetCursorPos(out var cur); + + // Grow up-left from the cursor (tray sits bottom-right); clamp on-screen. + int x = cur.X; + int y = cur.Y - h; + if (x + w > work.X + work.Width) x = work.X + work.Width - w; + if (x < work.X) x = work.X; + if (y < work.Y) y = work.Y; + if (y + h > work.Y + work.Height) y = work.Y + work.Height - h; + + try { AppWindow.Move(new PointInt32(x, y)); } catch { } + _positioned = true; + + // Take focus so the first click-away deactivates (and dismisses) us. + try { SetForegroundWindow(WinRT.Interop.WindowNative.GetWindowHandle(this)); } catch { } + } + catch { SafeClose(); } + } + + private void OnActivated(object sender, WindowActivatedEventArgs args) + { + // Only dismiss on genuine focus loss after we've placed + shown the menu. + if (_positioned && args.WindowActivationState == WindowActivationState.Deactivated) + SafeClose(); + } + + private void ConfigurePresenter() + { + AppWindow.IsShownInSwitchers = false; + if (AppWindow.Presenter is OverlappedPresenter p) + { + p.SetBorderAndTitleBar(false, false); + p.IsAlwaysOnTop = true; + p.IsResizable = false; + p.IsMaximizable = false; + p.IsMinimizable = false; + } + } + + private void SafeClose() + { + if (_closed) return; + try { Close(); } catch { _closed = true; } + } + + // ---- Win32 ex-styles + rounded corners + cursor ----------------------- + + private void ApplyExStyles() + { + var hwnd = WinRT.Interop.WindowNative.GetWindowHandle(this); + nint ex = GetWindowLongPtr(hwnd, GWL_EXSTYLE); + ex |= WS_EX_TOOLWINDOW; // no taskbar / Alt-Tab (but stays activatable) + SetWindowLongPtr(hwnd, GWL_EXSTYLE, ex); + } + + private void RoundCorners() + { + var hwnd = WinRT.Interop.WindowNative.GetWindowHandle(this); + int pref = DWMWCP_ROUND; + DwmSetWindowAttribute(hwnd, DWMWA_WINDOW_CORNER_PREFERENCE, ref pref, sizeof(int)); + } + + private const int GWL_EXSTYLE = -20; + private const nint WS_EX_TOOLWINDOW = 0x00000080; + private const int DWMWA_WINDOW_CORNER_PREFERENCE = 33; + private const int DWMWCP_ROUND = 2; + + [StructLayout(LayoutKind.Sequential)] + private struct POINT { public int X; public int Y; } + + [DllImport("user32.dll")] + private static extern bool GetCursorPos(out POINT lpPoint); + + [DllImport("user32.dll")] + private static extern bool SetForegroundWindow(nint hWnd); + + [DllImport("user32.dll", EntryPoint = "GetWindowLongPtrW", SetLastError = true)] + private static extern nint GetWindowLongPtr(nint hWnd, int nIndex); + + [DllImport("user32.dll", EntryPoint = "SetWindowLongPtrW", SetLastError = true)] + private static extern nint SetWindowLongPtr(nint hWnd, int nIndex, nint dwNewLong); + + [DllImport("dwmapi.dll", SetLastError = true)] + private static extern int DwmSetWindowAttribute(nint hwnd, int attribute, ref int pvAttribute, int cbAttribute); +} diff --git a/windows/winui/LibrePods.WinUI/Services/AppSettings.cs b/windows/winui/LibrePods.WinUI/Services/AppSettings.cs new file mode 100644 index 000000000..e7005819d --- /dev/null +++ b/windows/winui/LibrePods.WinUI/Services/AppSettings.cs @@ -0,0 +1,130 @@ +using System.IO; +using System.Text.Json; +using Microsoft.UI.Xaml; + +namespace LibrePods.WinUI.Services; + +/// Small persisted settings for the WinUI app, stored as JSON at +/// %LOCALAPPDATA%\LibrePods\winui-settings.json. An unpackaged app has no +/// ApplicationData.LocalSettings, so we keep our own file next to the daemon's +/// data. Best-effort — a read/write failure just falls back to defaults. +public static class AppSettings +{ + private sealed class Model + { + // 0 = System (Default), 1 = Light, 2 = Dark — the ThemeButtons indices. + public int ThemeIndex { get; set; } + + // Experimental gate for ALL experimental main-UI cards (heart-rate AND + // hearing-aid). Off by default. Heart rate in particular does not work on + // Windows (Apple-host gate — the buds ACK the enable but never send readings; + // see docs/heart-rate.md), and the hearing-aid ATT channel is still maturing. + public bool EnableExperimental { get; set; } + + // BCP-47 UI language override (e.g. "pt-PT"); "" = follow the system. + // Applied as ApplicationLanguages.PrimaryLanguageOverride at startup. + public string LanguageTag { get; set; } = ""; + + // Last-seen device model number (from the 0x1D metadata). Cached so the + // connect island can show the right artwork *immediately*, before this + // session's metadata packet arrives. + public string LastModel { get; set; } = ""; + } + + private static readonly object _gate = new(); + private static Model? _cache; + + private static string? Path_() + { + var local = System.Environment.GetFolderPath(System.Environment.SpecialFolder.LocalApplicationData); + if (string.IsNullOrEmpty(local)) return null; + return System.IO.Path.Combine(local, "LibrePods", "winui-settings.json"); + } + + private static Model Load() + { + lock (_gate) + { + if (_cache is not null) return _cache; + try + { + var path = Path_(); + if (path is not null && File.Exists(path)) + _cache = JsonSerializer.Deserialize(File.ReadAllText(path)); + } + catch { } + return _cache ??= new Model(); + } + } + + private static void Save(Model m) + { + lock (_gate) + { + _cache = m; + try + { + var path = Path_(); + if (path is null) return; + var dir = System.IO.Path.GetDirectoryName(path); + if (!string.IsNullOrEmpty(dir)) Directory.CreateDirectory(dir); + File.WriteAllText(path, JsonSerializer.Serialize(m)); + } + catch { } + } + } + + /// The saved theme selector index (0 System / 1 Light / 2 Dark). + public static int ThemeIndex => Load().ThemeIndex; + + /// The saved theme as an ElementTheme for the window root. + public static ElementTheme Theme => ThemeIndex switch + { + 1 => ElementTheme.Light, + 2 => ElementTheme.Dark, + _ => ElementTheme.Default, + }; + + /// Persist the chosen theme index. + public static void SetThemeIndex(int index) + { + var m = Load(); + m.ThemeIndex = index; + Save(m); + } + + /// Whether experimental cards (heart-rate + hearing-aid) are shown (off by default). + public static bool EnableExperimental => Load().EnableExperimental; + + /// Persist the experimental-features opt-in. + public static void SetEnableExperimental(bool on) + { + var m = Load(); + m.EnableExperimental = on; + Save(m); + } + + /// The last-seen device model number (for the connect island's early artwork). + public static string LastModel => Load().LastModel ?? ""; + + /// Persist the last-seen device model number. + public static void SetLastModel(string model) + { + if (string.IsNullOrWhiteSpace(model)) return; + var m = Load(); + if (m.LastModel == model) return; + m.LastModel = model; + Save(m); + } + + /// The saved UI language override (BCP-47, e.g. "pt-PT"); "" = follow the system. + public static string LanguageTag => Load().LanguageTag ?? ""; + + /// Persist the chosen UI language override (takes effect on restart). + public static void SetLanguageTag(string tag) + { + var m = Load(); + m.LanguageTag = tag ?? ""; + Save(m); + } +} diff --git a/windows/winui/LibrePods.WinUI/Services/DeviceArt.cs b/windows/winui/LibrePods.WinUI/Services/DeviceArt.cs new file mode 100644 index 000000000..81073e4f0 --- /dev/null +++ b/windows/winui/LibrePods.WinUI/Services/DeviceArt.cs @@ -0,0 +1,53 @@ +namespace LibrePods.WinUI.Services; + +/// Maps an AirPods model number (from the 0x1D metadata packet, e.g. "A3064") to +/// the right artwork family under Assets/. Each real family ships a base image +/// plus _left / _right / _case parts; unknown models fall back to the generic +/// airpods.png (which has no parts — the part URIs return null so callers can +/// degrade gracefully). +/// +/// Model → family table follows Apple's identifiers (support.apple.com/109525), +/// matching the daemon / Android side. +public static class DeviceArt +{ + private const string Base = "ms-appx:///Assets/"; + + /// The artwork family prefix for a model number, or "airpods" (generic) when + /// unknown. The generic family only has the base image, no parts. + public static string Family(string? model) => Normalize(model) switch + { + "A1523" or "A1722" => "airpods_1", + "A2032" or "A2031" => "airpods_2", + "A2564" or "A2565" => "airpods_3", + "A3053" or "A3050" or "A3054" or "A3055" or "A3056" or "A3057" => "airpods_4", + "A2083" or "A2084" => "airpods_pro_1", + "A2698" or "A2699" or "A2931" or "A2968" + or "A3047" or "A3048" or "A3049" => "airpods_pro_2", + "A3063" or "A3064" => "airpods_pro_3", + _ => "airpods", // includes AirPods Max (no dedicated asset) + unknown + }; + + /// Whether this family ships the _left / _right / _case part images. + private static bool HasParts(string family) => family != "airpods"; + + /// The main product image — always available (generic fallback). + public static string MainImage(string? model) => $"{Base}{Family(model)}.png"; + + /// The left-bud image, or null when the family has no parts. + public static string? LeftImage(string? model) => Part(model, "left"); + + /// The right-bud image, or null when the family has no parts. + public static string? RightImage(string? model) => Part(model, "right"); + + /// The case image, or null when the family has no parts. + public static string? CaseImage(string? model) => Part(model, "case"); + + private static string? Part(string? model, string part) + { + var family = Family(model); + return HasParts(family) ? $"{Base}{family}_{part}.png" : null; + } + + private static string Normalize(string? model) => + string.IsNullOrWhiteSpace(model) ? "" : model.Trim().ToUpperInvariant(); +} diff --git a/windows/winui/LibrePods.WinUI/Services/Loc.cs b/windows/winui/LibrePods.WinUI/Services/Loc.cs new file mode 100644 index 000000000..13c8aad88 --- /dev/null +++ b/windows/winui/LibrePods.WinUI/Services/Loc.cs @@ -0,0 +1,134 @@ +using System; +using System.Collections.Generic; +using System.ComponentModel; +using System.Globalization; +using System.Xml.Linq; + +namespace LibrePods.WinUI.Services; + +/// Runtime localization service — an Angular-style translate service. All UI text +/// binds to this indexer (via the {loc:Loc} markup extension or Get() in code); +/// changing the culture raises the indexer PropertyChanged so every binding +/// re-evaluates and the whole UI switches language LIVE, no restart. This sidesteps +/// x:Uid / PrimaryLanguageOverride, which don't work on the unpackaged build. +/// +/// The strings come from the same Strings//Resources.resw files (embedded +/// as "loc." and parsed into a culture -> key -> value map at startup), so +/// translations aren't duplicated. +public sealed class Loc : INotifyPropertyChanged +{ + public const string Fallback = "en-US"; + private static readonly string[] Cultures = { "en-US", "pt-PT", "fr-FR", "es-ES" }; + + // The ONE instance: App.xaml declares , whose ctor + // publishes itself here so code (Localize.Get -> Loc.Instance) and XAML + // ({StaticResource Loc}) share it. There is no static `new()` — a second + // instance would split the culture (code in one language, bindings in another). + public static Loc Instance { get; private set; } = null!; + + private readonly Dictionary> _map = + new(StringComparer.OrdinalIgnoreCase); + private string _culture; + + public event PropertyChangedEventHandler? PropertyChanged; + + public Loc() + { + foreach (var c in Cultures) _map[c] = Load(c); + _culture = ResolveInitial(); + Instance = this; + } + + /// The active BCP-47 culture (e.g. "pt-PT"). + public string CurrentCulture => _culture; + + /// Localized value for a resw key (e.g. "SettingsTitle.Text"), current culture, + /// falling back to en-US then the key itself. + public string this[string key] + { + get + { + if (_map.TryGetValue(_culture, out var d) && d.TryGetValue(key, out var v)) return v; + if (_map.TryGetValue(Fallback, out var f) && f.TryGetValue(key, out var fv)) return fv; + return key; + } + } + + public string Get(string key) => this[key]; + public string Get(string key, params object[] args) => string.Format(this[key], args); + + /// Switch the UI language live. "" / unknown → the system default. No-op if + /// unchanged; otherwise notifies every binding to re-fetch. + public void SetCulture(string tag) + { + var c = string.IsNullOrWhiteSpace(tag) ? SystemDefault() : tag; + if (!_map.ContainsKey(c)) c = Fallback; + if (string.Equals(c, _culture, StringComparison.OrdinalIgnoreCase)) return; + _culture = c; + var h = PropertyChanged; + if (h is null) return; + // WinUI (unlike WPF) doesn't reliably refresh `{Binding [key]}` on an + // "Item[]" notification — raise the "all properties changed" signal (empty + // string) so every binding sourced on this object re-fetches. + h(this, new PropertyChangedEventArgs(string.Empty)); + h(this, new PropertyChangedEventArgs("Item[]")); + } + + private static Dictionary Load(string culture) + { + var dict = new Dictionary(StringComparer.Ordinal); + try + { + var asm = typeof(Loc).Assembly; + // Find the embedded resw by name — exact LogicalName first, else any + // manifest name containing the culture (in case a namespace prefix or + // mangling was applied), so loading never silently returns empty. + var names = asm.GetManifestResourceNames(); + var resName = System.Array.Find(names, n => n == $"loc.{culture}") + ?? System.Array.Find(names, n => n.Contains(culture) && n.EndsWith(".resw", StringComparison.OrdinalIgnoreCase)) + ?? System.Array.Find(names, n => n.Contains(culture)); + if (resName is null) return dict; + using var stream = asm.GetManifestResourceStream(resName); + if (stream is null) return dict; + var doc = XDocument.Load(stream); + foreach (var data in doc.Root?.Elements("data") ?? System.Linq.Enumerable.Empty()) + { + var name = (string?)data.Attribute("name"); + var value = data.Element("value")?.Value; + if (!string.IsNullOrEmpty(name) && value is not null) + { + dict[name!] = value; + // Also index under a dot-free alias so XAML bindings can use + // [SettingsTitle_Text] (a dot in the indexer path is ambiguous). + var alias = name!.Replace('.', '_'); + if (alias != name) dict[alias] = value; + } + } + } + catch { } + return dict; + } + + private string ResolveInitial() + { + var saved = AppSettings.LanguageTag; + if (!string.IsNullOrWhiteSpace(saved) && _map.ContainsKey(saved)) return saved; + var sys = SystemDefault(); + return _map.ContainsKey(sys) ? sys : Fallback; + } + + private static string SystemDefault() + { + try + { + return CultureInfo.CurrentUICulture.TwoLetterISOLanguageName switch + { + "pt" => "pt-PT", + "fr" => "fr-FR", + "es" => "es-ES", + _ => "en-US", + }; + } + catch { return Fallback; } + } +} diff --git a/windows/winui/LibrePods.WinUI/Services/Localize.cs b/windows/winui/LibrePods.WinUI/Services/Localize.cs new file mode 100644 index 000000000..7beb07e63 --- /dev/null +++ b/windows/winui/LibrePods.WinUI/Services/Localize.cs @@ -0,0 +1,15 @@ +namespace LibrePods.WinUI.Services; + +/// Convenience facade over the runtime service for code-picked +/// strings (connection status, mute/unmute, mic state, tray, toasts). Delegates to +/// the current culture, so these follow a live language switch the next time they're +/// evaluated. Static XAML labels bind to {StaticResource Loc} directly. +internal static class Localize +{ + /// Look up a localized string by resw key (falls back to the key itself). + public static string Get(string key) => Loc.Instance is { } loc ? loc.Get(key) : key; + + /// Look up a composite string and fill its {0}… placeholders. + public static string Get(string key, params object[] args) => + Loc.Instance is { } loc ? loc.Get(key, args) : key; +} diff --git a/windows/winui/LibrePods.WinUI/Services/Notifier.cs b/windows/winui/LibrePods.WinUI/Services/Notifier.cs new file mode 100644 index 000000000..3ab58698c Binary files /dev/null and b/windows/winui/LibrePods.WinUI/Services/Notifier.cs differ diff --git a/windows/winui/LibrePods.WinUI/Services/OverlayText.cs b/windows/winui/LibrePods.WinUI/Services/OverlayText.cs new file mode 100644 index 000000000..0ee79af9c --- /dev/null +++ b/windows/winui/LibrePods.WinUI/Services/OverlayText.cs @@ -0,0 +1,53 @@ +using System.Collections.Generic; + +namespace LibrePods.WinUI.Services; + +/// The daemon (Rust) emits overlay/notification text in English. Map the fixed +/// phrases to the app's localized resources so toasts and the island match the UI +/// language. Interpolated bodies (battery %, device names) are passed through +/// unchanged for now — localizing those needs the daemon to send keys, not text. +public static class OverlayText +{ + // English daemon body → resource key. Reuses the ANC / status keys the rest of + // the UI already uses. + private static readonly Dictionary Exact = new() + { + ["Off"] = "Anc_Off", + ["Noise Cancellation"] = "Anc_NoiseCancellation", + ["Transparency"] = "Anc_Transparency", + ["Adaptive"] = "Anc_Adaptive", + ["Disconnected"] = "Status_Disconnected", + ["AirPod in case"] = "Overlay_InCase", + ["Heart rate monitoring on"] = "Overlay_HrOn", + ["Heart rate monitoring off"] = "Overlay_HrOff", + ["Hi-res microphone on"] = "Overlay_MicOn", + ["Microphone in use — hi-res on"] = "Overlay_MicInUse", + ["Microphone released — restoring stereo…"] = "Overlay_MicReleasing", + ["Stereo restored"] = "Overlay_StereoRestored", + }; + + /// Localize a daemon overlay body, falling back to the original text. + public static string Resolve(string body) + { + if (Exact.TryGetValue(body, out var key)) + { + var s = Localize.Get(key); + if (!string.IsNullOrEmpty(s)) return s; + } + + // Interpolated bodies (battery %, connect/case events): translate the known + // English fragments the daemon emits, leaving the numbers. Order matters — + // multi-word phrases before the bare "Case ". + var t = body; + t = t.Replace("Case battery low —", Localize.Get("Overlay_CaseBatteryLow")); + t = t.Replace("Battery low —", Localize.Get("Overlay_BatteryLow")); + t = t.Replace("Case opened", Localize.Get("Overlay_CaseOpened")); + t = t.Replace("Case closed", Localize.Get("Overlay_CaseClosed")); + t = t.Replace("Connected", Localize.Get("Status_Connected")); + t = t.Replace("Renamed to", Localize.Get("Overlay_RenamedTo")); + t = t.Replace("Left ", Localize.Get("Battery_LeftShort") + " "); + t = t.Replace("Right ", Localize.Get("Battery_RightShort") + " "); + t = t.Replace("Case ", Localize.Get("Battery_CaseShort") + " "); + return t; + } +} diff --git a/windows/winui/LibrePods.WinUI/Services/StartupService.cs b/windows/winui/LibrePods.WinUI/Services/StartupService.cs new file mode 100644 index 000000000..06927ca2e --- /dev/null +++ b/windows/winui/LibrePods.WinUI/Services/StartupService.cs @@ -0,0 +1,68 @@ +using System; +using System.IO; +using Microsoft.Win32; + +namespace LibrePods.WinUI.Services; + +/// Manages "run LibrePods at Windows login" via the per-user Run registry key +/// (HKCU\...\Run — no admin needed). Registers both the headless daemon and the +/// WinUI app (started minimised to the tray), mirroring what the installer sets up, +/// so the user can turn login-startup on/off from Settings. +public static class StartupService +{ + private const string RunKey = @"Software\Microsoft\Windows\CurrentVersion\Run"; + private const string AppValue = "LibrePods"; // the WinUI tray app + private const string DaemonValue = "LibrePods Daemon"; // the headless daemon + + /// True when the login-startup entries are present. + public static bool IsEnabled() + { + try + { + using var k = Registry.CurrentUser.OpenSubKey(RunKey); + return k?.GetValue(AppValue) is not null || k?.GetValue(DaemonValue) is not null; + } + catch { return false; } + } + + /// Add or remove the login-startup entries. Best-effort and fully guarded. + public static void SetEnabled(bool on) + { + try + { + using var k = Registry.CurrentUser.CreateSubKey(RunKey); + if (k is null) return; + if (on) + { + var app = Environment.ProcessPath; // this exe (librepods-winui.exe) + if (!string.IsNullOrEmpty(app)) + k.SetValue(AppValue, $"\"{app}\" --tray"); + var daemon = FindDaemon(); + if (daemon is not null) + k.SetValue(DaemonValue, $"\"{daemon}\""); + } + else + { + k.DeleteValue(AppValue, throwOnMissingValue: false); + k.DeleteValue(DaemonValue, throwOnMissingValue: false); + } + } + catch { } + } + + /// Locate librepodsd.exe — next to us, else the standard install dir. Mirrors + /// DaemonClient.FindDaemon so the startup entry points at the same binary. + private static string? FindDaemon() + { + string[] candidates = + { + Path.Combine(AppContext.BaseDirectory, "librepodsd.exe"), + Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), + "LibrePods", "librepodsd.exe"), + }; + foreach (var c in candidates) + if (File.Exists(c)) return c; + return null; + } +} diff --git a/windows/winui/LibrePods.WinUI/Strings/en-US/Resources.resw b/windows/winui/LibrePods.WinUI/Strings/en-US/Resources.resw new file mode 100644 index 000000000..9498a0124 --- /dev/null +++ b/windows/winui/LibrePods.WinUI/Strings/en-US/Resources.resw @@ -0,0 +1,224 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + + Devices + + + Connect + Disconnect + Repair connection + + + Battery + Left + Right + Case + + + Volume + Mute + On + Off + + + Adaptive Noise + Only affects Adaptive mode. + Low + Medium + High + + + Noise Control + Off + Noise Cancellation + Transparency + Adaptive + + + Features + Conversational Awareness + Adaptive Volume + Allow "Off" mode + + + Hi-res Microphone + Auto-enable on recording + Enable hi-res mic now + + + Hearing assistance + Amplify the world around you through your AirPods (Pro 3). + Experimental + Amplification without a fitted audiogram may cause feedback — keep it at a comfortable level. + Hearing aid + Amplification + Balance (left / right) + Conversation boost + Audiogram — hearing loss (dB HL) per frequency + Tone + + Heart Rate + AirPods Pro 3 only — experimental. Drains battery. + Monitor heart rate + bpm + Experimental — under test + This is a work-in-progress test and may not report any readings yet. + + + Settings + Appearance + Theme + System + Light + Dark + Language + Override the app's display language — changes immediately. + System default + Restart to apply + The language changes fully the next time you open LibrePods. + Default front-end + Choose which UI the tray's "Open App" launches next time. + Refresh state + Switch default UI to iced + Experimental + Startup + Start LibrePods when Windows starts + Experimental features + These features are experimental and may be unstable. Heart-rate monitoring in particular does not work on Windows — AirPods restrict it to Apple hosts (the buds acknowledge the request but never send readings). + Show experimental features + Device info + Model + Firmware + Serial + AirPods nearby + Connect + Not now + Case opened + Case closed + Battery low — + Case battery low — + Renamed to + Name updated + In Windows, the new name only appears after you disconnect and reconnect the AirPods. + Left + Right + Case + AirPod in case + Heart rate monitoring on + Heart rate monitoring off + Hi-res microphone on + Microphone in use — hi-res on + Microphone released — restoring stereo… + Stereo restored + About + LibrePods — native Windows client of librepodsd + + + Case + + + Connected + Disconnected + Connecting… + Waiting for daemon… + + muted + Mute + Unmute + Open + Quit + Connect + + Microphone: recording + Microphone: idle + + Off + Noise Cancellation + Transparency + Adaptive + + Noise Control + No battery data + Case + + AirPods + + Connect {0}? + Nearby — click Connect to start a session. + + Default UI changed + The iced app will be the default front-end next time you open LibrePods. + diff --git a/windows/winui/LibrePods.WinUI/Strings/es-ES/Resources.resw b/windows/winui/LibrePods.WinUI/Strings/es-ES/Resources.resw new file mode 100644 index 000000000..96b5a6f8f --- /dev/null +++ b/windows/winui/LibrePods.WinUI/Strings/es-ES/Resources.resw @@ -0,0 +1,215 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + + Dispositivos + + + Conectar + Desconectar + Reparar conexión + + + Batería + Izquierdo + Derecho + Estuche + + + Volumen + Silenciar + Activado + Desactivado + + + Ruido adaptativo + Solo afecta al modo Adaptativo. + Bajo + Medio + Alto + + + Control de ruido + Desactivado + Cancelación de ruido + Transparencia + Adaptativo + + + Funciones + Detección de conversación + Volumen adaptativo + Permitir modo «Desactivado» + + + Micrófono de alta resolución + Activar automáticamente al grabar + Activar micrófono HD ahora + + + Asistencia auditiva + Amplifica el sonido a tu alrededor a través de los AirPods (Pro 3). + Experimental + La amplificación sin un audiograma adecuado puede causar acoples — mantenla a un nivel cómodo. + Audífono + Amplificación + Balance (izquierda / derecha) + Refuerzo de conversación + Audiograma — pérdida auditiva (dB HL) por frecuencia + Tono + + Frecuencia cardíaca + Solo AirPods Pro 3 — experimental. Consume batería. + Monitorizar frecuencia cardíaca + ppm + Experimental — en prueba + Esto es una prueba en curso y puede que aún no muestre lecturas. + + + Ajustes + Apariencia + Tema + Sistema + Claro + Oscuro + Idioma + Anula el idioma de la aplicación — cambia al instante. + Predeterminado del sistema + Reinicia para aplicar + El idioma cambia por completo la próxima vez que abras LibrePods. + Interfaz predeterminada + Elige qué interfaz abre «Abrir app» de la bandeja la próxima vez. + Actualizar estado + Cambiar a la interfaz iced + Experimental + Inicio + Iniciar LibrePods al arrancar Windows + Funciones experimentales + Estas funciones son experimentales y pueden ser inestables. La monitorización de la frecuencia cardíaca, en particular, no funciona en Windows: los AirPods la restringen a equipos Apple (los auriculares confirman la solicitud, pero nunca envían lecturas). + Mostrar funciones experimentales + Información del dispositivo + Modelo + Firmware + N.º de serie + AirPods cerca + Conectar + Ahora no + Estuche abierto + Estuche cerrado + Batería baja — + Batería del estuche baja — + Renombrado a + Nombre actualizado + En Windows, el nuevo nombre solo aparece después de desconectar y volver a conectar los AirPods. + Izq + Der + Estuche + AirPod en el estuche + Monitorización cardíaca activada + Monitorización cardíaca desactivada + Micrófono HD activado + Micrófono en uso — HD activado + Micrófono liberado — restaurando estéreo… + Estéreo restaurado + Acerca de + LibrePods — cliente nativo de Windows de librepodsd + + + Estuche + + + Conectado + Desconectado + Conectando… + Esperando al daemon… + + silenciado + Silenciar + Activar sonido + Abrir + Salir + Conectar + + Micrófono: grabando + Micrófono: inactivo + + Desactivado + Cancelación de ruido + Transparencia + Adaptativo + + Control de ruido + Sin datos de batería + Estuche + + AirPods + + ¿Conectar {0}? + Cerca — haz clic en Conectar para iniciar una sesión. + + Interfaz predeterminada cambiada + La app iced será la interfaz predeterminada la próxima vez que abras LibrePods. + diff --git a/windows/winui/LibrePods.WinUI/Strings/fr-FR/Resources.resw b/windows/winui/LibrePods.WinUI/Strings/fr-FR/Resources.resw new file mode 100644 index 000000000..cbbee4515 --- /dev/null +++ b/windows/winui/LibrePods.WinUI/Strings/fr-FR/Resources.resw @@ -0,0 +1,215 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + + Appareils + + + Connecter + Déconnecter + Réparer la connexion + + + Batterie + Gauche + Droite + Boîtier + + + Volume + Muet + Activé + Désactivé + + + Bruit adaptatif + N'affecte que le mode Adaptatif. + Faible + Moyen + Élevé + + + Contrôle du bruit + Désactivé + Réduction de bruit + Transparence + Adaptatif + + + Fonctionnalités + Attention aux conversations + Volume adaptatif + Autoriser le mode « Désactivé » + + + Microphone haute résolution + Activer automatiquement à l'enregistrement + Activer le micro HD maintenant + + + Assistance auditive + Amplifiez le son autour de vous via vos AirPods (Pro 3). + Expérimental + Une amplification sans audiogramme adapté peut provoquer du larsen — gardez un niveau confortable. + Aide auditive + Amplification + Balance (gauche / droite) + Renfort de conversation + Audiogramme — perte auditive (dB HL) par fréquence + Tonalité + + Fréquence cardiaque + AirPods Pro 3 uniquement — expérimental. Consomme la batterie. + Surveiller la fréquence cardiaque + bpm + Expérimental — en test + Ceci est un test en cours et peut ne pas encore afficher de lectures. + + + Paramètres + Apparence + Thème + Système + Clair + Sombre + Langue + Remplace la langue d'affichage de l'application — s'applique immédiatement. + Par défaut du système + Redémarrez pour appliquer + La langue change entièrement au prochain lancement de LibrePods. + Interface par défaut + Choisissez l'interface que « Ouvrir l'app » de la barre d'état lance la prochaine fois. + Actualiser l'état + Passer à l'interface iced + Expérimental + Démarrage + Lancer LibrePods au démarrage de Windows + Fonctionnalités expérimentales + Ces fonctionnalités sont expérimentales et peuvent être instables. Le suivi de la fréquence cardiaque, en particulier, ne fonctionne pas sous Windows — les AirPods le réservent aux hôtes Apple (les écouteurs accusent réception de la demande mais n'envoient jamais de mesures). + Afficher les fonctionnalités expérimentales + Infos de l'appareil + Modèle + Firmware + N° de série + AirPods à proximité + Connecter + Pas maintenant + Boîtier ouvert + Boîtier fermé + Batterie faible — + Batterie du boîtier faible — + Renommé en + Nom mis à jour + Sous Windows, le nouveau nom n'apparaît qu'après avoir déconnecté puis reconnecté les AirPods. + G + D + Boîtier + AirPod dans le boîtier + Surveillance cardiaque activée + Surveillance cardiaque désactivée + Micro HD activé + Micro utilisé — HD activé + Micro libéré — restauration de la stéréo… + Stéréo restaurée + À propos + LibrePods — client Windows natif de librepodsd + + + Boîtier + + + Connecté + Déconnecté + Connexion… + En attente du démon… + + muet + Muet + Réactiver le son + Ouvrir + Quitter + Connecter + + Microphone : enregistrement + Microphone : inactif + + Désactivé + Réduction de bruit + Transparence + Adaptatif + + Contrôle du bruit + Aucune donnée de batterie + Boîtier + + AirPods + + Connecter {0} ? + À proximité — cliquez sur Connecter pour démarrer une session. + + Interface par défaut modifiée + L'app iced sera l'interface par défaut au prochain lancement de LibrePods. + diff --git a/windows/winui/LibrePods.WinUI/Strings/pt-PT/Resources.resw b/windows/winui/LibrePods.WinUI/Strings/pt-PT/Resources.resw new file mode 100644 index 000000000..e822236a5 --- /dev/null +++ b/windows/winui/LibrePods.WinUI/Strings/pt-PT/Resources.resw @@ -0,0 +1,215 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + + Dispositivos + + + Ligar + Desligar + Reparar ligação + + + Bateria + Esquerdo + Direito + Estojo + + + Volume + Silenciar + Ligado + Desligado + + + Ruído Adaptativo + Afeta apenas o modo Adaptativo. + Baixo + Médio + Alto + + + Controlo de Ruído + Desligado + Cancelamento de Ruído + Transparência + Adaptativo + + + Funcionalidades + Perceção de Conversa + Volume Adaptativo + Permitir modo "Desligado" + + + Microfone de alta resolução + Ativar automaticamente ao gravar + Ativar microfone agora + + + Assistência auditiva + Amplifica o som à tua volta através dos AirPods (Pro 3). + Experimental + Amplificação sem um audiograma adequado pode causar feedback — mantém num nível confortável. + Aparelho auditivo + Amplificação + Balanço (esquerda / direita) + Reforço de conversa + Audiograma — perda auditiva (dB HL) por frequência + Tom + + Frequência Cardíaca + Apenas AirPods Pro 3 — experimental. Consome bateria. + Monitorizar frequência cardíaca + bpm + Experimental — em teste + Isto é um teste em desenvolvimento e pode ainda não apresentar leituras. + + + Definições + Aparência + Tema + Sistema + Claro + Escuro + Idioma + Substitui o idioma da aplicação — muda de imediato. + Predefinição do sistema + Reinicie para aplicar + O idioma muda por completo da próxima vez que abrir o LibrePods. + Interface predefinida + Escolhe que interface o "Abrir app" da bandeja abre da próxima vez. + Atualizar estado + Mudar interface predefinida para iced + Experimental + Arranque + Iniciar o LibrePods com o Windows + Funcionalidades experimentais + Estas funcionalidades são experimentais e podem ser instáveis. A monitorização de frequência cardíaca, em particular, não funciona no Windows — os AirPods restringem-na a anfitriões Apple (os auriculares confirmam o pedido, mas nunca enviam leituras). + Mostrar funcionalidades experimentais + Sobre o dispositivo + Modelo + Firmware + Nº de série + AirPods por perto + Ligar + Agora não + Estojo aberto + Estojo fechado + Bateria fraca — + Bateria do estojo fraca — + Renomeado para + Nome atualizado + No Windows, o novo nome só aparece depois de desligares e voltares a ligar os AirPods. + Esq + Dir + Estojo + AirPod na caixa + Monitorização cardíaca ligada + Monitorização cardíaca desligada + Microfone hi-res ligado + Microfone em uso — hi-res ligado + Microfone libertado — a restaurar estéreo… + Estéreo restaurado + Acerca + LibrePods — cliente nativo Windows do librepodsd + + + Estojo + + + Ligado + Desligado + A ligar… + À espera do daemon… + + silenciado + Silenciar + Repor som + Abrir + Sair + Ligar + + Microfone: a gravar + Microfone: inativo + + Desligado + Cancelamento de Ruído + Transparência + Adaptativo + + Controlo de Ruído + Sem dados de bateria + Estojo + + AirPods + + Ligar {0}? + Por perto — clica em Ligar para iniciar uma sessão. + + Interface predefinida alterada + A app iced será a interface predefinida da próxima vez que abrires o LibrePods. + diff --git a/windows/winui/LibrePods.WinUI/Tray/TrayController.cs b/windows/winui/LibrePods.WinUI/Tray/TrayController.cs new file mode 100644 index 000000000..d71959a3c --- /dev/null +++ b/windows/winui/LibrePods.WinUI/Tray/TrayController.cs @@ -0,0 +1,245 @@ +using System.Drawing; +using System.Drawing.Drawing2D; +using System.Drawing.Text; +using System.IO; +using System.Runtime.InteropServices; +using System.Windows.Input; +using H.NotifyIcon; +using LibrePods.WinUI.Ipc; +using LibrePods.WinUI.Popup; +using LibrePods.WinUI.Services; +using Microsoft.UI.Xaml.Media.Imaging; + +namespace LibrePods.WinUI.Tray; + +/// The system-tray presence, built on H.NotifyIcon.WinUI. Double-click (or the +/// menu's "Open") shows the main window; right-click opens a custom themed menu +/// (TrayMenuWindow — the WinUI MenuFlyout clipped and the native Win32 menu can't +/// carry icons or follow the theme). The tooltip + icon badge are driven live from +/// the daemon's Snapshot; the icon shows the lower bud's battery % as a number. +/// +/// Because the TaskbarIcon is created outside the visual tree, we call ForceCreate(). +public sealed class TrayController : IDisposable +{ + private readonly TaskbarIcon _icon; + private readonly DaemonClient _client; + private readonly Action _onOpen; + private readonly Action _onQuit; + + // Latest snapshot, so the menu (built on right-click) reflects current state. + private Snapshot? _lastSnapshot; + private TrayMenuWindow? _menuWindow; + + // Icon lifetime: the plain LibrePods icon is persistent; each number badge is a + // freshly generated GDI icon whose HICON we must destroy when it's replaced. + private readonly Icon? _baseIcon; + private Icon? _generatedIcon; + private IntPtr _generatedHandle; + + [DllImport("user32.dll", SetLastError = true)] + private static extern bool DestroyIcon(IntPtr hIcon); + + public TrayController(Action onOpen, Action onQuit, DaemonClient client) + { + _client = client; + _onOpen = onOpen; + _onQuit = onQuit; + + _icon = new TaskbarIcon + { + ToolTipText = "LibrePods", + // Right-click opens our custom themed menu window instead of a flyout. + ContextFlyout = null, + RightClickCommand = new RelayCommand(ShowMenu), + IconSource = new BitmapImage(new Uri("ms-appx:///Assets/tray.ico")), + // Double-click opens the app; single click is left to the OS so a + // double-click isn't consumed early. + DoubleClickCommand = new RelayCommand(onOpen), + }; + + // Load the plain icon from disk for the "no number" fallback. + try + { + var path = Path.Combine(AppContext.BaseDirectory, "Assets", "tray.ico"); + if (File.Exists(path)) _baseIcon = new Icon(path); + } + catch { _baseIcon = null; } + } + + public void Show() => _icon.ForceCreate(); + + /// Open the custom tray menu at the cursor, from the latest snapshot. + private void ShowMenu() + { + try + { + try { _menuWindow?.Close(); } catch { } + var menu = new TrayMenuWindow(_client, _onOpen, _onQuit); + _menuWindow = menu; + menu.Closed += (_, _) => { if (ReferenceEquals(_menuWindow, menu)) _menuWindow = null; }; + menu.ShowAt(_lastSnapshot ?? new Snapshot()); + } + catch { } + } + + /// Refresh the tooltip + icon badge from the latest snapshot, and cache it for + /// the menu. MUST be called on the UI thread (the App marshals via DispatcherQueue). + public void UpdateSnapshot(Snapshot s) + { + _lastSnapshot = s; + + var name = string.IsNullOrWhiteSpace(s.DevName) ? "LibrePods" : s.DevName; + var left = Present(s.Battery.Left); + var right = Present(s.Battery.Right); + var @case = Present(s.Battery.Case); + + string tip; + if (!s.Connected) + { + tip = $"{name} — {Localize.Get("Status_Disconnected")}"; + } + else + { + var parts = new List(); + if (left is byte l) parts.Add($"L {l}%"); + if (right is byte r) parts.Add($"R {r}%"); + if (@case is byte c) parts.Add($"{Localize.Get("Tray_CaseShort")} {c}%"); + tip = parts.Count > 0 ? $"{name} — {string.Join(" ", parts)}" : $"{name} — {Localize.Get("Status_Connected")}"; + if (AncName(s.Anc) is string mode) tip += $" · {mode}"; + } + // Win32 NOTIFYICONDATA tooltip caps at 127 chars. + _icon.ToolTipText = tip.Length > 127 ? tip[..127] : tip; + + // Icon badge: lower present bud (min of L/R), else plain icon. + UpdateIconBadge(LowerBud(left, right)); + } + + /// A battery reading is "present" only when it's a real 0..100 value; the + /// daemon uses 0xFF (255) as an absent sentinel. + private static byte? Present(byte? value) => + value is byte v and <= 100 ? v : null; + + /// The localized noise-control mode name for anc 1..4, or null if unknown. + private static string? AncName(byte anc) => anc switch + { + 1 => Localize.Get("Anc_Off"), + 2 => Localize.Get("Anc_NoiseCancellation"), + 3 => Localize.Get("Anc_Transparency"), + 4 => Localize.Get("Anc_Adaptive"), + _ => null, + }; + + private static byte? LowerBud(byte? left, byte? right) + { + if (left is byte l && right is byte r) return Math.Min(l, r); + return left ?? right; + } + + private void UpdateIconBadge(byte? level) + { + if (level is not byte v) + { + if (_baseIcon is not null) SetIcon(_baseIcon, IntPtr.Zero); + return; + } + + try + { + var (icon, handle) = RenderNumberIcon(v.ToString(), LightTaskbar()); + SetIcon(icon, handle); + } + catch + { + if (_baseIcon is not null) SetIcon(_baseIcon, IntPtr.Zero); + } + } + + /// Assign a new tray icon and release the previously generated one (never the + /// persistent base icon, which is passed with a zero handle). + private void SetIcon(Icon icon, IntPtr generatedHandle) + { + try { _icon.Icon = icon; } catch { } + + _generatedIcon?.Dispose(); + if (_generatedHandle != IntPtr.Zero) DestroyIcon(_generatedHandle); + + _generatedIcon = generatedHandle == IntPtr.Zero ? null : icon; + _generatedHandle = generatedHandle; + } + + /// Draw the battery number as LARGE as it fits, centred on a 64px transparent + /// bitmap for a crisp tray downscale. Returns the Icon + its backing HICON. + private static (Icon icon, IntPtr handle) RenderNumberIcon(string text, bool lightTaskbar) + { + const int size = 64; + using var bmp = new Bitmap(size, size); + using (var g = Graphics.FromImage(bmp)) + { + g.SmoothingMode = SmoothingMode.AntiAlias; + g.TextRenderingHint = TextRenderingHint.AntiAliasGridFit; + g.Clear(Color.Transparent); + + using var brush = new SolidBrush(lightTaskbar ? Color.FromArgb(255, 32, 32, 32) : Color.White); + using var fmt = new StringFormat + { + Alignment = StringAlignment.Center, + LineAlignment = StringAlignment.Center, + }; + + // Shrink from a large face until the number nearly fills the box, so a + // big "9" and a big "100" both read large in the tray. + float emSize = 64f; + while (emSize > 12f) + { + using var probe = new Font("Segoe UI", emSize, FontStyle.Bold, GraphicsUnit.Pixel); + var m = g.MeasureString(text, probe); + if (m.Width <= size * 1.0f && m.Height <= size) break; + emSize -= 2f; + } + using var font = new Font("Segoe UI", emSize, FontStyle.Bold, GraphicsUnit.Pixel); + g.DrawString(text, font, brush, new RectangleF(0, 0, size, size), fmt); + } + + var handle = bmp.GetHicon(); + return (Icon.FromHandle(handle), handle); + } + + /// True when the taskbar uses the light theme (→ draw dark text). Defaults to + /// false (dark taskbar, white text) — the Windows 11 default — if unreadable. + private static bool LightTaskbar() + { + try + { + using var key = Microsoft.Win32.Registry.CurrentUser.OpenSubKey( + @"Software\Microsoft\Windows\CurrentVersion\Themes\Personalize"); + return key?.GetValue("SystemUsesLightTheme") is int i && i != 0; + } + catch + { + return false; + } + } + + /// Show a tray balloon for a daemon overlay event. + public void ShowBalloon(string title, string body) + { + try { _icon.ShowNotification(title, body); } + catch { } + } + + public void Dispose() + { + _icon.Dispose(); + _generatedIcon?.Dispose(); + if (_generatedHandle != IntPtr.Zero) DestroyIcon(_generatedHandle); + _baseIcon?.Dispose(); + } + + /// Minimal ICommand so the tray's clicks can invoke an Action. + private sealed class RelayCommand(Action execute) : ICommand + { + public event EventHandler? CanExecuteChanged { add { } remove { } } + public bool CanExecute(object? parameter) => true; + public void Execute(object? parameter) => execute(); + } +} diff --git a/windows/winui/LibrePods.WinUI/app.manifest b/windows/winui/LibrePods.WinUI/app.manifest new file mode 100644 index 000000000..b15470950 --- /dev/null +++ b/windows/winui/LibrePods.WinUI/app.manifest @@ -0,0 +1,19 @@ + + + + + + + + true/pm + PerMonitorV2 + + + + + + + + + + diff --git a/windows/winui/README.md b/windows/winui/README.md new file mode 100644 index 000000000..f0fc32d4d --- /dev/null +++ b/windows/winui/README.md @@ -0,0 +1,159 @@ +# LibrePods — WinUI 3 client (`librepods-winui.exe`) + +![The WinUI 3 client](docs/screenshot-device.png) + +A native **C# / WinUI 3 (Windows App SDK)** front-end for LibrePods. + +## Screenshots + +| | | +|---|---| +| ![Devices pane](docs/screenshot-nav.png) | ![Narrow single-column layout](docs/screenshot-narrow.png) | +| Navigation pane (devices + settings) | Responsive: one column when narrow | +| ![Settings](docs/screenshot-settings.png) | ![Tray menu](docs/screenshot-tray.png) | +| Settings (theme, default front-end) | Tray menu (battery + Noise Control + Mute) | +| ![Toast notification](docs/screenshot-toast.png) | ![Connection island](docs/screenshot-island.png) | +| Native Windows toast (ANC change) | iOS-style connection island | + It is the native Windows client, alongside a lightweight Rust tray +(`librepods-tray.exe`). Both are thin IPC clients of the same Rust daemon, +**`librepodsd.exe`**, which owns the driver, the AAP session and the hi-res mic. + +This app is **primarily a tray app**: + +- It starts **hidden to the system tray**. +- Left-click the tray icon (or **Open** in its menu) shows the main window. +- Closing the window **hides it back to the tray** — it does not exit. +- **Quit** in the tray menu exits the app. (It does *not* stop the daemon; the + Rust tray's own Quit is what shuts the daemon down.) + +## What it talks to + +Two one-directional Windows named pipes (see `../ipc/src/lib.rs`): + +| Pipe | Direction | Purpose | +|------|-----------|---------| +| `LibrePods-events` | daemon → app (read) | newline-delimited JSON events (`state` / `overlay` / `connect_prompt`) | +| `LibrePods-cmds` | app → daemon (write) | newline-delimited JSON commands (`hello`, `set_anc`, …) | + +If the daemon isn't running when the app starts, it launches the sibling +`librepodsd.exe` (from `AppContext.BaseDirectory`) and retries every 500 ms. Put +`librepods-winui.exe` next to the other LibrePods exes (the `dist/` layout). + +All pipe I/O is fully asynchronous (`ConnectAsync` / `ReadLineAsync` / +`WriteAsync`) on background loops; snapshots are marshalled to the UI thread via +`DispatcherQueue`. + +## Prerequisites + +- **Windows 10 1809 (17763)+ or Windows 11**, x64. +- **Visual Studio 2022 (17.11+)** with the **.NET Desktop Development** workload + and the **Windows App SDK C# templates** component, *or* a standalone MSBuild + plus the SDKs below. +- **.NET 10 SDK** (LTS). +- **Windows 10 SDK (10.0.19041.0)** or newer (pulled in by the build tools + package, but the matching Windows SDK must be installed). +- NuGet restore reaches nuget.org for the packages below. + +## Pinned versions + +| Component | Version | +|-----------|---------| +| Target framework | `net10.0-windows10.0.19041.0` (min `10.0.17763.0`) | +| .NET SDK | **.NET 10** (LTS) | +| `Microsoft.WindowsAppSDK` | **1.7.250606001** | +| `Microsoft.Windows.SDK.BuildTools` | **10.0.26100.4188** | +| `H.NotifyIcon.WinUI` | **2.3.0** | +| RuntimeIdentifier | `win-x64` | + +The project is **unpackaged** (`WindowsPackageType=None`) and **self-contained** +(`WindowsAppSDKSelfContained=true`, `SelfContained=true`), so the output is a +plain `.exe` with the WinUI/WinAppSDK and .NET runtimes carried alongside — no +MSIX, no machine-wide runtime install. + +> If NuGet cannot resolve an exact version above (feeds drift over time), bump to +> the newest stable of the same major/minor — the code only uses stable public +> API (`TaskbarIcon`, `MicaBackdrop`, `AppWindow`, `NamedPipeClientStream`, +> `System.Text.Json`). + +## Build + +From this `winui/` directory (the folder with the `.sln`): + +```powershell +# Restore + build (Release, x64) +dotnet build LibrePods.WinUI.sln -c Release -p:Platform=x64 +``` + +or, to produce the self-contained unpackaged output explicitly: + +```powershell +dotnet publish LibrePods.WinUI\LibrePods.WinUI.csproj -c Release -r win-x64 --self-contained true +``` + +With MSBuild directly: + +```powershell +msbuild LibrePods.WinUI.sln /t:Restore,Build /p:Configuration=Release /p:Platform=x64 +``` + +The executable is emitted as **`librepods-winui.exe`** under +`LibrePods.WinUI\bin\x64\Release\net10.0-windows10.0.19041.0\win-x64\` +(`publish\` for the `dotnet publish` command). Copy it — plus its runtime files — +next to `librepodsd.exe`. + +## Run + +```powershell +.\librepods-winui.exe +``` + +It appears in the tray, connects to (or launches) `librepodsd.exe`, and renders +the state. The **Switch default UI to iced** button writes +`%LOCALAPPDATA%\LibrePods\ui.pref` = `iced`, so the tray's "Open App" launches +the iced front-end by default next time. + +## Project layout + +``` +winui/ + LibrePods.WinUI.sln + LibrePods.WinUI/ + LibrePods.WinUI.csproj + app.manifest + App.xaml / App.xaml.cs bootstrap; wires tray + DaemonClient + MainWindow.xaml / .xaml.cs Fluent UI (Mica, light/dark aware) + Ipc/ + Messages.cs Snapshot/Battery DTOs, command DTOs, event parser + DaemonClient.cs async named-pipe client + reconnect + daemon spawn + Tray/ + TrayIcon.cs H.NotifyIcon.WinUI tray icon + Open/Quit menu + Services/ + UiPreference.cs read/write %LOCALAPPDATA%\LibrePods\ui.pref + Assets/ + app.ico tray.ico icon.png LibrePods icon (window / tray / app) + airpods.png AirPods product image for the device card +``` + +## Localization + +UI strings are externalized to `Strings/en-US/Resources.resw` (the base language). +Two mechanisms use it: + +- **XAML** labels carry `x:Uid` — the framework resolves `.Text` / + `.Content` / `.Header` against the `.resw` automatically (no code). +- **Runtime** strings (connection status, mute/unmute, the tray menu, toasts) are + fetched in code via `Services/Localize.cs` (`Localize.Get("Key")`), a thin + wrapper over the Windows App SDK `ResourceLoader`. + +To add a language, copy `Strings/en-US` to `Strings/` (e.g. `Strings/pt-PT`) +and translate the ``s — the build merges them into `resources.pri` and the +OS display language selects the match at runtime. `en-US` stays the fallback +(`` in the csproj). + +## Visual identity + +The UI follows the LibrePods look (Android app + iced app), adapted to native +Fluent: a **Mica** backdrop, rounded cards for the device/battery/sections, the +AirPods product image in the device header, and the LibrePods brand accent +(`#039BE5`, the Android app's `light_blue_600`) applied to the progress bars and +Fluent accent controls. It is light/dark-theme aware via system theme resources. diff --git a/windows/winui/docs/screenshot-device-light.png b/windows/winui/docs/screenshot-device-light.png new file mode 100644 index 000000000..dc2dee18f Binary files /dev/null and b/windows/winui/docs/screenshot-device-light.png differ diff --git a/windows/winui/docs/screenshot-device.png b/windows/winui/docs/screenshot-device.png new file mode 100755 index 000000000..695fb448a Binary files /dev/null and b/windows/winui/docs/screenshot-device.png differ diff --git a/windows/winui/docs/screenshot-hearing.png b/windows/winui/docs/screenshot-hearing.png new file mode 100644 index 000000000..2cf8f6b6f Binary files /dev/null and b/windows/winui/docs/screenshot-hearing.png differ diff --git a/windows/winui/docs/screenshot-island.png b/windows/winui/docs/screenshot-island.png new file mode 100755 index 000000000..a0b019339 Binary files /dev/null and b/windows/winui/docs/screenshot-island.png differ diff --git a/windows/winui/docs/screenshot-narrow.png b/windows/winui/docs/screenshot-narrow.png new file mode 100755 index 000000000..d61d32e67 Binary files /dev/null and b/windows/winui/docs/screenshot-narrow.png differ diff --git a/windows/winui/docs/screenshot-settings.png b/windows/winui/docs/screenshot-settings.png new file mode 100755 index 000000000..e43107c90 Binary files /dev/null and b/windows/winui/docs/screenshot-settings.png differ diff --git a/windows/winui/docs/screenshot-tray.png b/windows/winui/docs/screenshot-tray.png new file mode 100755 index 000000000..1b4e5afd8 Binary files /dev/null and b/windows/winui/docs/screenshot-tray.png differ