Skip to content

Native Windows support for AirPods (self-contained windows/ folder) - #716

Closed
arctumn wants to merge 59 commits into
librepods-org:mainfrom
arctumn:windows-native
Closed

arctumn wants to merge 59 commits into
librepods-org:mainfrom
arctumn:windows-native

Conversation

@arctumn

@arctumn arctumn commented Aug 11, 2026 •

Copy link
Copy Markdown

Update (30 Sep 2026): closing this, the port continues as NTPods

Kavish asked me not to call this "LibrePods for Windows", and he won't merge it into the main repo because it would be hard for him to maintain. That's fair, so I'm closing this PR.

The Windows port now lives in its own repo under a new name, NTPods: https://github.com/arctumn/ntpods. It's the same code with the full history. Please send Windows issues there, not here.

Heart rate works on Windows now. The fix was the L2CAP MTU on the AAP channel (the buds only publish the heart-rate service when its descriptor fits). I wrote it up in #702 in case it helps the Linux side.

Thanks Kavish for LibrePods and the protocol work, and thanks to everyone who helped in this thread and on Discord.

Original description

The short version

Native AirPods support for Windows, as a self-contained windows/ folder at the repo root, next to android/ — with zero changes to the Linux app (linux-rust) or its branch. A headless daemon owns two open-source kernel drivers (AAP L2CAP + a virtual mic) and serves a native WinUI 3 app over IPC. Everything below is developed and hardware-validated on real AirPods Pro on a Windows machine. Related to #433 (native Windows, no WSL). Supersedes #710 (which bundled a cross-platform refactor of the Linux crate).

How it compares

Front-ends (WinUI · iced-Linux · Android)

The Windows client (WinUI) next to the reference Android app and the Linux iced app. On Windows the native client is WinUI, not iced. Legend: ✓ yes · — no · ◐ partial.

Feature WinUI (Windows) iced (Linux) Android
Battery (L / R / Case) ✓ ✓ ✓
Noise control (Off / ANC / Transparency / Adaptive) ✓ ✓ ✓
Ear-detection auto-pause ✓ (SMTC) ✓ (MPRIS) ✓
Conversational Awareness ducking (host-side) ✓ ✓ ✓
Adaptive / Personalized Volume, Allow-Off ✓ ✓ ✓
Adaptive noise strength (0x2E) ✓ — ✓
Hi-res AAC-ELD microphone (system input) ✓ — —
Per-person hearing-aid audiogram (ATT 0x001F) ✓ — ✓
Heart rate (AirPods Pro 3) — live BPM, graph, session history ✓ 1 — ◐ (#702) 1
Rename device ✓ ✓ ✓
Connection island / native toasts ✓ — ✓
Localization ✓ (4 languages) — (English) ◐ (2)
System tray ✓ ✓ (ksni) n/a (Quick-Settings tile)
BLE connect-prompt ("never steal from phone") ✓ ◐ ✓
Multi-vendor (Nothing earbuds) — ✓ —

1 Verified on AirPods Pro 3 with iOS 27 firmware. The key finding: the AirPods only publish the heart-rate sensor service when its ~920-byte RTBuddy descriptor fits the AACP channel's L2CAP MTU. A 672-byte default MTU gets ACKs but no readings; Android (Fluoride) requests 1691, and so does this driver now. It likely explains "works with Bumble, not BlueZ" too (Discussion #233) — see "Heart rate" below.

vs MagicPods

The closest Windows counterpart is MagicPods — a mature, polished app worth acknowledging. This is a fair-as-I-can comparison; the point is to show what a fully-FOSS alternative brings.

LibrePods (this PR) MagicPods
License / price Free, GPL — fully FOSS (app + both kernel drivers) $9.99 Store; app open-source
Battery (L / R / Case) ✓ ✓
Noise control (Off / ANC / Transparency / Adaptive) ✓ (kernel L2CAP driver) ✓ (MagicAAP driver)
Adaptive noise strength (0x2E), Allow-Off, Adaptive/Personalized Volume ✓ partial
Conversational Awareness ducking (host-side) ✓ —
Hi-res AAC-ELD microphone as a native input ✓ (own WDM/ACX virtual mic driver) — (system HFP / 16 kHz)
Per-person hearing-aid audiogram ✓ —
Heart-rate monitoring (AirPods Pro 3) ✓ live BPM, graph, SQLite session history —
Connection island / toasts / animations ✓ ✓
Multi-vendor (Galaxy Buds / Sony / Beats) — (AirPods) ✓
Low-latency gaming mode — ✓
Driver signing test-signed (Test Mode); EV / attestation for a no-Test-Mode build Test Mode, or an attestation-signed "Official" build

Where MagicPods is ahead (multi-vendor breadth, low-latency mode, a signed no-Test-Mode build) is noted honestly. Where this project is ahead: 100% free and open — drivers included — with a hi-res mic, heart rate, a per-person hearing-aid audiogram, and host-side CA ducking, all under windows/ with no impact on the Linux app.

What works today

All hardware-validated on real AirPods Pro (Windows):

  • Battery (L / R / Case), over both AAP and BLE
  • Noise control — Off / ANC / Transparency / Adaptive
  • Ear-detection auto-pause (via SMTC)
  • Hi-res AAC-ELD microphone as a native Windows input for any app (48 kHz; auto-activates when an app records) — validated by a real recording
  • Per-person hearing-aid audiogram (8-band L/R dB HL) written over the ATT/GATT channel (PSM 0x001F)
  • Conversational Awareness ducking (host-side), Adaptive / Personalized Volume, Allow-Off, Adaptive noise strength (0x2E)
  • Heart rate (AirPods Pro 3) — 1 Hz BPM with the sensor's confidence byte gating out the PPG warm-up; a full-width graph of the last minute (min / avg / max, hover readout) and a picker for past sessions, stored by the daemon in a dedicated SQLite DB (%LOCALAPPDATA%\LibrePods\heart-rate.sqlite3)
  • Device info + rename
  • BLE proximity connect prompt (asks before connecting, never steals from your phone), plus a Repair connection action
  • A headless daemon (librepodsd) that owns the exclusive driver + AAP session and serves thin UI clients over named-pipe IPC

What doesn't work (yet), honestly

  • Heart rate is verified on AirPods Pro 3 only, and the PC has to be the buds' active device (if the iPhone holds them, they don't answer sensor requests).
  • Hearing aid is still behind the experimental opt-in.
  • The two kernel drivers require Test Mode for personal use (they're test-signed). A no-Test-Mode build would need an EV cert + Microsoft attestation signing — future work.

Heart rate — what it took

For months the AirPods ACKed every heart-rate start from Windows and never sent a reading, with the enable byte-identical to #702's working Android client. Every AAP-level difference to android/rewrite was ruled out (connect burst, features d7, country code, magic keys, seq encoding, HRM_STATE, an authenticated + encrypted channel), and so were host identity (Device ID, Class of Device), a fresh pairing and ERTM.

What isolated it was driving the same AX210 with Bumble at MTU 1691: right after the AACP connect the buds pushed metadata for services 16/14/18/19/20 and streamed BPM. Under Windows they only ever published devmotion6 (~521 B) and activity (~573 B) — the descriptors that fit in 672 bytes. Requesting a 1691-byte inbound MTU in the driver (BRB_L2CA_OPEN_CHANNEL.ConfigIn — on bthport that's the inbound direction, ConfigOut only caps what we send) made the HR descriptors arrive and BPM stream at 1 Hz on service 20 (the start goes to 84, HEARTRATE_COMMAND; 19 NAKs kIOReturnBadArgument on this firmware). Full write-up: windows/docs/heart-rate.md, and on #702.

How it's built

  • One daemon, thin clients. librepodsd owns the exclusive driver handle + the one AAP session; the WinUI app is a named-pipe IPC client. That keeps audio clean and lets the UI come and go.
  • Two kernel drivers, because Windows exposes no user-mode API for either (verified against the docs + a Winsock AF_BTH spike that returned WSAENETDOWN): AAP runs over a classic-Bluetooth L2CAP channel (PSM 0x1001) that only a kernel profile driver (BRB_L2CA_OPEN_CHANNEL) can open as a client; and there's no user-mode API to create a virtual audio device, so the hi-res mic is a WDM/ACX driver (trimmed from the MS sample to a capture-only path).
  • Decoupled from Linux. Nothing under linux-rust/ is touched. ci-windows.yml is path-scoped to windows/**, so a Windows build never gates a Linux release. FFmpeg (AAC-ELD decode) is fetched at build time (pinned + SHA256), not vendored. AAP Definitions.md is included as protocol reference.
  • CI builds everything from source, drivers included. ci-windows.yml (pinned to windows-2022) installs the SDK + WDK via winget, builds both kernel drivers and regenerates their catalogs, then builds the daemon and the WinUI app and assembles the install folder — so a driver source change always reaches the release (the WDK-via-winget approach comes from @Gab4545's fork). The committed drivers/*/prebuilt packages remain for installs straight from the repo.

Install & releases

  • Installer: windows/installer/install.ps1 (one-shot, admin) installs both drivers, copies the daemon + FFmpeg DLLs + the WinUI app, and adds them to startup (WinUI starts minimised to the tray).
  • Releases: one self-contained LibrePods-Windows.zip (installer + both CI-built drivers + daemon + WinUI + FFmpeg DLLs). Every push to windows-native refreshes a windows-nightly pre-release; pushing a windows-v* tag publishes a versioned release. PRs get the same bundle as a transient artifact.

Native WinUI 3 client

The default Windows front-end — battery, noise control, heart rate, hearing aid, hi-res mic — fully async, self-contained/unpackaged on .NET 10, with a native tray (window hides to it on close), toasts, an iOS-style connection island, and 4-language localization.

WinUI 3 client — device page (wide, 2-column, dark)

Light theme Narrow single-column
Same page in the light theme Responsive: collapses to one column when narrow
Hearing aid Settings
Per-person hearing-aid audiogram (8-band L/R) Settings (theme, language, experimental)
Tray menu Connection island
Tray menu (battery + Noise Control) iOS-style connection island

Feedback welcome — on the drivers, the daemon/IPC boundary, or anything I've framed unfairly above.

arctumn added 2 commits August 11, 2026 14:03
Adds the full native Windows stack under windows/, alongside android/ — with
zero changes to the Linux app (linux-rust) or its branch:

- KMDF profile driver (LibrePodsAAP) that opens the AAP L2CAP channel (PSM 0x1001)
  Windows won't let user-mode apps touch, bridged to user space over IOCTLs.
- WDM/ACX virtual audio driver (LibrePodsMic) exposing the AirPods hi-res AAC-ELD
  mic as a native Windows input.
- librepodsd daemon: owns the exclusive driver + AAP session and serves thin UI
  clients over named-pipe IPC — battery, ANC, ear-detection auto-pause (SMTC),
  Conversational Awareness ducking, adaptive/personalized volume, hi-res mic
  auto-activation, per-person hearing-aid audiogram over ATT (PSM 0x001F).
- Native C#/WinUI 3 client (librepods-winui) + a thin tray, both IPC clients of
  the daemon; localized (en/es/fr/pt), connection island + native toasts.
- Heart rate is implemented but blocked on current AirPods Pro 3 firmware
  (protocol changed, unmapped on every platform) — ships off behind a toggle.

Windows CI (ci-windows.yml) is path-scoped to windows/**, so it never gates the
Linux pipeline. AAP Definitions.md is included as protocol reference.
- Remove windows/dist/ (18 MB of build output, incl. the 15 MB iced librepods.exe)
  and gitignore it — CI builds these artifacts.
- Drop the "iced (cross-platform)" front-end switcher from the tray and WinUI:
  iced is Linux-only now, so the native WinUI client is the sole Windows UI, and
  the tray "Open App" launches it directly.
- Fix stale cross-platform references (le.rs, READMEs) and remove the stale
  HANDOFF handoff doc.
arctumn added 15 commits August 11, 2026 14:30
The AAC-ELD decode libs (avcodec/avutil/swresample) are downloaded by
windows/daemon/fetch-ffmpeg.sh (pinned URL + SHA256, providing both the MSVC .lib
and MinGW .dll.a import libs) instead of being committed — dropping ~28k lines of
FFmpeg headers from the repo. CI runs the fetch before building the daemon; only
the LGPL license and a short README stay tracked.
The release artifact carried only the exes — the daemon couldn't actually run
(no AAC-ELD runtime DLLs) and there were no drivers to install. Copy the fetched
FFmpeg DLLs next to librepodsd.exe and bundle the prebuilt, test-signed AAP + mic
drivers under dist/drivers/.
…tch)

The rolling `latest` tag gets rebuilt, so the pinned SHA256 broke between CI runs.
Pin to autobuild-2026-08-11-13-11 (immutable) instead.
…re-only)

The virtual mic only needs the capture path, but the driver still carried, from
the MS ACX sample, the render (speaker) circuit, save-to-disk debug recording,
and Cortana keyword detection. Remove:
- RenderCircuit.cpp, SaveData.cpp/.h, KeywordDetector.cpp/.h (files)
- the unused CRenderStreamEngine + CBufferedCaptureStreamEngine engine classes
- the CSaveData device-level work-item init/teardown in Device.cpp
- their SamplesCommon.vcxproj build entries

The live capture engine (CCaptureStreamEngine, fed by MicPipeRead) is untouched;
ToneGenerator/WaveReader remain as inert dead code for a follow-up. NOTE: the CI
doesn't build the drivers (needs the WDK), so this needs a local WDK build to
verify.
Second strip pass. The live capture engine's data comes from MicPipeRead, so the
tone-generator and wave-file-reader sources inherited from the MS ACX sample are
dead weight. Remove them and their hooks:
- ToneGenerator.cpp/.h, WaveReader.cpp/.h (files)
- the m_ToneGenerator/m_WaveReader members + wave registry settings + the
  ReadRegistrySettings method in CCaptureStreamEngine
- the CWaveReader device-level init/teardown in Device.cpp
- their SamplesCommon.vcxproj entries

PrepareHardware/ReleaseHardware now just chain to the base. Needs a WDK build.
driver-test / mic-rename / mic-test were driver bring-up utilities, not part of
the shipped product and not built by CI. Removes ~1.2k lines (incl. their
lockfiles). They live in git history if needed for debugging.
The WinUI app already carries a full system tray (H.NotifyIcon) and hides to it on
close, so the separate lightweight Rust tray became redundant once WinUI was the
only Windows UI. Remove windows/tray/ (~3.5k lines incl. its 2.5k-line lockfile),
drop its CI build step + artifact copy, and point startup.ps1 at
librepods-winui.exe. The daemon (which shares the ipc crate) is unaffected.
Completes the tray consolidation. With the Rust tray gone, the WinUI is the
always-on tray presence, so on launch it skips showing the window when passed
--tray/--minimized (startup.ps1 does). Login just puts the icon in the tray; the
window opens on demand (double-click / "Open") and hides back on close.
… formats

CircuitHelper now keeps only AllocateFormat (CaptureCircuit's only use); dropped
the render/jack/bridge/attribute-list helpers (all unreferenced). AudioFormats.h
keeps only Pcm48000c1 — the single 48kHz/mono format the capture pin advertises;
dropped the other 34 unused format definitions. ~1,560 lines. Needs a WDK build.
…Private.h)

Prune the big contiguous dead declaration blocks left in Private.h after the
capture-only strip: the Dsp / RenderMC / CaptureMC / RenderMCDsp / CaptureMCDsp
multicircuit contexts + callbacks, the Codec Render (speaker) circuit, and the
MicArray / MicrophoneHp / Speaker / HDMI declarations — all unreferenced (verified:
no .cpp/.h uses them; the live capture context types are untouched). ~577 lines.
Public.h is left alone (its dead blocks interlock with kept prototypes). Needs a
WDK build.
… on stall

Two connection-stability fixes, validated on hardware:

- Don't tear down the AAP session on the driver's flaky "State" — it reads
  not-connected whenever the buds play audio (the A2DP stream contends for the
  radio) or the channel is idle, a FALSE "gone". Trust Windows' own BT status
  instead: hold as long as the OS still sees the AirPods connected, and release
  only once it has lost them (really cased / handed to the phone). This stops the
  false teardowns whose reconnect toggled the OS audio and kicked calls.

- When the hi-res mic is engaged and the AAP channel has gone silent for a while
  (the mic uplink stalled) but the buds are still connected, rebuild the channel
  IN PLACE (drop + reopen, which re-arms START_AUDIO) WITHOUT touching the OS
  audio — so the microphone recovers without kicking the call.
Replace the pre-strip prebuilt AudioCodec.sys (45 KB) with the stripped build
(40 KB) — validated end-to-end by a real recording (clean 48 kHz mono voice).
Drop the stale audiocodec.cat; install.ps1 regenerates the catalog via inf2cat on
every install, so it isn't tracked. INF refreshed to match.
Removing windows/dist/ earlier dropped the end-user installer along with the
build output. Restore it as a tracked windows/installer/ (install.ps1 + bundled
devcon.exe), rewritten for the current architecture — installs both kernel drivers
(test-signed on the fly), copies the daemon + FFmpeg DLLs + the WinUI app to
%LOCALAPPDATA%\LibrePods, and adds the daemon + WinUI (--tray) to startup. Drops
the old references to the removed iced app, Rust tray and mic-rename tool.

CI: on a `windows-v*` tag, ci-windows.yml now assembles dist/ in the installer's
layout (driver/, driver-mic/, tools/, winui/, install.ps1, daemon + DLLs), zips it
and publishes a GitHub Release (softprops/action-gh-release). Normal branch/PR runs
still just upload the transient artifact.
…nore)

devcon creates the ROOT\AudioCodec mic device at install time; the installer needs
it bundled. Add a .gitignore negation + force-add past the global *.exe rule.
@arctumn
arctumn marked this pull request as ready for review August 11, 2026 17:15
@arctumn arctumn changed the title [Draft] Native Windows support for AirPods (self-contained windows/ folder) Native Windows support for AirPods (self-contained windows/ folder) Aug 11, 2026
arctumn added 8 commits August 11, 2026 18:26
…ay, island)

Replace the old screenshots with current ones — dark + light device pages, narrow
single-column, the per-person hearing-aid audiogram, settings, tray menu, and the
connection island. Drop the stale nav/toast shots.
…nstance

The daemon's Shutdown did a bare exit(0), which skips destructors, so the
exclusive LibrePodsAAP handle was never closed cleanly. That left the devnode
stuck in Code 38 (CM_PROB_DRIVER_FAILED_PRIOR_UNLOAD) and the next daemon failing
with "driver open FAILED" until a reboot.

- daemon: Shutdown now closes the driver handle (new Driver::close_now) before
  exit, running the L2CAP channel teardown the OS-on-exit close does not do
  reliably.
- app: on startup, gracefully shut down any orphaned daemon before spawning a
  fresh one (never a hard kill — a kill leaks the same handle), so the app always
  owns a single clean instance.
…st-privilege

The heart-rate doc claimed the gate is "Apple-host identity" and only cited the
AirPods' own DID. Update it with the full evidence and the sharper conclusion:

- all three possible host Device-IDs tested (AirPods 004C:2027:0100, Android
  004C:0000:0000, iPhone 004C:7805:1A50) — all ACK, none stream. Identity ruled out.
- independent macOS user-space probe (branch macos-hr-probe) reaches the same
  result from a host whose DID is byte-identical to an iPhone's.
- reframe: the gate is being the buds' privileged PRIMARY host, not identity.
  iOS third-party apps (Strava) don't open the AAP channel at all — they read the
  Apple-system-computed value via HealthKit; the direct AAP path we take is what's
  withheld from a non-primary host, and Windows has neither option.
… on every connect

The AAP driver opened a second L2CAP channel — the ATT/GATT channel on PSM 0x001F
used only for the hearing-aid audiogram — as a client on EVERY connect, even when
the hearing aid is never used. The buds' ATT server is dormant until hearing-assist
is enabled, so that channel just idled and was torn down ~30 s later, and each ATT
teardown on the shared ACL stalled the AAP channel: with the mic on it starved the
uplink (data_age climbing) until the mic-stall watchdog rebuilt the AAP channel,
which reopened the ATT channel — a self-feeding ~44 s drop/rebuild cycle.

Fix: don't open the ATT channel in LpConnect. Open it lazily on the first ATT write
(LpAttSend), i.e. only when the hearing aid is actually applied; it reconnects
transparently after an idle close (the remote-disconnect indication clears
AttConnected). With the hearing aid unused, the ATT channel never opens, the churn
is gone, and the AAP channel + audio stay stable (validated: channel_open stays 0,
zero mic-stall rebuilds across a session).
…default)

The Experimental section (heart-rate opt-in) was an always-visible card. Wrap it in
a collapsed Expander so it isn't immediately visible — the user must expand it to
reach the experimental toggles. Keeps HeartRateSetting's x:Name so the code-behind
is unchanged.
…he driver

The mic-uplink-stall watchdog dropped and reopened the driver (driver_cell = None →
break → reopen) whenever the AAP channel went silent for 8 s with the mic on. That
fired on ordinary call silence (you not speaking), and the repeated drop+reopen churn
tipped the OS into a code-22 disconnect and finally bricked the AAP devnode into
Code 38 (CM_PROB_DRIVER_FAILED_PRIOR_UNLOAD) — a stuck "driver open FAILED" loop.

Never drop the channel while the mic is in use. Instead re-arm the uplink in place by
re-sending START_AUDIO on the still-open channel, rate-limited to once every 5 s. A
silence becomes a harmless no-op; a real stall gets nudged back without touching the
L2CAP link, so the call isn't kicked and the driver never churns into Code 38.
Settings ▸ Experimental only gated the heart-rate card; the hearing-aid card (also
experimental, and the source of the ATT-channel instability fixed earlier) was always
visible. Generalise the opt-in: rename EnableHeartRate → EnableExperimental and gate
BOTH the heart-rate and hearing-aid cards on it (hidden by default; shown only when the
experimental toggle is on). Relabel the toggle "Show experimental features" with a
generalised warning across all four languages.
…eserved PSM

The AirPods repeatedly open an inbound L2CAP connection to PSM 0x001F (ATT/GATT) and
bthport refuses it "PSM not supported" because we register no server there. Tested
registering one (LpRegisterAttServer, from the hearing-aid M1): bthport rejects it with
STATUS_INVALID_PARAMETER (0xC000000D) — a profile driver may be an ATT client but not a
server on the reserved PSM. So the buds' inbound GATT connection is unavoidably refused
on Windows; record it next to the lazy-client note so it isn't re-attempted.
arctumn and others added 14 commits August 16, 2026 16:00
… the maintainer

The maintainer's startHr selects the sensor service from the firmware: version3 first
digit >= 8 uses HEARTRATE_COMMAND (84), older uses HEARTRATE (19). Implement the same:
hr_service(firmware) chooses 84/19 from the parsed 0x1D firmware string (defaults to
84 when unknown), and both the START and STOP now go through one aap::hr_stream(seq,
service, period) builder matching his SensorDataWX{ServiceSettings{service, setting=2,
config=0x01+interval_µs_LE}} — START at 1 Hz, STOP at interval 0, on the same service.
Decoder already accepts both 84 and 19. (Confirmed with the maintainer: standalone HR
still needs beta firmware — his own AirPods don't stream either, only his beta testers'
— so this is the correct, faithful implementation, ready for when the firmware streams.)
…rmware topology)

Per the maintainer + thibaup (Discord 2026-08-13), the HR service topology varies by
firmware: 8* sets the interval AND reports on 19; 9* sets on 84 (HEARTRATE_COMMAND) but
the readings arrive on 20 — and even the set service was seen to vary ("something weird
with apple"). Our decoder only watched 19/84, so on 9* it would MISS the reports (they
come on 20). Fix: set the 1 Hz interval on BOTH 84 and 19 (buds ignore the wrong one),
STOP likewise, and the decoder now accepts reports on 19, 20 AND 84. Drops the brittle
firmware-version guess (we don't have their exact version3 field). Standalone HR still
needs beta firmware, but this is now robust across the known service layouts.
The Windows docs had drifted from the code:

- README: dropped the "no system-volume control yet" claim (the daemon owns
  volume via WASAPI), led the install with installer/install.ps1 and the
  committed prebuilt driver packages instead of "build it with VS+WDK",
  documented the mic driver install, and refreshed the feature/tray-menu list.
- docs/README.md: dead link to a HANDOFF.md that doesn't exist, wrong relative
  paths, and links to protocol files that were moved/renamed.
- docs/daemon-ipc/PLAN.md: described an architecture with librepods-tray.exe +
  the iced librepods.exe, neither of which exists on Windows anymore; volume is
  daemon-side, not client-side; Phase 3 was resolved by the WinUI client.
- docs/hires-mic/PLAN.md: the trailing "phases 3 and 4 to follow" contradicted
  the DONE entries above it; noted the pipeline moved into the daemon and the
  cushion value in the shipped code.
- docs/heart-rate.md: reframed as "no working version on Windows yet" and
  recorded the tested unit/firmware.
- drivers/aap: status said on-hardware testing was "the next step"; install now
  starts from the prebuilt package.
- drivers/mic: written in future tense with unticked boxes for work already
  done; fixed two broken links and the build command.
- winui: removed references to the retired Rust tray.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QYDBL3zcBRDBLispNbL7mN
… pruned)

The Windows CI broke with `curl: (22) 404` fetching FFmpeg: BtbN keeps only the
last ~14 daily autobuilds plus the last build of each month, and we were pinned
to a mid-month tag (autobuild-2026-08-11-13-11), which was deleted.

Re-pinned to autobuild-2026-07-31-14-10 — same FFmpeg build
(n7.1.5-12-g1fdbca85aa, LGPL shared) and the same av*-NN.dll SONAMEs, but from a
month-end tag, which BtbN retains for ~2 years. Documented that rule in the
script's header so the next version bump doesn't repeat this, and made the
download failure explain what to do instead of just exiting 22.

Verified locally: downloads, checksum matches, and the .lib/.dll.a import libs
plus avcodec-61/avutil-59/swresample-5 DLLs land in vendor/ffmpeg as before.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QYDBL3zcBRDBLispNbL7mN
…session

Root cause, proven live over the l2cap-tx pipe: sending `STOP_AUDIO` (leaving
hi-res mic mode) is what leaves the AirPods rendering audio on ONE SIDE only.
It self-heals after minutes; re-sending `START_AUDIO` restores stereo instantly,
which proves mic mode itself is fine and only the EXIT is broken.

So we never leave mic mode during a session. Clearing `mic_on` is enough: the
receive loop stops decoding the 0x58 uplink and the virtual mic goes silent,
which is what "off" means to the user. The buds keep the uplink armed until the
session ends (a disconnect resets them anyway).

This also retires `a2dp::reset()`. It never fixed a mono A2DP negotiation —
btvs captures show every AVDTP SET_CONFIGURATION and all 2891 SBC frames are
0x03 JointStereo, always, with no RECONFIGURE and no SCO/HFP anywhere. It only
"worked" because it rebuilt the audio link right after the bad exit, at the cost
of killing the AAP channel and bricking the driver into Code 38. The comment
atop a2dp.rs claiming playback "degrades to mono/right" is wrong and predates
the virtual-mic driver. Dropping it also removes a ~2.65 s dropout per release.

Why only we hit this: Linux sends STOP_AUDIO too, but PulseAudio switches the
card into HFP for mic use and back to A2DP after, and that profile switch
rebuilds the stream and wipes the bad state. macOS never takes this path — its
mic rides HFP/eSCO with a custom codec (AT+BCS=128) and is call-scoped. Our
virtual mic deliberately never leaves A2DP, so we are the first to get stuck.

Also fixes the zombie AAP channel. The channel can sit OPEN but silent for as
long as Windows still sees the AirPods (observed: 56 minutes) — `status_fails`
resets every pass, so the loop never releases or rebuilds, and the in-place
re-arm was gated behind `mic_on`. Attached to the PC, but not to the daemon.
Now: after 30 s of silence with the mic off we set `link_stale` and nudge in
place by re-asserting the noise mode we already believe is active (a write the
buds accept, nothing audible, and a live link answers with a 0x0D that clears
the flag). We deliberately do NOT drop + reopen the driver — that is what
"Repair connection" does, and repeated churn is what bricks it into Code 38.

`link_stale` rides the Snapshot (serde/JSON default, so older snapshots load).
The app now offers Repair while disconnected OR stale, and paints the status dot
orange in that state. Before, `connected` stayed true forever, so the Repair
button was hidden exactly when it was needed and the only way to surface it was
to turn Bluetooth off.

Mic card text now describes what it actually does, and the "Microphone released"
notification is gone: nothing is released any more, so announcing it was both
wrong and noise.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UAAKZFpHoNY9pskrdvLKaP
The teardown BRBs — CLOSE_CHANNEL for the AAP and ATT channels, then
UNREGISTER_SERVER — were submitted from EvtDeviceReleaseHardware. WDF stops
the default I/O target *before* calling ReleaseHardware, so every one of them
failed at WdfRequestSend and never reached bthport. Silently: all three call
sites discarded the result with (VOID).

bthport was therefore left holding our PSM 0x001F server registration and the
open channels, referencing a driver that was going away. That stale reference
pins the whole Bluetooth branch. Measured 2026-08-31: with the LibrePodsAAP
service already STOPPED, `pnputil /restart-device` answered "System reboot is
needed to complete configuration operations!" for our devnode AND for the Intel
radio above it. Nothing short of a reboot cleared it.

Move the teardown to EvtDeviceSelfManagedIoSuspend, which runs while the target
is still started, and log each status instead of dropping it so the debug build
shows whether the BRBs land. ReleaseHardware keeps only the
InterfaceDereference, plus an idempotent repeat of the teardown for the paths
where Suspend is not reached.

Also drop the interface reference in EvtCleanupCallback. That runs on every
path, including ones where ReleaseHardware was skipped or bailed out early, and
a reference still held there pins the BTHENUM parent — and with it the radio —
for good.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ljr4VfCfTNYw244k8cnbEV
…e AirPods

The reported bug: a notification or a call on an iPhone takes the buds, and the
PC never gets its audio back — "tenho que desligar e voltar a ligar, mesmo em
chamada, que nao recupera". Several independent faults, each measured against
daemon.log on 2026-08-31.

DETECTION. `run_receiver` decided whether to release with
`bt::find_airpods().is_some()`, which does not answer the question: it
enumerates with fReturnRemembered and never reads fConnected, so it is Some for
any *paired* device. The branch was always taken, `status_fails` pinned at 0,
the session never released, `connected` stayed true, the BLE watcher (which only
scans while disconnected) never re-armed, and nothing ever asked for the audio
back. Signature: `fails=0` beside a data_age past 42 s. `bt::is_connected` asks
the real question.

ORDERING. `set_audio_connected` ran on four unsynchronized threads and
BluetoothSetServiceState blocks for seconds, so a DISABLE issued before an
ENABLE could land after it — buds connected on AAP, audio services off:

    08:47:49 Disconnect             -> queues DISABLE
    08:47:50 auto-connecting        -> queues ENABLE
    08:47:56 auto-connect  = true   <- ENABLE done
    08:48:01 disconnect    = true   <- DISABLE done 4 s LATER

`bt::request_audio` serializes them and drops any request a newer one overtook.
It also keeps the Win32 codes; the bool collapsed ERROR_SUCCESS, access-denied
and ERROR_INVALID_PARAMETER into "false" and made the path undebuggable. That
mattered: the codes showed BluetoothSetServiceState returns 87 whenever the
device is not connected and 0 whenever it is, which is why the first reclaim
campaign — which only ever called it while the buds were away — could not work.

PRIORITY. `Tier` encodes the hierarchy Apple routes by: 1 calls, 2 user-started
playback, 3 background/system sounds. A phone notification is tier 3 and must
not outrank a tier-1/2 session here, so a loss while we hold 1 or 2 starts a
bounded reclaim campaign; at tier 3 we let go as before. Tier 1 is the virtual
mic in use; tier 2 is SMTC *or* an active WASAPI render session, because Teams,
Discord and games never register with SMTC and read as silence there.

REPAIR. Reconnecting the AAP session is not enough — Windows keeps the device,
endpoint and profile healthy while the audio plays elsewhere, so it never
rebuilds the stream. `force_audio_rebuild` toggles the audio services, which is
exactly what the user does by hand, and it hangs off the handshake (where the
device is connected) rather than off the loss (where the call returns 87). Armed
for every tier: most losses happen at tier 3, and those were the ones that never
came back.

RESUMING. On a real phone call the buds stay away for minutes: the ~18 s of
connect retries ran out, `give_up` cleared connect_requested, and nothing tried
again. The BLE watcher could not help — it re-arms only after a 45 s absence,
and during the call they advertise throughout. Measured: released 14:37:05, gave
up 14:38:17, session back only at 14:38:33 because the user pressed Connect. The
idle gate now also resumes when Windows has the AirPods again, which steals
nothing; an explicit Disconnect still holds us off.

KEEPALIVE. The worst case leaves no passive signal at all: on 2026-08-31 the
sound died for ten and a half minutes with driver.status() at Ok(2) every
second, the device connected and the endpoint OK. But an AAP command is
acknowledged, so we can ask instead of watch. Every 30 s, and only while this
host is actually playing, re-assert the current noise mode and wait 5 s for any
inbound packet; two unanswered probes trigger the rebuild. The same poke fires
on the silence->playing edge, to pre-empt the fault rather than detect it.

Plus a 60 s heartbeat logging the real AAP data age. All the diagnostics lived
inside the `else` branch of driver.status(), so a healthy-looking status made us
blind by construction.

RepairConnection gains a five-second debounce — each press drops and reopens the
exclusive handle, and the log shows four in two seconds, which is the churn that
strands the devnode in Code 38. `devnode.rs` recovers from that without a
reboot: fire an on-demand elevated scheduled task (the mic-rename pattern) with
`schtasks /run`, rate-limited; the task re-checks the devnode and no-ops when it
is healthy.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ljr4VfCfTNYw244k8cnbEV
Windows cannot see multipoint: it only knows about its own link, and during a
handoff the endpoint, the Bluetooth device and the AAP channel all keep
reporting healthy while the audio plays on a phone. The buds are the only party
that knows, and they say so — in one capture the phone appeared in AAP 0x2E 3.2
seconds before Windows noticed anything.

`parse_connected_devices` reads that list. Each entry is a 6-byte address in
big-endian (so it compares directly with a BLUETOOTH_ADDRESS) plus two flag
bytes. A first version read the address little-endian, which produced this
machine's own radio byte-reversed and so never matched itself.

The flag bytes are deliberately NOT interpreted. A first version guessed a
device class from them, on one capture where this PC was `02 06` and a phone
`01 00`; more data killed it — over 23 packets the same PC appeared as `02 06`
(15x), `01 06` (4x) and `02 04` (4x). A value that changes for a fixed device is
not its class. `flags_hex` keeps them in the log so more samples can settle what
they are, and no label is inferred: an iPhone shown as "Computer" is worse than
an honest "Other device".

`route_diag` dumps the routing/stream opcodes the daemon does not interpret
(0x2B Stream State Info, 0x2E, 0x44 Smart Routing 2.0, 0x52 Source Context), so
a reproduction shows which one announces a handoff.

`volume::any_render_active` walks the Core Audio render sessions on the default
endpoint. SMTC only sees apps that register a media transport session — Teams,
Zoom, Discord and games never do, and read as silence there. The system-sounds
session is skipped on purpose: that is Apple's tier 3, and Core Audio hands us
exactly that distinction through IsSystemSoundsSession, so a Windows chime does
not make us fight a phone over a ding of our own.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ljr4VfCfTNYw244k8cnbEV
…dio" button

Two things the user could not see or do.

A "Shared with" card lists every host the AirPods report themselves connected to
(AAP 0x2E) — this PC plus whatever else holds them. It is the only place that
sharing is visible, since Windows knows nothing about multipoint. Hidden until
the first 0x2E arrives. Identification goes from the most trustworthy source
down: our own radios ("This PC"), then a device paired to this machine (Windows'
own name and Class of Device), then "Other device" with its address. A phone
sharing the buds is normally not paired here, so it lands on the last one; the
0x2E flag bytes are not a device class (see the parser) and naming it "iPhone"
would be invention.

"Restore audio" toggles the AirPods' audio services — the one-click version of
Disconnect then Connect, which is the only thing that reliably brings the sound
back. It deliberately does not touch the AAP session: in the failure it exists
for, the session is perfectly healthy and rebuilding it would be both useless
and disruptive. Shown only while connected, which is the opposite case to
"Repair connection".

Strings in all four shipped languages.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ljr4VfCfTNYw244k8cnbEV
prebuilt/ exists so the driver can be installed without a WDK, so it has to
follow the source. Rebuilt from windows/drivers/aap at Release; the catalog is
regenerated over it (inf2cat reports no signability errors).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ljr4VfCfTNYw244k8cnbEV
…oducible

The dist folder had drifted into a second, older copy of the installer: its
install.ps1 still deployed the retired iced app and the old tray, knew nothing
about the daemon or the WinUI app, and pointed startup at librepods-tray.exe.
Every fix had to be made twice, and the copy under version control was not the
one being installed.

make-dist.ps1 assembles a dist from THIS repository and is now the only
supported way to produce one. Everything but the two built artifacts comes
straight out of the tree: the installer, the recovery script, devcon, and both
prebuilt driver packages. It also copies librepods-winui.pri back into the WinUI
publish output — that publish drops the app's own resource index, and without it
the app starts with no strings and no icons.

install.ps1 gains fix-driver.ps1 and its elevated on-demand task, so the daemon
can recover the AAP devnode from Code 38 without a UAC prompt and without a
reboot (see daemon/src/devnode.rs). The script decides for itself whether to
act, no-ops on a healthy devnode, and self-elevates when run by hand.

fix-driver.ps1's escalation order is measured, not guessed. Restarting our own
devnode is tried first; then the Bluetooth RADIO, because the stale PnP state
lives in the branch above our leaf node — with the LibrePodsAAP service already
STOPPED, `pnputil /restart-device` still answered "System reboot is needed" for
the leaf. remove-device is demoted to last: on its own the rescan rebinds into
the same stale branch and lands back on Code 38, which only obscures the
diagnosis. When PnP reports CM_PROB_NEED_RESTART the script stops and says a
reboot is genuinely required rather than churning the device further.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ljr4VfCfTNYw244k8cnbEV
…releases

The CI zip was not installable on a clean PC: the mic package had no
catalog (install.ps1 generated it with inf2cat, which only exists with the
WDK), devcon's failure went unnoticed so the installer still said "Done",
fix-driver.ps1 and the mic-rename helper were missing, the msvc daemon
needed VCRUNTIME140.dll, and signing needed signtool from the Windows SDK.

- drivers/mic/prebuilt: commit audiocodec.cat (inf2cat /os:10_X64).
- install.ps1: preflight (admin, Test Mode active in the running boot,
  every bundled file present); sign with Set-AuthenticodeSignature instead
  of signtool; check pnputil/devcon exit codes and that ROOT\AudioCodec
  exists; reuse the test cert; stop running apps before copying; unblock
  the installed files; register the "LibrePods Rename Mic" task too.
- rename-mic.ps1: replaces lp-mic-rename.exe (source was never committed).
  Reads micname.txt, matches the endpoint by ROOT\AudioCodec, sets
  DeviceDesc, restarts AudioEndpointBuilder only when it changed.
- make-dist.ps1: -DaemonExe/-WinUIDir, copies rename-mic.ps1, fails when
  the folder is incomplete or the daemon imports the VC++ runtime.
- ci-windows.yml: static CRT daemon, assemble via make-dist.ps1,
  contents: write, rolling "windows-nightly" pre-release on every push to
  windows-native and a versioned release on windows-v* tags.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RVj5nnJjbdbsJpYPrdJKUB
68d9d0e refreshed LibrePodsAAP.sys + librepodsaap.cat, but the catalog was generated against the 1.0.4.0 INF (DriverVer 08/08/2026) while prebuilt/ kept the 1.0.0.0 one, so the committed package failed its catalog hash check. Restore the matching INF (the one in drivers/aap/, and what a working install has in its DriverStore).

Also mark prebuilt INFs -text: the Windows CI checkout converted it to CRLF, which changes the hash too. make-dist.ps1 now fails when an INF is not in its catalog, so this cannot ship again.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RVj5nnJjbdbsJpYPrdJKUB
The uplink writes on the AirPods' schedule and the ACX capture timer reads on
its own, with no drift correction between them. "Drop the oldest byte when
full" was the only backpressure, so a write side even marginally the faster of
the two filled the ring within seconds and then sat pinned there — the jitter
cushion became a fixed delay equal to its whole capacity, ~1.36 s.

Bound the steady state instead of the worst case: once the backlog passes ~64
ms, trim it back to ~32 ms in one step. The ring itself drops to ~341 ms, which
now only caps the worst case, and the trim never leaves less than two capture
packets buffered — the stream engine publishes its packet size for that, or a
client that pulls 40 ms at a time would underrun on every read. The trim moves
whole samples only; it is the one place that could shift the ring by half a
16-bit frame and garble everything downstream of it.

Diagnosis and the original fix by @Gab4545 (Gab4545/librepods).

Note the shipped driver is the prebuilt package, so this needs a WDK rebuild
plus a regenerated catalog before it reaches anyone.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
miner7222 added a commit to miner7222/librepods that referenced this pull request Sep 27, 2026
A bud charging inside its case reports battery status 0x05, not 0x01
(documented in librepods-org#716). The popup, battery indicator
and widgets already accepted both, but the service compared against
CHARGING alone, so with 0x05:
- both buds seated in the case no longer disconnected audio
- the system's untethered battery metadata showed them not charging
- the notification dropped its charging mark and the charge-complete
  check never saw them charging

Put the rule in BatteryStatus.isCharging and use it in every check.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
arctumn and others added 14 commits September 29, 2026 21:18
… heart rate works

The AAP channel was opened with ConfigIn/ConfigOut.Flags == 0, so bthport sent a
Configure Request with no MTU option and the inbound MTU stayed at the 672-byte
L2CAP default. The AirPods only publish the RTBuddy sensor services whose
descriptor fits that MTU: devmotion6 (~521 B) and activity (~573 B) did, the
HEARTRATE/HEARTRATEv2 descriptors (~920 B) never did — so service 19 answered
kIOReturnBadArgument, 84/20 ACKed but never streamed, and no BPM ever arrived.

Android (Fluoride) and Bumble both request MTU 1691. Driving the same AX210 with
Bumble at MTU 1691 made the buds push the HR descriptors immediately and stream
BPM; setting the same MTU here fixes Windows (verified in the app: 1 Hz readings on
service 20, confidence ~235).

On bthport, ConfigIn is the inbound direction (it becomes the MTU option of OUR
Configure Request); ConfigOut only caps what we send — confirmed on the air with an
HCI ETW capture. The channel is also opened authenticated + encrypted, mirroring
upstream android/rewrite; heart rate was verified in this configuration, but whether
it strictly needs those flags is not yet isolated.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ng stream

- hr.rs: drop PPG warm-up readings by gating on the confidence byte (payload[2]
  >= 0x80; ~20 while warming up, 160-240 once locked) instead of trusting the
  30-220 range alone, and accept the two status tails the iOS 27 firmware added
  (10 00 80, 20 80 00; upstream PR librepods-org#702).
- aap.rs: stream 0x0E is the ACTIVITY classifier (its RTBuddy descriptor says
  "activity"), not head tracking — renamed to STREAM_ACTIVITY.
- main.rs: stop sending a STOP to 0x0E before enabling heart rate (it only turned
  off the activity classifier; HR streams fine beside motion services), give the
  first sample 12 s (warm-up is now filtered, first locked sample lands ~6-8 s),
  and update the comments that said the buds never stream heart rate.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
windows: heart rate works — request a 1691-byte MTU on the AAP channel
…and what was ruled out

heart-rate.md said there was no working heart rate on Windows and blamed a
host-privilege gate. Rewrite it around what was actually found: the AAP channel's
672-byte inbound MTU kept the AirPods from publishing the ~920-byte HEARTRATE
descriptors; requesting 1691 (ConfigIn on bthport) fixes it. Document what the
daemon sends (84 → reported on 20, 19 NAKs on iOS 27 firmware) and decodes (BPM
byte 1, confidence byte 2 gated at 0x80, tails), keep the ruled-out list, and
note that 0x0E is the activity classifier. Update the README and docs index.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…on and app

The pipeline only built the daemon and the WinUI app and bundled the committed
prebuilt driver packages, so a driver source change (the AAP channel's 1691-byte
MTU that heart rate needs) never reached the install folder or the nightly.

Build the AAP (LibrePodsAAP.vcxproj) and hi-res mic (AudioCodec.sln) drivers on
the runner with the WDK installed via winget, regenerate their catalogs with
Inf2Cat, and overwrite windows/drivers/*/prebuilt so make-dist.ps1 bundles the
fresh builds. The WDK's own post-build Inf2Cat is disabled for the AAP project
(it runs before the .sys sits next to the .inf); the catalog is made afterwards.
Pinned to windows-2022: windows-latest's VS2026 lacks WDK integration for the
WindowsKernelModeDriver10.0 toolset. PRs touching the workflow now run it too.

WDK-via-winget approach and the windows-2022 pin come from the Gab4545/librepods
fork, which built the mic driver this way.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…t may not work

Add the recent-readings graph from upstream PR librepods-org#702's HeartRateMiniGraph to the
heart-rate card: the last 24 readings, scaled around their centre with a 20 bpm
minimum span, a faint bar per reading, the line, and a dot on the newest; three
guide lines while there's no data. The daemon only pushes a snapshot when the BPM
changes, so the card samples the latest value once a second to follow the
stream's 1 Hz cadence.

The InfoBar is now informational ("works on AirPods Pro 3, the first reading
takes a few seconds") in en-US/pt-PT/es-ES/fr-FR, with a caption for the graph,
and DevicePage's comment no longer claims heart rate can't work on Windows.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Every validated reading is stored in %LOCALAPPDATA%\LibrePods\heart-rate.sqlite3
(its own file, WAL):

- sessions(id, device, started_at, ended_at): opened by the first reading, closed
  when HR is switched off, the buds disconnect or the user disconnects; another
  pair of buds starts its own session.
- samples(id, session_id, ts, bpm, confidence): Unix ms, BPM and the sensor's
  confidence byte (the decoder now returns it alongside the BPM).

Writes go through a channel to one writer thread so the AAP receive loop never
waits on the disk; if the file can't be opened, tracking is disabled with a log
line and heart rate itself keeps working. rusqlite with `bundled` compiles SQLite
in, so nothing has to be installed. Verified: 1 Hz rows (72-81 bpm, confidence
236-239) in a live session.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…rtifact and release asset

The install folder already carries both drivers built in the run; also upload
them on their own (librepods-windows-drivers artifact) and attach
LibrePods-Windows-drivers.zip to the nightly and versioned releases, so the
drivers can be installed or updated separately.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
windows: build the drivers in CI, heart-rate docs, and the HR graph in the app
…s, and no longer experimental

- The graph now spans the card: the last minute (60 readings) with the scale's
  BPM on a left axis and dashed guides, min / avg / max next to the current BPM,
  and the reading under the pointer on hover (value + how long ago).
- A "Readings" picker switches the graph to a past session read (read-only) from
  the daemon's heart-rate.sqlite3 via Microsoft.Data.Sqlite: every reading
  stretched across the width (averaged into buckets when there are more readings
  than pixels), its stats, and the time of each point on hover.
- Heart rate is no longer experimental: the card is always on the device page (the
  Settings ▸ Experimental opt-in now only gates hearing aid), the warning InfoBar
  is gone, and the subtitle / settings text no longer say it may not work.
  Strings in en-US, pt-PT, es-ES and fr-FR.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… drivers

- Features: heart-rate monitoring on AirPods Pro 3 (live BPM, graph, history).
- Install: download from the windows-nightly release; LibrePods-Windows-drivers.zip
  for the drivers alone; committed prebuilt packages can lag the source.
- Building: CI builds both drivers (windows-2022, WDK via winget).
- New "Heart rate" section: the card, the Readings picker, the SQLite history DB,
  and that the PC must be the AirPods' active device.
- Uninstall: app data lives in %LOCALAPPDATA%\LibrePods.
- heart-rate.md: the card is no longer behind Settings ▸ Experimental.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- "Also on Windows" line under the banner and a Windows CI badge.
- Feature table: a Windows column (listening mode, ear detection, battery,
  rename, conversational awareness, auto-connect, heart rate and high-quality
  two-way audio ✅; hearing aid 🧪 experimental; multipoint ⚪ like the others),
  with 🧪 added to the legend.
- Heart rate: works on Windows, and why — the AirPods only publish the HR sensor
  service when its ~920-byte descriptor fits the AACP channel's L2CAP MTU.
- High-quality two-way audio: the Windows virtual-microphone driver.
- Installation link for Windows (needs Test Mode) and the Windows registry way to
  spoof the DID VendorID; MagicPods now listed as the paid, no-Test-Mode option.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…hing

The separate LibrePods-Windows-drivers.zip only duplicated the driver\ and
driver-mic\ folders the install zip already has (same SHA-256), and two
downloads confused people. Drop it (and its artifact) so each release has a
single zip with the app, the daemon and both CI-built drivers; the release notes
say so, and the README points driver-only installs at the zip's folders.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
windows: land the HR card v2 + READMEs that #10 missed, and ship one release zip
@arctumn arctumn closed this Sep 30, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant